From 97db94c9bf6fa5d83f94c8fff85566d7fc96276e Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:45:33 +0300 Subject: [PATCH 001/144] fix(web): keep pull request panel within viewport (#6451) --- .../src/components/preview/PreviewPanelShell.test.ts | 12 +++++++++++- .../web/src/components/preview/PreviewPanelShell.tsx | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts index 4ac086157a2f..31258b166bdf 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -1,6 +1,8 @@ +import { jsx } from "react/jsx-runtime"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { getPreviewPanelMaxWidth } from "./PreviewPanelShell"; +import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell"; describe("getPreviewPanelMaxWidth", () => { it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => { @@ -10,4 +12,12 @@ describe("getPreviewPanelMaxWidth", () => { it("rounds fractional CSS pixels down", () => { expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); }); + + it("keeps inline panels inside their containing workspace", () => { + const markup = renderToStaticMarkup( + jsx(PreviewPanelShell, { mode: "inline", defaultWidth: 1_000, children: "Panel" }), + ); + + expect(markup).toContain("max-w-full"); + }); }); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 30a0c9eed0ff..17ca389feab2 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -51,7 +51,7 @@ export function PreviewPanelShell(props: { return (
Date: Thu, 13 Aug 2026 10:26:36 -0400 Subject: [PATCH 002/144] Add bil0000 to VOUCHED contributors list (#6462) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 29910f522516..71e576e5c7e4 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -38,3 +38,4 @@ github:jappyjan github:justsomelegs github:UtkarshUsername github:SunkenInTime +github:bil0000 From 2ab188f1c0afe99ba269739704e2e96f0bbbe78f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:51:31 +0000 Subject: [PATCH 003/144] fix: ignore pull request actions in latency tracker (#6476) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/web/src/rpc/requestLatencyState.test.ts | 10 ++++++++++ apps/web/src/rpc/requestLatencyState.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index e5b3144d2520..68433035fd18 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -59,6 +59,16 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))( + "ignores pull request workspace request %s", + (method) => { + trackRpcRequestSent("1", method); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }, + ); + it("keeps ignoring untracked methods when a display tag is supplied", () => { trackRpcRequestSent( "1", diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 4736d3783c3b..4ec5b56f9e2b 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -49,7 +49,11 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(method: string): boolean { - return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method); + return ( + !method.includes("subscribe") && + !method.startsWith("pullRequests.") && + !untrackedRpcAckMethods.has(method) + ); } function rpcAckThresholdMs(method: string): number { From 9e201941aaa9cfece3e0ffaa4cc24bbe880d1be4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 13 Aug 2026 17:04:42 +0200 Subject: [PATCH 004/144] Remove rebase requirement before opening PR (#6479) --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1b41f833ce58..12f357747991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,6 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Never make a PR unless the developer explicitly asks you to do so. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. -- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. - UI changes need before/after images. Motion or timing needs a short video. - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. From fd51561b4e2de1893cb7eb4069937256d702572c Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 13 Aug 2026 19:00:03 +0100 Subject: [PATCH 005/144] fix(mobile): extend blockquotes across wrapped lines (#6482) --- .../src/NativeMarkdownBlock.ios.tsx | 31 ++++++++--- .../src/SelectableMarkdownText.ios.tsx | 1 + .../src/nativeMarkdownText.ts | 1 + .../mobile/src/lib/nativeMarkdownText.test.ts | 54 ++++++++++++++++++- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index e6a045b3cd97..5fbe6d4dff44 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,16 +4,13 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, -} from "./nativeMarkdownText"; +import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, NativeMarkdownTextStyle, + SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; type HighlightedCode = ReadonlyArray>; @@ -48,12 +45,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { function SelectableNode(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { return ( @@ -322,6 +320,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { function NativeTable(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -359,7 +358,7 @@ function NativeTable(props: { }} > + runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) => rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} @@ -376,6 +375,7 @@ function NativeTable(props: { function NativeMarkdownImage(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -384,6 +384,7 @@ function NativeMarkdownImage(props: { return ( @@ -445,6 +446,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { function NativeMixedParagraph(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -455,6 +457,7 @@ function NativeMixedParagraph(props: { @@ -462,6 +465,7 @@ function NativeMixedParagraph(props: { @@ -473,6 +477,7 @@ function NativeMixedParagraph(props: { function NativeList(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -534,6 +539,7 @@ function NativeList(props: { ; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -566,6 +573,7 @@ export function NativeMarkdownBlock(props: { @@ -595,6 +604,7 @@ export function NativeMarkdownBlock(props: { return ( @@ -624,6 +634,7 @@ export function NativeMarkdownBlock(props: { child.type === "image") ? ( ) : ( @@ -673,6 +687,7 @@ export function NativeMarkdownBlock(props: { > @@ -690,6 +705,7 @@ export function NativeMarkdownBlock(props: { diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ada..7860ff592a69 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -69,6 +69,7 @@ export function SelectableMarkdownText({ chunk.kind === "rich" ? ( { ]); }); + it("decorates known skill references inside blockquotes", () => { + const node: MarkdownNode = { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }); + }); + it("leaves unknown skill-like text unchanged", () => { const node: MarkdownNode = { type: "document", @@ -328,7 +347,7 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); - it("includes quotes and fenced code in the same selectable string", () => { + it("preserves quotes and fenced code in document runs", () => { const node: MarkdownNode = { type: "document", children: [ @@ -414,6 +433,39 @@ describe("nativeMarkdownListItemBlocks", () => { }); describe("nativeMarkdownDocumentChunks", () => { + it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { + const blockquote: MarkdownNode = { + type: "blockquote", + beg: 0, + end: 120, + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + content: + "Persistent random per-result keys are the strongest design, even when this text wraps.", + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentChunks({ + type: "document", + children: [blockquote], + }), + ).toEqual([ + { + kind: "rich", + key: "rich:blockquote:0:120", + node: blockquote, + }, + ]); + }); + it("keeps headings and plain lists in one selectable document", () => { const document: MarkdownNode = { type: "document", From 83ad26c3a3aeb877ee8bf07c05145ad804717e2e Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 13 Aug 2026 21:08:13 +0200 Subject: [PATCH 006/144] fix(mobile): prevent invalid HTML entities from crashing markdown (#6495) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../t3-markdown-text/src/nativeMarkdownText.ts | 11 +++++++++-- apps/mobile/src/lib/nativeMarkdownText.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 719070f3dcd3..8db904b5a6ca 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -70,15 +70,22 @@ const EMPTY_CONTEXT: RunContext = { const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; +function decodeCodePoint(codePoint: number, entity: string): string { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return entity; + } + return String.fromCodePoint(codePoint); +} + function decodeHtmlEntitiesOnce(value: string): string { return value.replace( /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { if (decimal) { - return String.fromCodePoint(Number.parseInt(decimal, 10)); + return decodeCodePoint(Number.parseInt(decimal, 10), entity); } if (hexadecimal) { - return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); } switch (entity.toLowerCase()) { case "&": diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 5ad2cca26f07..867d9e983017 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -126,6 +126,22 @@ describe("nativeMarkdownTextRuns", () => { ]); }); + it.each([ + ["😀", "😀"], + ["🚀", "🚀"], + ["�", "�"], + ["�", "�"], + ["&#9999999999;", "�"], + ["&#x110000;", "�"], + ])("normalizes numeric entity %s without throwing", (content, expected) => { + const node: MarkdownNode = { + type: "paragraph", + children: [{ type: "text", content }], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]); + }); + it("reads inline content from nested text nodes", () => { const node: MarkdownNode = { type: "paragraph", From 1b16ed663ffe475490a644d1004873a8aae6bb90 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:08:28 -0400 Subject: [PATCH 007/144] fix(web): avoid Clerk close button overlap (#6442) Co-authored-by: t3-code[bot] <236186684+t3-code[bot]@users.noreply.github.com> Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> --- apps/web/src/components/clerk/ClerkUserProfilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 09021aaad51c..00f20e53fbe1 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -19,7 +19,7 @@ export function ClerkUserProfilePage({ }) { return (
-
+

{title}

{description ? ( From 2fab18e289bdb2b1b767f05a307729d0a0d1c002 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:33:48 +0530 Subject: [PATCH 008/144] fix(web): show unlinked icon when viewport aspect ratio is unlocked (#6509) --- apps/web/src/browser/BrowserDeviceToolbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index f20ab0b37109..cd33bd216d87 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -7,7 +7,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { Link2, X } from "lucide-react"; +import { Link2, Unlink2, X } from "lucide-react"; import { useState } from "react"; import { Button } from "~/components/ui/button"; @@ -310,7 +310,11 @@ export function BrowserDeviceToolbar({ onPointerDown={(event) => event.preventDefault()} onClick={toggleAspectRatio} > - + {aspectRatio === null ? ( + + ) : ( + + )} ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( From db1507e986591ae8e82f8fa1e173a9013309c64e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 13 Aug 2026 19:18:15 -0400 Subject: [PATCH 014/144] feat: allow disabling auto-settle on merge (#5880) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/mobile/src/features/home/HomeScreen.tsx | 9 +++-- .../features/settings/SettingsRouteScreen.tsx | 12 +++++++ .../threads/ThreadNavigationSidebar.tsx | 12 +++++-- .../features/threads/thread-list-v2-items.tsx | 4 +-- .../src/features/threads/threadListV2.test.ts | 15 ++++++++ .../src/features/threads/threadListV2.ts | 14 +++++--- .../src/persistence/mobile-preferences.ts | 5 +++ apps/web/src/components/ChatView.tsx | 12 ++++--- apps/web/src/components/Sidebar.tsx | 30 ++++++++++------ .../components/settings/SettingsPanels.tsx | 34 ++++++++++++++++++- .../src/components/settings/settingsSearch.ts | 5 +++ apps/web/src/hooks/useThreadActionMenu.ts | 3 ++ .../src/state/threadSettled.test.ts | 34 +++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 21 ++++++++---- packages/contracts/src/settings.test.ts | 12 ++++++- packages/contracts/src/settings.ts | 2 ++ 17 files changed, 192 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68..44c12cc554ad 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6c364787301c..60cb1b475569 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition (mirrors web). + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule, matching web. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) { }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4fb4b1a97a5a..c718558a2e66 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; + return ( + savePreferences({ autoSettleOnMerge: value })} + /> ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index b03ba9468d96..12e974fe830c 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -10,6 +10,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row; merged/closed PRs auto-settle their thread - // on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane( }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index db2e805d0fc3..1c25f949ed7c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state up so the partition can auto-settle - merged/closed work (mirrors web's onChangeRequestState). */ + /** Reports this row's live PR state for the partition's merge and close + rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, state: "open" | "closed" | "merged" | null, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index a9ea0138b845..c4a1a844c777 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("keeps a merged thread active when auto-settle on merge is off", () => { + const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); + expect(layout.settledCount).toBe(0); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index eba56ac8de5e..53b80e52c4f1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` - * mirrors the web default of 3 — mobile has no client-settings sync yet, so - * the default is fixed here rather than user-configurable. + * the settled recency tail, matching the web v2 list. Mobile stores these + * auto-settle preferences per device. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: { contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; + readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const autoSettleOnMerge = input.autoSettleOnMerge ?? true; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: { } if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index bf40acb053b7..b504fb190c6d 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,6 +26,7 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; + readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; + autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; } = {}; @@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } + if (typeof parsed.autoSettleOnMerge === "boolean") { + preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; + } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c307..fdc7e7dee382 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { + changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) { // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, @@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread: same visit - // comparison, same merged/closed-PR suppression (finished work needs no - // wake-up call). Drives the dismissible composer banner below. + // Mirror of the sidebar's Woke pill for the open thread. It uses the same + // visit comparison and change request settle rule. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false; + if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) { activeThreadLastVisitedAt, activeThreadPr?.state, activeThreadWokeAt, + autoSettleOnMerge, ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, + autoSettleOnMerge, changeRequestState: activeThreadPr?.state ?? null, }); }, [ activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, + autoSettleOnMerge, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 31fd7cdbaf97..6b44479b0cb5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, + changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick @@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: // reading a completion-triggered wake, clicking the pill, sending a - // message, settling, archiving — or finishing the work outright (merged - // or closed PR). Timer wakes survive a mere visit. An unparseable visit - // timestamp counts as never-visited — corrupt local data must not eat - // the wake signal. + // message, settling, archiving, or a change request state that settles the + // thread. Timer wakes survive a mere visit. An unparseable visit timestamp + // counts as never-visited, so corrupt local data cannot eat the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - prState !== "merged" && - prState !== "closed"; + !changeRequestAutoSettles(prState, props.autoSettleOnMerge); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. + // Report the PR state so the parent can apply the configured merge rule + // and the always-on close rule during partitioning. useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); @@ -1599,6 +1599,7 @@ export default function Sidebar() { const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -1801,8 +1802,8 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -1953,7 +1954,12 @@ export default function Sidebar() { pinned.push(thread); } else if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { @@ -1988,6 +1994,7 @@ export default function Sidebar() { }; }, [ autoSettleAfterDays, + autoSettleOnMerge, changeRequestStateByKey, nowMinute, scopedProjectKeys, @@ -3532,6 +3539,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } + autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e4cfbe9ac033..9df7f88ab1dd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), + ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge + ? ["Auto-settle merged threads"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace @@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> + } + /> + s.markThreadUnread); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { // parked-thread banner within the same minute. now: `${now.toISOString().slice(0, 16)}:00.000Z`, autoSettleAfterDays, + autoSettleOnMerge, changeRequestState, }), isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), @@ -284,6 +286,7 @@ export function useThreadActionMenu(input: { }, [ autoSettleAfterDays, + autoSettleOnMerge, changeRequestState, confirmThreadDelete, copyBranchToClipboard, diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab83..97f397da3e80 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSettle, + changeRequestAutoSettles, effectiveSettled, hasQueuedTurnStart, threadLastActivityAt, @@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z"; const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; +describe("changeRequestAutoSettles", () => { + it.each([ + ["open", true, false], + ["merged", true, true], + ["merged", false, false], + ["closed", false, true], + [null, false, false], + ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { + expect(changeRequestAutoSettles(state, autoSettleOnMerge)).toBe(expected); + }); +}); + function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; readonly activityAt: string | null; @@ -178,6 +191,27 @@ describe("effectiveSettled", () => { } }); + it("can keep a merged change request active", () => { + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); + expect( + effectiveSettled(recentlyActive, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: false, + changeRequestState: "merged", + }), + ).toBe(false); + + expect( + effectiveSettled(recentlyActive, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: false, + changeRequestState: "closed", + }), + ).toBe(true); + }); + it("never auto-settles a stale thread with an open change request", () => { const stale = makeShell({ activityAt: STALE }); expect( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f8002d1c97b9..e2e93f288889 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; +/** Returns whether the change request state settles the thread immediately. */ +export function changeRequestAutoSettles( + state: ChangeRequestStateLike | null | undefined, + autoSettleOnMerge = true, +): boolean { + return state === "closed" || (state === "merged" && autoSettleOnMerge); +} + const DAY_MS = 24 * 60 * 60 * 1_000; export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { @@ -221,9 +229,9 @@ export function threadWokeAt( * queued turn) are checked first and hold a thread active regardless of any * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread auto-settles on a - * merged/closed PR immediately or on inactivity past the window — except - * that an open PR blocks the inactivity path entirely. The server + * wins in both directions; without one, a thread can auto-settle on a + * merged PR, always settles on a closed PR, or settles on inactivity past + * the window. An open PR blocks the inactivity path entirely. The server * un-settles on real activity (user message, session start, approval/ * user-input request), so an override never goes stale silently. */ @@ -232,6 +240,7 @@ export function effectiveSettled( options: { readonly now: string; readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge?: boolean; readonly changeRequestState?: ChangeRequestStateLike | null; }, ): boolean { @@ -258,13 +267,13 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; - if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) { return true; } // An open PR is unfinished business regardless of how long the thread has // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. Only merge/close (above) or an explicit user settle - // resolves it. + // work waiting on it. A configured merge, a close, or an explicit user + // settle resolves it. if (options.changeRequestState === "open") return false; if (options.autoSettleAfterDays === null) return false; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4..570157292b54 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with a three-day auto-settle threshold", () => { + it("defaults to the current sidebar with automatic merge and inactivity settling", () => { const settings = decodeClientSettings({}); expect(settings.legacySidebarEnabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => { ).toBeNull(); }); + it("allows auto-settle on merge to be disabled", () => { + expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( + false, + ); + expect( + decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, + ).toBe(false); + }); + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..ee1970639adf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), + sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 85389b9883a2c7b31022573563b59981d697c3b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 01:59:27 +0200 Subject: [PATCH 015/144] Nest mobile task settings in bottom sheets (#6224) Co-authored-by: codex Co-authored-by: Claude Fable 5 --- .agents/skills/test-t3-mobile/SKILL.md | 32 +- .../test-t3-mobile/scripts/pair-client.sh | 72 + apps/mobile/src/Stack.tsx | 138 +- ...ToolbarTrigger.tsx => ComposerToolbar.tsx} | 70 +- apps/mobile/src/components/GlassSurface.tsx | 8 +- .../connection/ConnectionsNewRouteScreen.tsx | 67 +- apps/mobile/src/features/home/HomeHeader.tsx | 1 + .../layout/native-mail-search-toolbar.ts | 3 + .../features/projects/AddProjectScreen.tsx | 70 +- .../features/settings/SettingsRouteScreen.tsx | 13 +- .../terminal/ThreadTerminalRouteScreen.tsx | 2 +- .../threads/NewTaskContextPickerScreens.tsx | 473 ++++++ .../features/threads/NewTaskDraftScreen.tsx | 723 ++++---- .../features/threads/NewTaskRouteScreen.tsx | 111 +- .../src/features/threads/ThreadComposer.tsx | 272 ++- .../features/threads/ThreadSettingsSheet.tsx | 1461 ++++++++++++----- .../features/threads/legacy-plan-mode.test.ts | 57 + .../src/features/threads/legacy-plan-mode.ts | 29 + .../new-task-context-presentation.test.ts | 128 ++ .../threads/new-task-context-presentation.ts | 83 + .../threads/new-task-flow-provider.tsx | 160 +- .../new-task-project-selection.test.ts | 30 +- .../threads/new-task-project-selection.ts | 15 +- .../threads/thread-settings-menu.test.ts | 284 ---- .../features/threads/thread-settings-menu.ts | 202 --- .../threads/thread-settings-options.test.ts | 29 + .../threads/thread-settings-options.ts | 46 + .../thread-settings-sheet-state.test.ts | 22 +- .../threads/thread-settings-sheet-state.ts | 34 + .../threads/use-legacy-plan-mode-enabled.ts | 26 + .../use-thread-settings-sheet-presentation.ts | 178 +- apps/mobile/src/native/native-glass.ts | 4 +- apps/mobile/src/native/sheet-surface.ts | 28 + .../src/persistence/mobile-preferences.ts | 6 + apps/mobile/src/state/queries.ts | 126 +- .../src/state/use-composer-drafts.test.ts | 125 ++ apps/mobile/src/state/use-composer-drafts.ts | 51 +- docs/user/permission-modes.md | 3 +- ...act-navigation%2Fnative-stack@7.17.6.patch | 22 + patches/react-native-screens@4.25.2.patch | 176 +- pnpm-lock.yaml | 108 +- 41 files changed, 3652 insertions(+), 1836 deletions(-) create mode 100755 .agents/skills/test-t3-mobile/scripts/pair-client.sh rename apps/mobile/src/components/{ComposerToolbarTrigger.tsx => ComposerToolbar.tsx} (77%) create mode 100644 apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx create mode 100644 apps/mobile/src/features/threads/legacy-plan-mode.test.ts create mode 100644 apps/mobile/src/features/threads/legacy-plan-mode.ts create mode 100644 apps/mobile/src/features/threads/new-task-context-presentation.test.ts create mode 100644 apps/mobile/src/features/threads/new-task-context-presentation.ts delete mode 100644 apps/mobile/src/features/threads/thread-settings-menu.test.ts delete mode 100644 apps/mobile/src/features/threads/thread-settings-menu.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-options.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-options.ts create mode 100644 apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts create mode 100644 apps/mobile/src/native/sheet-surface.ts diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 98c1c3b20224..fbcd52e697dd 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra ## Pair each client once -Issue a fresh credential against the running backend's exact base directory: +Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: ```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --base-url \ - --ttl 15m \ - --label agent-mobile- +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + ios + +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + android ``` -In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. +Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme. -If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: +The helper opens this registered route: -```bash -xcrun simctl openurl 't3code-dev://connections/new' -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d 't3code-dev://connections/new' \ - com.t3tools.t3code.dev +```text +t3code-dev://connections/new?pairingUrl=&autoConnect=1 ``` -Run only the command for the selected platform. +The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. + +Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. -In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. +Verify the expected seeded projects appear before exercising the affected flow. Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. @@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. - **A second client cannot pair:** pairing tokens are single-use; issue another token. +- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. +- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh new file mode 100755 index 000000000000..9caa060728ec --- /dev/null +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 [url-scheme]" >&2 + exit 2 +} + +[[ $# -ge 4 && $# -le 5 ]] || usage + +platform="$1" +device_id="$2" +server_port="$3" +base_dir="$4" +url_scheme="${5:-t3code-dev}" + +case "$platform" in + ios) + mobile_origin="http://127.0.0.1:${server_port}" + ;; + android) + mobile_origin="http://10.0.2.2:${server_port}" + ;; + *) + usage + ;; +esac + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if ! pairing_output="$({ + T3CODE_PORT="$server_port" node apps/server/src/bin.ts auth pairing create \ + --base-dir "$base_dir" \ + --base-url "$mobile_origin" \ + --ttl 15m \ + --label "agent-mobile-${device_id:0:8}" +} 2>&1)"; then + echo "Could not mint a mobile pairing credential." >&2 + exit 1 +fi + +pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)" +if [[ -z "$pairing_url" ]]; then + echo "Could not parse the mobile pairing URL." >&2 + exit 1 +fi + +deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' +const query = new URLSearchParams({ + pairingUrl: process.env.PAIRING_URL, + autoConnect: "1", +}); +process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); +NODE +)" + +case "$platform" in + ios) + xcrun simctl openurl "$device_id" "$deep_link" + ;; + android) + # adb shell re-joins its arguments and evaluates them through the device + # shell, so the deep link's `?`/`&` must be quoted once more for that shell. + adb -s "$device_id" shell \ + "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ + >/dev/null + ;; +esac + +echo "Opened the existing Add Environment route with a fresh pairing credential." diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 93bb6165524c..20bba1f6062d 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -11,7 +11,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef } from "react"; -import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; @@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; +import { + NewTaskBranchPickerRouteScreen, + NewTaskEnvironmentPickerRouteScreen, +} from "./features/threads/NewTaskContextPickerScreens"; +import { + ExistingThreadSettingsRouteProvider, + ExistingThreadSettingsRouteScreen, + NewTaskThreadSettingsRouteScreen, +} from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -62,17 +71,15 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; +import { + FORM_SHEET_PRESENTATION_OPTIONS, + NATIVE_SHEET_SURFACE_COLOR, + NATIVE_SHEET_SURFACE_CONTENT_STYLE, +} from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); -// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header -// background stay STATIC config while still adapting to appearance changes. -const SHEET_BACKGROUND_COLOR = - Platform.OS === "ios" - ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" }) - : undefined; - type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; }; @@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } - : SHEET_BACKGROUND_COLOR !== undefined - ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : NATIVE_SHEET_SURFACE_COLOR !== undefined + ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, @@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: - SHEET_BACKGROUND_COLOR !== undefined + NATIVE_SHEET_SURFACE_COLOR !== undefined ? // native-stack types this as `string`, but the native side accepts any // ColorValue including DynamicColorIOS. - { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: false, @@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: undefined, }; +// A native glass header for a sheet screen whose primary child is a scroll +// view. The centered sheet title stays stable while UIKit supplies scroll-edge +// fading from that child. +const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = { + ...GLASS_HEADER_OPTIONS, + unstable_navigationItemStyle: undefined, +}; + const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { ...SHEET_SOLID_HEADER_OPTIONS, headerBackVisible: false, @@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; const NewTaskSheetStack = createNativeStackNavigator({ initialRouteName: "NewTask", screenOptions: { - ...GLASS_HEADER_OPTIONS, - // Sheets read better with the iOS-default centered title (no editor style). - unstable_navigationItemStyle: undefined, + ...SHEET_GLASS_HEADER_OPTIONS, + // The form-sheet host owns the one opaque adaptive surface. Child screens + // and the navigation bar stay transparent over it, avoiding visible color + // slabs as view controllers move horizontally. + contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + // UIKit's default push adds a dimming shadow and independently transitions + // the navigation bar. Both read as mismatched sheet backgrounds here. + // simple_push retains native push/pop gestures without either artifact. + animation: Platform.OS === "ios" ? "simple_push" : undefined, + animationDuration: Platform.OS === "ios" ? 350 : undefined, }, screens: { NewTask: createNativeStackScreen({ @@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({ NewTaskDraft: createNativeStackScreen({ screen: NewTaskDraftRouteScreen, linking: "draft", - // The draft composer has no scroll view for glass to sample; a solid - // header also lays the content out below the bar (no manual inset). - options: SHEET_SOLID_HEADER_OPTIONS, + options: { + headerBackVisible: false, + title: "", + }, + }), + NewTaskEnvironment: createNativeStackScreen({ + screen: NewTaskEnvironmentPickerRouteScreen, + linking: "draft/environment", + options: { + title: "Environment", + }, + }), + NewTaskBranch: createNativeStackScreen({ + screen: NewTaskBranchPickerRouteScreen, + linking: "draft/branch", + options: { + title: "Branch", + }, + }), + ThreadSettings: createNativeStackScreen({ + screen: NewTaskThreadSettingsRouteScreen, + linking: "draft/settings", + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + }), + }, }), AddProject: createNativeStackScreen({ screen: AddProjectSourceRoute, @@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "SettingsLegal", "SettingsSheet", "ThreadReviewComment", + "ThreadSettingsSheet", ]); /** @@ -356,9 +409,11 @@ function RootStackLayout(props: { - - {props.children} - + + + {props.children} + + ); } @@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({ options: { // Android cannot host the keyboard-driven comment composer inside a // formSheet; use a full-screen modal there instead. - presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet", + ...(Platform.OS === "android" + ? { presentation: "fullScreenModal" as const } + : FORM_SHEET_PRESENTATION_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], sheetGrabberVisible: Platform.OS !== "android", }, @@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files`, options: { ...GLASS_HEADER_OPTIONS, - contentStyle: - SHEET_BACKGROUND_COLOR !== undefined - ? { backgroundColor: SHEET_BACKGROUND_COLOR } - : undefined, + contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, title: "Files", }, }), @@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: SOLID_HEADER_OPTIONS, }), + ThreadSettingsSheet: createNativeStackScreen({ + screen: ExistingThreadSettingsRouteScreen, + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + }), + }, + }), GitOverview: createNativeStackScreen({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.45, 0.7], sheetGrabberVisible: true, }, @@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.7, 0.92], sheetGrabberVisible: true, }), @@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.6, 0.95], sheetGrabberVisible: true, }, @@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectionsNewRouteScreen, linking: "connections/new", options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, @@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.92], sheetGrabberVisible: true, }), diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbar.tsx similarity index 77% rename from apps/mobile/src/components/ComposerToolbarTrigger.tsx rename to apps/mobile/src/components/ComposerToolbar.tsx index 20187624964f..de2cca1f6028 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -17,11 +17,73 @@ import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; import { SymbolView } from "./AppSymbol"; -export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; -export const COMPOSER_TOOLBAR_GAP = 8; -export const COMPOSER_TOOLBAR_FADE_WIDTH = 18; +const COMPOSER_TOOLBAR_GAP = 8; +const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; +/** + * Quiet inline composer control used inside cards and their context rows. + * Unlike ComposerToolbarButton, this does not draw another pill inside the + * composer surface, so model and workspace controls read as part of the card. + */ +export function ComposerInlineControl(props: { + readonly accessibilityHint?: string; + readonly accessibilityLabel?: string; + readonly disabled?: boolean; + readonly emphasized?: boolean; + readonly icon?: ComponentProps["name"]; + readonly iconNode?: ReactNode; + readonly label: string; + readonly maxWidth?: number; + readonly onPress?: () => void; + readonly selected?: boolean; + readonly static?: boolean; + readonly chevronDirection?: "down" | "right"; + readonly showChevron?: boolean; +}) { + const iconColor = useThemeColor( + props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted", + ); + + return ( + + {props.iconNode ? ( + {props.iconNode} + ) : props.icon ? ( + + ) : null} + + {props.label} + + {props.showChevron === false ? null : ( + + )} + + ); +} + export function ComposerToolbarRow(props: { readonly children: ReactNode; readonly paddingBottom?: number; @@ -247,5 +309,3 @@ export function ComposerToolbarButton(props: { ); } - -export const ComposerToolbarTrigger = ComposerToolbarButton; diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index f34bd4e2836b..f0b1f863f98b 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -5,16 +5,19 @@ import { useColorScheme, View, type ColorValue, + type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; -export interface GlassSurfaceProps extends Omit { +interface GlassSurfaceProps extends Omit { readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; readonly chrome?: "default" | "none"; + /** Styling used only when native Liquid Glass is unavailable. */ + readonly fallbackStyle?: StyleProp; } export function GlassSurface({ @@ -22,6 +25,7 @@ export function GlassSurface({ glassEffectStyle = "regular", chrome = "default", tintColor, + fallbackStyle, style, ...props }: GlassSurfaceProps) { @@ -67,7 +71,7 @@ export function GlassSurface({ } return ( - + {children} ); diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8e..37d53cbd8eea 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; -import { extractPairingUrlFromQrPayload } from "./pairing"; +import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; -import { buildPairingUrl, parsePairingUrl } from "./pairing"; type ConnectionsNewRouteParams = { readonly mode?: string; + readonly pairingUrl?: string; + readonly autoConnect?: string; }; export function ConnectionsNewRouteScreen({ @@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({ } = useRemoteConnections(); const navigation = useNavigation(); const params = route.params ?? {}; + // Deep-link prefill exists for development automation only. A production + // link must not arrive with attacker-chosen host and token already filled. + const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; + const shouldAutoConnect = + __DEV__ && + routePairingUrl.length > 0 && + (params.autoConnect === "1" || params.autoConnect === "true"); const insets = useSafeAreaInsets(); const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); @@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({ const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); + const attemptedAutoConnectRef = useRef(null); const headerIconColor = useThemeColor("--color-icon"); @@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({ setCodeInput(code); }, [connectionPairingUrl]); + useEffect(() => { + if (routePairingUrl.length === 0) { + return; + } + + const { host, code } = parsePairingUrl(routePairingUrl); + setHostInput(host); + setCodeInput(code); + }, [routePairingUrl]); + useEffect(() => { if (pairingConnectionError) { setIsSubmitting(false); @@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({ [onChangeConnectionPairingUrl, scannerLocked], ); + const connectAndClose = useCallback( + async (pairingUrl: string, replaceWithHome: boolean) => { + setIsSubmitting(true); + onChangeConnectionPairingUrl(pairingUrl); + try { + const result = await onConnectPress(pairingUrl); + if (AsyncResult.isSuccess(result)) { + if (replaceWithHome || !navigation.canGoBack()) { + navigation.dispatch(StackActions.replace("Home")); + } else { + navigation.goBack(); + } + } + } finally { + setIsSubmitting(false); + } + }, + [navigation, onChangeConnectionPairingUrl, onConnectPress], + ); + const handleSubmit = useCallback(async () => { - setIsSubmitting(true); + await connectAndClose(buildPairingUrl(hostInput, codeInput), false); + }, [codeInput, connectAndClose, hostInput]); - const pairingUrl = buildPairingUrl(hostInput, codeInput); - onChangeConnectionPairingUrl(pairingUrl); - const result = await onConnectPress(pairingUrl); - if (AsyncResult.isSuccess(result)) { - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } - } else { - setIsSubmitting(false); + useEffect(() => { + if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { + return; } - }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); + + attemptedAutoConnectRef.current = routePairingUrl; + void connectAndClose(routePairingUrl, true); + }, [connectAndClose, routePairingUrl, shouldAutoConnect]); return ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f3d33934a9b2..e7ce41cb43bd 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) { onSearchTextChange: props.onSearchQueryChange, placeholder: "Search", searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, }), ], } diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 8770d96b124b..34d5570e6109 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; +/** Clearance for scroll content that must come to rest above the floating toolbar. */ +export const NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET = 56; + type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 39e6bda3c44a..747a919a0c88 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -27,7 +27,7 @@ import { inferProjectTitleFromPath, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { StackActions, useNavigation } from "@react-navigation/native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -402,13 +402,12 @@ function SourceControlRow(props: { icon={icon} isFirst={props.isFirst} onPress={() => - navigation.navigate("NewTaskSheet", { - screen: "AddProjectRepository", - params: { + navigation.dispatch( + StackActions.push("AddProjectRepository", { environmentId: props.selectedEnvironmentId, source: props.source, - }, - }) + }), + ) } /> ); @@ -498,12 +497,11 @@ export function AddProjectSourceScreen() { } isFirst onPress={() => - navigation.navigate("NewTaskSheet", { - screen: "AddProjectLocal", - params: { + navigation.dispatch( + StackActions.push("AddProjectLocal", { environmentId: selectedEnvironment.environmentId, - }, - }) + }), + ) } /> {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( @@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) { if (existing) { Alert.alert("Project already exists", existing.title); navigation.dispatch( - StackActions.replace("NewTaskDraft", { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, + CommonActions.reset({ + index: 0, + routes: [ + { + name: "NewTaskDraft", + params: { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + }, + ], }), ); return; @@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) { return result; } navigation.dispatch( - StackActions.replace("NewTaskDraft", { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), + CommonActions.reset({ + index: 0, + routes: [ + { + name: "NewTaskDraft", + params: { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + }, + ], }), ); return result; @@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: { const provider = addProjectRemoteSourceProvider(source); if (!provider) { const remoteUrl = repositoryInput.trim(); - navigation.navigate("NewTaskSheet", { - screen: "AddProjectDestination", - params: { + navigation.dispatch( + StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, remoteUrl, repositoryTitle: remoteUrl, - }, - }); + }), + ); setIsSubmitting(false); return; } @@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: { setError(errorMessage(Cause.squash(result.cause))); } else { const repository = result.value; - navigation.navigate("NewTaskSheet", { - screen: "AddProjectDestination", - params: { + navigation.dispatch( + StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, remoteUrl: repository.sshUrl, repositoryTitle: repository.nameWithOwner, - }, - }); + }), + ); } setIsSubmitting(false); }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c718558a2e66..84a5634e518b 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -549,7 +549,10 @@ function GeneralSettingsSection() { */ function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const planModeEnabled = + AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; return ( @@ -560,10 +563,16 @@ function LegacySettingsSection() { value={!threadListV2Enabled} onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} /> + savePreferences({ planModeEnabled: value })} + /> - Brings back the original grouped thread list. The default list is flat, in creation order: - active work renders as cards; settled threads collapse to compact rows. + Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan + control; otherwise every task runs in Build mode. ); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index cb281bf4aed8..a80d90d82cd7 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -18,7 +18,7 @@ import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, -} from "../../components/ComposerToolbarTrigger"; +} from "../../components/ComposerToolbar"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx new file mode 100644 index 000000000000..68bf0d05c59d --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -0,0 +1,473 @@ +import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { LegendList } from "@legendapp/list/react-native"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Haptics from "expo-haptics"; +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + Switch, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { useFontFamily } from "../../lib/useFontFamily"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { vcsEnvironment } from "../../state/vcs"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; +import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; +import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; + +function SelectionRow(props: { + readonly icon?: "arrow.triangle.branch" | "desktopcomputer"; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly selected: boolean; + readonly isLast?: boolean; + readonly subtitle?: string; + readonly title: string; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const checkmarkColor = useThemeColor("--color-icon"); + + return ( + + {props.icon ? ( + + ) : null} + + + {props.title} + + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + {props.selected ? ( + + ) : null} + + ); +} + +function ToggleRow(props: { + readonly title: string; + readonly value: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + return ( + + + {props.title} + + + + ); +} + +function BranchSelectionRow(props: { + readonly badge: string | null; + readonly branch: VcsRef; + readonly disabled: boolean; + readonly isFirst: boolean; + readonly isLast: boolean; + readonly onSelect: (branch: VcsRef) => void; + readonly selected: boolean; +}) { + const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]); + + return ( + + + + ); +} + +function PickerSurface(props: { readonly children: ReactNode }) { + return {props.children}; +} + +export function NewTaskEnvironmentPickerRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + {flow.environments.map((environment, index) => ( + { + void Haptics.selectionAsync(); + flow.selectEnvironment(environment.environmentId); + navigation.goBack(); + }} + selected={flow.selectedEnvironmentId === environment.environmentId} + title={environment.environmentLabel} + /> + ))} + + + + ); +} + +export function NewTaskBranchPickerRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const placeholderColor = useThemeColor("--color-placeholder"); + const foregroundColor = useThemeColor("--color-foreground"); + const fontFamily = useFontFamily("regular"); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const [switchingBranchName, setSwitchingBranchName] = useState(null); + const selectingBranchNameRef = useRef(null); + const allowSelectionNavigationRef = useRef(false); + const mountedRef = useRef(true); + const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch"; + const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const selectedBranchName = + flow.selectedBranchName ?? + flow.availableBranches.find((branch) => branch.current)?.name ?? + flow.availableBranches.find((branch) => branch.isDefault)?.name ?? + null; + const branchListContentStyle = useMemo( + () => ({ + paddingBottom: usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 + : Platform.OS === "ios" + ? 16 + : Math.max(insets.bottom, 16) + 16, + paddingHorizontal: 16, + paddingTop: 12, + }), + [insets.bottom, usesNativeMailSearchToolbar], + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + flow.setBranchQuery(""); + }; + }, [flow.setBranchQuery]); + + useEffect( + () => + navigation.addListener("beforeRemove", (event) => { + if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) { + event.preventDefault(); + } + }), + [navigation], + ); + + const selectBranch = useCallback( + async (branch: VcsRef) => { + if (selectingBranchNameRef.current !== null) { + return; + } + selectingBranchNameRef.current = branch.name; + void Haptics.selectionAsync(); + + try { + let selectedBranch = branch; + const needsCheckout = shouldCheckoutNewTaskBranch({ + branchIsCurrent: branch.current, + branchWorktreePath: branch.worktreePath, + workspaceMode: flow.workspaceMode, + }); + if (needsCheckout && flow.selectedProject) { + setSwitchingBranchName(branch.name); + const result = await switchRef({ + environmentId: flow.selectedProject.environmentId, + input: { + cwd: flow.selectedProject.workspaceRoot, + refName: branch.name, + }, + }); + if (result._tag === "Failure") { + if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); + } + return; + } + selectedBranch = { + ...branch, + current: true, + isRemote: false, + name: result.value.refName ?? branch.name, + }; + } + + // The checkout has already changed the repository. Persist the matching + // draft selection even if the native sheet was dismissed while the + // command was in flight; only visible-screen work is focus-gated below. + flow.selectBranch(selectedBranch); + if (!mountedRef.current || !navigation.isFocused()) { + return; + } + flow.setBranchQuery(""); + allowSelectionNavigationRef.current = true; + navigation.goBack(); + } finally { + selectingBranchNameRef.current = null; + allowSelectionNavigationRef.current = false; + if (mountedRef.current) { + setSwitchingBranchName(null); + } + } + }, + [ + flow.selectBranch, + flow.selectedProject, + flow.setBranchQuery, + flow.workspaceMode, + navigation, + switchRef, + ], + ); + + const renderBranch = useCallback( + ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( + + ), + [ + flow.filteredBranches.length, + flow.selectedProject, + selectBranch, + selectedBranchName, + switchingBranchName, + ], + ); + + const branchListHeader = + flow.workspaceMode === "worktree" ? ( + + + + ) : null; + + const branchContent = + flow.filteredBranches.length === 0 ? ( + + {branchListHeader} + + {flow.branchesLoading ? : null} + + {flow.branchesLoading + ? "Loading branches…" + : flow.branchesError + ? flow.branchesError + : flow.branchQuery + ? "No matching branches" + : "No branches available"} + + {!flow.branchesLoading && flow.branchesError ? ( + + Try again + + ) : null} + + + ) : ( + + `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}` + } + ListHeaderComponent={branchListHeader} + ListFooterComponent={ + flow.branchesFetchingNextPage ? ( + + + + ) : null + } + onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined} + onEndReachedThreshold={0.35} + renderItem={renderBranch} + showsVerticalScrollIndicator={false} + /> + ); + + if (Platform.OS === "android") { + return ( + + + navigation.goBack()} /> + + + + {branchContent} + + ); + } + + return ( + <> + [ + createNativeMailSearchToolbarItem({ + onSearchTextChange: flow.setBranchQuery, + placeholder: "Find a branch", + searchTextChangeId: "new-task-branch-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerSearchBarOptions: usesNativeMailSearchToolbar + ? undefined + : { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + placeholder: "Find a branch", + onChangeText: (event) => { + flow.setBranchQuery(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + flow.setBranchQuery(""); + }, + }, + }} + /> + {usesNativeMailSearchToolbar ? null : ( + + + + )} + {branchContent} + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ece23ca0551..87b12ad22f5f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,9 +1,14 @@ -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { - KeyboardAvoidingView, + StackActions, + useFocusEffect, + useNavigation, + usePreventRemove, +} from "@react-navigation/native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; +import { + KeyboardController, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; @@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; -import { EnvironmentId } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -19,22 +23,24 @@ import { import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { + ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, - ComposerToolbarTrigger, -} from "../../components/ComposerToolbarTrigger"; +} from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; import { ComposerSurface } from "./ThreadComposer"; -import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; -import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; +import { + useThreadSettingsSheetPresentation, + type NavigationWithFinishTransitioning, +} from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; -import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; +import { useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; +import { + resolveNewTaskBranchLabel, + resolveNewTaskWorkspaceLabel, +} from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; -function formatWorkspaceLabel(input: { - readonly workspaceMode: string; - readonly currentBranchName: string | null; - readonly selectedBranchName: string | null; -}): string { - const branchName = input.selectedBranchName ?? input.currentBranchName; - if (input.workspaceMode === "worktree") { - return branchName ? `New worktree · ${branchName}` : "New worktree"; +function NewTaskWorkspaceIcon(props: { + readonly workspaceMode: "local" | "worktree"; + readonly worktreePath: string | null; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + + if (props.workspaceMode === "local" && props.worktreePath === null) { + return ; } - return branchName ? `Current · ${branchName}` : "Current checkout"; + + return ( + + + + + + + ); } export function NewTaskDraftScreen(props: { @@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: { const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); - const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); + const controlsBottomPadding = Math.max(insets.bottom, 10); + const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( @@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: { editorRef: promptInputRef, isEditorFocused: isComposerFocused, }); + useEffect(() => { + if (Platform.OS !== "ios") { + return; + } + + navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible }); + }, [isKeyboardVisible, navigation]); + useEffect(() => { + return () => { + if (Platform.OS === "ios") { + navigation.getParent()?.setOptions({ gestureEnabled: true }); + } + }; + }, [navigation]); + const settingsRoutePresentedRef = useRef(false); + useEffect(() => { + if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = true; + navigation.dispatch(StackActions.push("ThreadSettings")); + }, [navigation, settingsSheetPresentation.isVisible]); + useFocusEffect( + useCallback(() => { + if (!settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + }, [settingsSheetPresentation.onDismissed]), + ); + useEffect( + () => + // UIKit's completion callback for the sheet dismissal, surfaced by the + // native-stack patch. This is when the queued keyboard restore runs. + (navigation as unknown as NavigationWithFinishTransitioning).addListener( + "finishTransitioning", + settingsSheetPresentation.onStackTransitionsFinished, + ), + [navigation, settingsSheetPresentation.onStackTransitionsFinished], + ); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); @@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: { }, [props.pendingTaskId, cancelEditingPendingTask]); const foregroundColor = useThemeColor("--color-foreground"); + const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const headlineText = useScaledTextRole("headline"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; @@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - void flow.loadBranches(); + flow.loadBranches(); }, [flow.loadBranches, selectedProject]); useEffect(() => { @@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: { shareImportAttempt, ]); - useEffect(() => { - // Android starts with the collapsed composer pill (like an open thread) - // and only expands/focuses when tapped. - if (!selectedProject || Platform.OS === "android") { - return; - } - - let focusFrame: ReturnType | null = null; - const interaction = InteractionManager.runAfterInteractions(() => { - focusFrame = requestAnimationFrame(() => { - // The delayed focus can land after the settings sheet opened, which - // would pop the keyboard underneath its modal. - if (!settingsSheetPresentation.isActiveRef.current) { - promptInputRef.current?.focus(); - } else { - settingsSheetPresentation.restoreFocusAfterSave(); - } - }); - }); - - return () => { - interaction.cancel(); - if (focusFrame !== null) { - cancelAnimationFrame(focusFrame); - } - }; - }, [ - selectedProject, - settingsSheetPresentation.isActiveRef, - settingsSheetPresentation.restoreFocusAfterSave, - ]); - - const environmentMenuActions = useMemo( - () => - flow.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.environmentLabel, - attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, - state: - flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, - })), - [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], - ); - - const providerOptionDescriptors = useMemo( - () => - resolveProviderOptionDescriptors({ - capabilities: flow.selectedModelOption?.capabilities, - selections: flow.selectedModel?.options, - }), - [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], - ); - - const workspaceMenuActions = useMemo(() => { - const branchActions = - flow.availableBranches.length === 0 - ? [ - { - id: "workspace:branch:none", - title: flow.branchesLoading ? "Loading branches…" : "No branches available", - attributes: { disabled: true }, - }, - ] - : flow.availableBranches.slice(0, 12).map((branch) => { - const badge = branchBadgeLabel({ - branch, - project: flow.selectedProject, - }); - - return { - id: `workspace:branch:${branch.name}`, - title: branch.name, - subtitle: badge ? badge.toUpperCase() : undefined, - state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined, - }; - }); - - return [ - { - id: "workspace:mode", - title: "Mode", - subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree", - subactions: (["local", "worktree"] as const).map((value) => ({ - id: `workspace:mode:${value}`, - title: value === "local" ? "Current checkout" : "New worktree", - state: flow.workspaceMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "workspace:branch", - title: "Branch", - subtitle: flow.selectedBranchName ?? "Choose branch", - subactions: branchActions, - }, - ...(flow.workspaceMode === "worktree" - ? [ - { - id: "workspace:start-from-origin", - title: "Start from origin", - subtitle: "Base the worktree on the latest origin branch", - image: "arrow.triangle.pull", - state: flow.startFromOrigin ? ("on" as const) : undefined, - }, - ] - : []), - ]; - }, [ - flow.availableBranches, - flow.branchesLoading, - flow.selectedBranchName, - flow.selectedProject, - flow.startFromOrigin, - flow.workspaceMode, - ]); - const selectedEnvironmentLabel = flow.environments.find( (environment) => environment.environmentId === flow.selectedEnvironmentId, @@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const settingsSummaryLabel = threadSettingsSummaryLabel({ - modelLabel: flow.selectedModelOption?.label ?? "Model", - optionDescriptors: providerOptionDescriptors, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, + const selectedBranchName = flow.selectedBranchName ?? currentBranchName; + const selectedBranchLabel = resolveNewTaskBranchLabel({ + branchName: selectedBranchName, + startFromOrigin: flow.startFromOrigin, + workspaceMode: flow.workspaceMode, }); - const workspaceLabel = useMemo( - () => - formatWorkspaceLabel({ - currentBranchName, - selectedBranchName: flow.selectedBranchName, - workspaceMode: flow.workspaceMode, - }), - [currentBranchName, flow.selectedBranchName, flow.workspaceMode], - ); - function handleEnvironmentMenuAction(event: string) { - if (isIncomingShareTransferPending || !event.startsWith("environment:")) { - return; - } - flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); - } - - function handleWorkspaceMenuAction(event: string) { - if (isIncomingShareTransferPending) { - return; - } - if (event.startsWith("workspace:mode:")) { - flow.setWorkspaceMode( - event.slice("workspace:mode:".length) as Parameters[0], - ); - return; - } - if (event === "workspace:start-from-origin") { - flow.setStartFromOrigin(!flow.startFromOrigin); - return; - } - if (event.startsWith("workspace:branch:")) { - const branchName = event.slice("workspace:branch:".length); - const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); - if (branch) { - flow.selectBranch(branch); - } - } - } + const workspaceLabel = resolveNewTaskWorkspaceLabel({ + workspaceMode: flow.workspaceMode, + worktreePath: flow.selectedWorktreePath, + }); + const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; async function handlePickImages(): Promise { if (isIncomingShareTransferPending) { @@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = draft.interactionMode ?? flow.interactionMode; + const interactionMode = flow.planModeEnabled + ? (draft.interactionMode ?? flow.interactionMode) + : "default"; const initialMessageText = draft.text.trim(); if ( @@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: { if (!selectedProject) { return ( - + {Platform.OS === "android" ? ( <> @@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const isDarkMode = colorScheme === "dark"; - // Android expansion follows native editor focus so relayout cannot race - // the touch gesture that opens the keyboard. - // The settings sheet dismisses the keyboard, so its flag keeps the Android - // draft composer expanded through the blur (mirrors ThreadComposer). - const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: { const promptEditor = ( setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} - // Same collapsed centering as ThreadComposer: native vertical gravity - // in a pill-height box. - singleLineCentered={!isExpanded} - contentInsetVertical={isAndroid ? 0 : undefined} - style={ - isAndroid - ? isExpanded - ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } - : { height: 36 } - : { flex: 1, minHeight: 0 } - } - textStyle={ - isAndroid - ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } - : headlineText - } + placeholder="Ask anything…" + singleLineCentered={false} + contentInsetVertical={0} + style={{ + minHeight: 72, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + }} + textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} /> ); - const toolbarPills = ( - <> - void handlePickImages()} - showChevron={false} - disabled={isIncomingShareTransferPending} - /> - { + void KeyboardController.dismiss({ animated: true }); + const parentNavigation = navigation.getParent(); + if (parentNavigation) { + parentNavigation.goBack(); + return; + } + navigation.goBack(); + }; + const chooseProject = () => { + if (isIncomingShareTransferPending) { + return; + } + promptInputRef.current?.blur(); + void KeyboardController.dismiss({ animated: true }); + navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); + }; + const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { + if (isIncomingShareTransferPending) { + return; + } + promptInputRef.current?.blur(); + void KeyboardController.dismiss({ animated: true }); + navigation.dispatch(StackActions.push(routeName)); + }; + + const hero = ( + + + + What should we build + + + in + + + {selectedProject.title} + + + ? + + + + } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} + icon="desktopcomputer" + label={`on ${selectedEnvironmentLabel}`} + maxWidth={260} + onPress={ + flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined + } + showChevron={flow.environments.length > 1} + static={flow.environments.length <= 1} /> - handleEnvironmentMenuAction(nativeEvent.event)} - > - - - handleWorkspaceMenuAction(nativeEvent.event)} + + ); + const heroViewport = ( + + - - - + {hero} + + ); - const settingsSheet = ( - flow.setSelectedModelKey(option.key, option.selection.options)} - optionDescriptors={providerOptionDescriptors} - onUpdateOptionSelections={flow.setSelectedModelOptions} - runtimeMode={flow.runtimeMode} - onUpdateRuntimeMode={flow.setRuntimeMode} - /> + const workspaceControls = ( + + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} + showChevron={false} + /> + + openContextPicker("NewTaskBranch")} + /> + ); - const startButton = ( - void handleStart()} - variant="primary" - showChevron={false} - disabled={!canStart} - /> + const composerDock = ( + + {workspaceControls} + + + {flow.attachments.length > 0 ? ( + + undefined : flow.removeAttachment} + /> + + ) : null} + + {promptEditor} + + + + void handlePickImages()} + showChevron={false} + /> + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth={152} + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( + + flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan") + } + showChevron={false} + /> + ) : null} + + void handleStart()} + showChevron={false} + variant="primary" + /> + + + ); if (isAndroid) { - // The draft is a thread that doesn't exist yet, so it mirrors the thread - // page: in-screen header, empty feed canvas above, and the same floating - // composer chrome as ThreadComposer (collapsed pill → expanded card). - // - // Composer positioning mirrors ThreadDetailScreen's floating overlay - // (KeyboardStickyView, absolute bottom overlay) rather than - // KeyboardAvoidingView's automaticOffset+padding: automaticOffset - // resolves the composer's on-screen frame via a native - // viewPositionInWindow measurement, which this app's Android - // edge-to-edge setup (KeyboardProvider's native content-view margin - // handling neutralizes windowSoftInputMode="adjustResize" while active) - // makes unreliable — the composer stayed under the keyboard instead of - // translating above it. KeyboardStickyView sticks directly to the - // animated keyboard height instead, sidestepping that measurement. return ( - + - navigation.goBack()} /> - - + + {heroViewport} - - - {isExpanded && flow.attachments.length > 0 ? ( - - undefined : flow.removeAttachment - } - /> - - ) : null} - {promptEditor} - {!isExpanded ? ( - void handleStart()} - /> - ) : null} - - - {isExpanded ? ( - - - {toolbarPills} - - {startButton} - - ) : null} - + {composerDock} - {settingsSheet} ); } return ( - - - - - {promptEditor} + + + + + - - {flow.attachments.length > 0 ? ( - - undefined : flow.removeAttachment} - imageSize={88} - imageBorderRadius={20} - /> - - ) : null} - - - {toolbarPills} - - {startButton} - - - - {settingsSheet} + {heroViewport} + + {composerDock} + ); } diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 7f4a68c08c7d..94304448eaf3 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,8 +1,13 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + StackActions, + useIsFocused, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { scopedProjectKey } from "../../lib/scopedEntities"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); - const { projectScopes } = useNewTaskFlow(); + const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps>(() => new Set()); const { getShare, releaseShareReservation } = useIncomingShare(); const routeShareId = Array.isArray(route.params?.incomingShareId) ? route.params.incomingShareId[0] @@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { - const next = new Set(current); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); + }), + ); } useEffect(() => { @@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + onPress: () => navigation.dispatch(StackActions.push("AddProject")), }, ] : [] @@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => navigation.dispatch(StackActions.push("AddProject"))} separateBackground /> ) : null} @@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => navigation.dispatch(StackActions.push("AddProject"))} > Add new project @@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {projectScopes.map((scope, scopeIndex) => { const hasMultipleProjects = scope.projects.length > 1; - const expanded = expandedGroupKeys.has(scope.key); - const singleProject = hasMultipleProjects ? null : scope.projects[0]; + const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); return ( 0 && "border-t border-border-subtle")} > { - if (singleProject) { - void selectProject(singleProject); - } else { - toggleGroup(scope.key); - } - }} + disabled={reservedDestinationProject !== null} + onPress={() => void selectProject(selectionTarget)} className="flex-row items-center gap-3 bg-card px-4 py-3.5" > @@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {hasMultipleProjects ? `${scope.projects.length} workspaces` - : singleProject?.workspaceRoot} + : selectionTarget.workspaceRoot} - {hasMultipleProjects && expanded - ? scope.projects.map((project) => ( - void selectProject(project)} - className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10" - > - - - - {project.title} - - - {project.workspaceRoot} - - - - - )) - : null} ); })} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6ce42aeb148d..3fba0a351c2b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, MessageId, @@ -14,7 +13,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; -import * as Haptics from "expo-haptics"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle, type ComposerEditorSelection, } from "../../components/ComposerEditor"; import { + ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, - ComposerToolbarTrigger, -} from "../../components/ComposerToolbarTrigger"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; +} from "../../components/ComposerToolbar"; +import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -63,15 +63,17 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { - applyProviderOptionSelection, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; -import { buildThreadSettingsMenu } from "./thread-settings-menu"; -import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; -import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; +import { + type ExistingThreadSettingsRouteSession, + useExistingThreadSettingsRoutePresentation, +} from "./ThreadSettingsSheet"; +import { + useThreadSettingsSheetPresentation, + type NavigationWithFinishTransitioning, +} from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */ -export const COMPOSER_EXPANDED_CHROME = 174; +export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; @@ -123,8 +125,8 @@ export interface ThreadComposerProps { } /** - * The pill / card container — renders as LiquidGlassView on supported - * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. + * The pill / card container — renders with Expo's native GlassView on supported + * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */ // One timing for every piece of the expanded↔compact morph so the surface, @@ -140,6 +142,8 @@ export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; readonly isDarkMode: boolean; + /** Existing thread composers morph between pill and card layouts. */ + readonly animateLayout?: boolean; }) { // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself // (needed to clip content to the pill shape) would clip the shadow on iOS. @@ -152,35 +156,26 @@ export function ComposerSurface(props: { elevation: 10, }; - if (isLiquidGlassSupported) { - return ( - - - {props.children} - - - ); - } - return ( - - + {props.children} - + ); } @@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( }); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const navigation = useNavigation(); const isDarkMode = useColorScheme() === "dark"; const foregroundColor = useThemeColor("--color-foreground"); const bodyText = useScaledTextRole("body"); @@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer editorRef: inputRef, isEditorFocused: isFocused, }); + const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); + const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Opening and closing count as active so the composer stays expanded while - // focus moves between its native editor and the settings modal. + // Opening and presentation count as active so the composer stays expanded + // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; @@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; - const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const settingsSummaryLabel = threadSettingsSummaryLabel({ - modelLabel: currentModelOption?.label ?? currentModelSelection.model, - optionDescriptors: providerOptionDescriptors, - runtimeMode: currentRuntimeMode, - interactionMode: currentInteractionMode, - }); - - // iOS gets a native menu on the trigger pill: the everyday adjustments - // apply without resigning the keyboard, while "All Settings…" (and the - // Android trigger) still route through the sheet, which must dismiss it. - const settingsMenu = useMemo( - () => - Platform.OS === "ios" - ? buildThreadSettingsMenu({ - providerGroups: threadProviderGroups, - selectedModel: currentModelSelection, - optionDescriptors: providerOptionDescriptors, - runtimeMode: currentRuntimeMode, - }) - : null, - [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], - ); - - const onUpdateModelSelection = props.onUpdateModelSelection; - const onUpdateRuntimeMode = props.onUpdateRuntimeMode; - const handleSettingsMenuAction = useCallback( - (eventId: string) => { - const event = settingsMenu?.events.get(eventId); - if (!event) { - return; - } - switch (event.type) { - case "select-model": - void Haptics.selectionAsync(); - onUpdateModelSelection(event.option.selection); - return; - case "set-option": { - const options = applyProviderOptionSelection(providerOptionDescriptors, { - id: event.optionId, - value: event.value, - }); - if (options) { - void Haptics.selectionAsync(); - onUpdateModelSelection({ ...currentModelSelection, options }); - } - return; - } - case "set-runtime": - void Haptics.selectionAsync(); - onUpdateRuntimeMode(event.mode); - return; - } - }, + const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); + const settingsRouteSession = useMemo( + () => ({ + ownerId: settingsOwnerId, + providerGroups: threadProviderGroups, + selectedModel: currentModelSelection, + onSelectModel: (option) => props.onUpdateModelSelection(option.selection), + optionDescriptors: providerOptionDescriptors, + onUpdateOptionSelections: (options) => + props.onUpdateModelSelection({ ...currentModelSelection, options }), + runtimeMode: currentRuntimeMode, + onUpdateRuntimeMode: props.onUpdateRuntimeMode, + }), [ currentModelSelection, - onUpdateModelSelection, - onUpdateRuntimeMode, + currentRuntimeMode, + props.onUpdateModelSelection, + props.onUpdateRuntimeMode, providerOptionDescriptors, - settingsMenu, + settingsOwnerId, + threadProviderGroups, ], ); + const openSettings = useCallback(() => { + settingsRoutePresentation.present(settingsRouteSession); + settingsSheetPresentation.open(); + }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); + + useEffect(() => { + if (settingsSheetPresentation.isActive) { + settingsRoutePresentation.present(settingsRouteSession); + } + }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); + + useEffect(() => { + if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = true; + navigation.dispatch(StackActions.push("ThreadSettingsSheet")); + }, [navigation, settingsSheetPresentation.isVisible]); + + useFocusEffect( + useCallback(() => { + if (!settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + settingsRoutePresentation.clear(settingsOwnerId); + }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), + ); + + useEffect( + () => + // UIKit's completion callback for the sheet dismissal, surfaced by the + // native-stack patch. This is when the queued keyboard restore runs. + (navigation as unknown as NavigationWithFinishTransitioning).addListener( + "finishTransitioning", + settingsSheetPresentation.onStackTransitionsFinished, + ), + [navigation, settingsSheetPresentation.onStackTransitionsFinished], + ); return ( ) : null} - - - {isExpanded ? ( - // Toolbar row — matches draft page layout (expanded only) - - + {isExpanded ? ( + void props.onPickDraftImages()} showChevron={false} /> - {settingsMenu ? ( - handleSettingsMenuAction(nativeEvent.event)} - > - - } - label={settingsSummaryLabel} - maxWidth={320} - /> - - ) : ( - - } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} - /> - )} + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth={152} + onPress={openSettings} + /> {showStopAction ? ( - - ) : null} + ) : null} + {/* Queue count */} {props.queueCount > 0 ? ( @@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - props.onUpdateModelSelection(option.selection)} - optionDescriptors={providerOptionDescriptors} - onUpdateOptionSelections={(options) => - props.onUpdateModelSelection({ ...currentModelSelection, options }) - } - runtimeMode={currentRuntimeMode} - onUpdateRuntimeMode={props.onUpdateRuntimeMode} - /> - = new Set(["claudeAgent", "codex"]); - /** - * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, - * covering model, provider options, runtime mode, and plan mode in one label. + * Keep measured row changes stable, but let catalog mutations use the list's + * native bounds so a filtered catalog that underflows returns to the top. */ -export function threadSettingsSummaryLabel(input: { - readonly modelLabel: string; - readonly optionDescriptors: ReadonlyArray; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; -}): string { - const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); - return [ - input.modelLabel, - ...providerOptionValueLabels(input.optionDescriptors), - ...(runtime ? [runtime.shortLabel] : []), - ...(input.interactionMode === "plan" ? ["Plan"] : []), - ].join(" · "); -} - +const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { + data: false, + size: true, +} as const; +const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); +const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); +const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); +const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); +const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); +const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); +const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( + Platform.OS, + Platform.Version, +); function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; + readonly isFirst: boolean; + readonly isLast: boolean; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); + const checkmarkColor = useThemeColor("--color-icon"); return ( - + {props.option.label} {props.option.isDefault ? ( @@ -101,17 +122,19 @@ function ModelRow(props: { ) : null} {props.selected ? ( - + ) : null} ); } -/** - * Provider section header with the harness logo. Secondary providers render - * as a tappable fold (count + chevron while collapsed); primary providers - * and the group holding the current selection are static headers. - */ +/** Provider catalog header with its harness logo and disclosure state. */ function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; @@ -121,24 +144,10 @@ function ProviderHeader(props: { readonly onToggle: () => void; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); - return ( - + const content = ( + <> - - {props.label} - + {props.label} {props.collapsible ? ( <> @@ -149,13 +158,33 @@ function ProviderHeader(props: { ) : null} ) : null} - + + ); + + if (props.collapsible) { + return ( + + {content} + + ); + } + + return ( + + {content} + ); } @@ -163,18 +192,17 @@ function ProviderHeader(props: { function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; - readonly disabled?: boolean; readonly onPress: () => void; + readonly isLast?: boolean; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( {props.label} @@ -192,31 +220,37 @@ function DisclosureRow(props: { /** Single option inside a submenu panel. */ function ChoiceRow(props: { readonly label: string; + readonly description?: string; readonly selected: boolean; readonly onPress: () => void; + readonly isLast: boolean; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); + const checkmarkColor = useThemeColor("--color-icon"); return ( - - {props.label} - - + + {props.label} + {props.description ? ( + {props.description} + ) : null} + {props.selected ? ( - + ) : null} ); @@ -225,60 +259,31 @@ function ChoiceRow(props: { function SwitchRow(props: { readonly label: string; readonly value: boolean; - readonly disabled?: boolean; readonly onValueChange: (value: boolean) => void; + readonly isLast?: boolean; }) { - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); return ( {props.label} ); } -type SubmenuPage = +type ThreadSettingsSubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; -/** - * Unified thread settings: the sheet is the provider-grouped model list - * (primary harnesses expanded, other providers folded, legacy behind the - * top-right pill) with a Save button, plus compact disclosure rows whose - * single-choice submenus stack in a small panel over the sheet so it never - * changes size. Model changes stage until Save — while staged, the settings - * rows edit the staged model's options and Save applies everything together. - * - * Callers control which harnesses are offered via providerGroups: an - * existing thread must pass only its own provider's group, since a session - * can't switch harness mid-thread. - * - * Rendered through an RN Modal (not the root OverlayPortal) so it also - * presents above natively-presented form sheets like the new-task draft. - * Callers must dismiss the keyboard when opening — the iOS keyboard window - * would otherwise cover the lower half of the sheet. - */ -export function ThreadSettingsSheet(props: { - readonly visible: boolean; - /** - * "save" = the Save/Done button (the user is finished configuring); - * "dismiss" = backdrop, grabber, or system back. Hosts only restore the - * keyboard for "save" so a stray tap outside a control never pops it. - */ - readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; - readonly onDismissed: () => void; +type ThreadSettingsSessionProps = { readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; @@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: { readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; -}) { - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); +}; + +export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { + readonly ownerId: string; +}; + +type ExistingThreadSettingsRouteContextValue = { + readonly session: ExistingThreadSettingsRouteSession | null; + readonly present: (session: ExistingThreadSettingsRouteSession) => void; + readonly clear: (ownerId: string) => void; +}; + +const ExistingThreadSettingsRouteContext = + createContext(null); + +/** Bridges the active thread's settings state into the root native sheet route. */ +export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { + const [session, setSession] = useState(null); + const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { + setSession(nextSession); + }, []); + const clear = useCallback((ownerId: string) => { + setSession((current) => (current?.ownerId === ownerId ? null : current)); + }, []); + const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); + + return ( + + {props.children} + + ); +} + +export function useExistingThreadSettingsRoutePresentation() { + const value = use(ExistingThreadSettingsRouteContext); + if (!value) { + throw new Error( + "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", + ); + } + return value; +} + +type ThreadSettingsSessionValue = { + readonly providerGroups: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; + readonly displayedDescriptors: ReadonlyArray; + readonly providerExpansionOverrides: ReadonlySet; + readonly hasLegacyModels: boolean; + readonly pendingModel: ModelOption | null; + readonly providerFilter: string | null; + readonly searchQuery: string; + readonly showLegacy: boolean; + readonly applyOptionChange: (id: string, value: string | boolean) => void; + readonly commitPendingModel: () => void; + readonly isApplied: (option: ModelOption) => boolean; + readonly isDisplayed: (option: ModelOption) => boolean; + readonly pressModel: (option: ModelOption) => void; + readonly setProviderFilter: (providerKey: string | null) => void; + readonly setSearchQuery: (query: string) => void; + readonly setShowLegacy: (showLegacy: boolean) => void; + readonly toggleProvider: (providerKey: string) => void; +}; + +const ThreadSettingsSessionContext = createContext(null); + +/** Owns the staged model and option state for one picker presentation. */ +function ThreadSettingsSessionProvider( + props: ThreadSettingsSessionProps & { readonly children: ReactNode }, +) { const [showLegacyToggle, setShowLegacyToggle] = useState(false); - const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); + const [providerFilter, setProviderFilter] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [providerExpansionOverrides, setProviderExpansionOverrides] = useState>( + () => new Set(), + ); const [pendingModel, setPendingModel] = useState(null); - const [submenu, setSubmenu] = useState(null); - const wasPresentedRef = useRef(false); - const notifyDismissed = useCallback(() => { - if (!wasPresentedRef.current) { - return; - } - wasPresentedRef.current = false; - props.onDismissed(); - }, [props.onDismissed]); - // Every open starts fresh: no staged model, no submenu, legacy hidden, - // secondary providers folded. The sheet stays mounted between opens, so - // state would otherwise stick around. - useEffect(() => { - if (props.visible) { - wasPresentedRef.current = true; - setShowLegacyToggle(false); - setExpandedProviders(new Set()); - setPendingModel(null); - setSubmenu(null); - } else if (Platform.OS === "android" && wasPresentedRef.current) { - // React Native only emits Modal.onDismiss on iOS. Android uses no exit - // animation below, so the post-commit effect is its dismissal boundary. - notifyDismissed(); - } - }, [notifyDismissed, props.visible]); - - const isApplied = (option: ModelOption) => - option.selection.instanceId === props.selectedModel?.instanceId && - option.selection.model === props.selectedModel.model; + const isApplied = useCallback( + (option: ModelOption) => + option.selection.instanceId === props.selectedModel?.instanceId && + option.selection.model === props.selectedModel.model, + [props.selectedModel], + ); // The list highlights the staged pick; Save turns it into the applied one. - const isDisplayed = (option: ModelOption) => - pendingModel ? option.key === pendingModel.key : isApplied(option); + const isDisplayed = useCallback( + (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), + [isApplied, pendingModel], + ); // While a model is staged, the settings rows describe and edit the staged // model's options (kept on its pending selection); Save applies model and // options together. Otherwise they edit the applied selection directly. - const displayedDescriptors = pendingModel - ? pendingModel.capabilities - ? getProviderOptionDescriptors({ - caps: pendingModel.capabilities, - selections: pendingModel.selection.options, - }) - : [] - : props.optionDescriptors; - - const hasLegacyModels = props.providerGroups.some((group) => - group.models.some((model) => model.isLegacy), - ); - // Legacy stays hidden unless the pill is toggled this open; a highlighted - // legacy model is exempted from the filter instead of forcing the whole - // legacy list visible. - const showLegacy = showLegacyToggle; - - // Stable settings rows: the union of descriptors across the primary - // harnesses' current models (plus whatever the displayed model advertises) - // always renders, with unsupported rows disabled instead of vanishing when - // the selection changes. Keyed by label, not id — Claude and Codex use - // different ids for the same "Reasoning" concept. - const descriptorTemplate = (() => { - const seen = new Map(); - for (const group of props.providerGroups) { - const driver = group.models[0]?.providerDriver; - if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { - continue; - } - for (const model of group.models) { - if (model.isLegacy) { - continue; - } - for (const descriptor of model.capabilities?.optionDescriptors ?? []) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - } - } - for (const descriptor of displayedDescriptors) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); - })(); + const displayedDescriptors = useMemo( + () => + pendingModel + ? pendingModel.capabilities + ? getProviderOptionDescriptors({ + caps: pendingModel.capabilities, + selections: pendingModel.selection.options, + }) + : [] + : props.optionDescriptors, + [pendingModel, props.optionDescriptors], + ); - const handleSave = () => { + const hasLegacyModels = useMemo( + () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), + [props.providerGroups], + ); + const commitPendingModel = useCallback(() => { if (pendingModel) { void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } - props.onClose("save"); - }; + }, [pendingModel, props.onSelectModel]); - const handleOptionChange = (id: string, value: string | boolean) => { - const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); - if (!next) { - return; - } - if (pendingModel) { - setPendingModel({ - ...pendingModel, - selection: { ...pendingModel.selection, options: next }, - }); - } else { - props.onUpdateOptionSelections(next); - } - }; + const applyOptionChange = useCallback( + (id: string, value: string | boolean) => { + const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + if (!next) { + return; + } + if (pendingModel) { + setPendingModel({ + ...pendingModel, + selection: { ...pendingModel.selection, options: next }, + }); + } else { + props.onUpdateOptionSelections(next); + } + }, + [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], + ); - const toggleProvider = (providerKey: string) => { - setExpandedProviders((current) => { + const toggleProvider = useCallback((providerKey: string) => { + setProviderExpansionOverrides((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); - }; + }, []); + + const pressModel = useCallback( + (option: ModelOption) => { + void Haptics.selectionAsync(); + setPendingModel((current) => + pendingModelAfterPress({ + current, + pressed: option, + pressedIsApplied: isApplied(option), + }), + ); + }, + [isApplied], + ); + + const value = useMemo( + () => ({ + providerGroups: props.providerGroups, + runtimeMode: props.runtimeMode, + onUpdateRuntimeMode: props.onUpdateRuntimeMode, + displayedDescriptors, + providerExpansionOverrides, + hasLegacyModels, + pendingModel, + providerFilter, + searchQuery, + showLegacy: showLegacyToggle, + applyOptionChange, + commitPendingModel, + isApplied, + isDisplayed, + pressModel, + setProviderFilter, + setSearchQuery, + setShowLegacy: setShowLegacyToggle, + toggleProvider, + }), + [ + applyOptionChange, + commitPendingModel, + displayedDescriptors, + providerExpansionOverrides, + hasLegacyModels, + isApplied, + isDisplayed, + pendingModel, + pressModel, + providerFilter, + props.onUpdateRuntimeMode, + props.providerGroups, + props.runtimeMode, + searchQuery, + showLegacyToggle, + toggleProvider, + ], + ); + + return ( + + {props.children} + + ); +} + +function useThreadSettingsSession() { + const value = use(ThreadSettingsSessionContext); + if (!value) { + throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider."); + } + return value; +} + +type ThreadSettingsProviderCatalog = { + readonly key: string; + readonly driver: string | undefined; + readonly label: string; + readonly collapsible: boolean; + readonly collapsed: boolean; + readonly modelCount: number; + readonly models: ReadonlyArray; +}; + +type ThreadSettingsCatalogItem = + | { + readonly kind: "provider"; + readonly key: string; + readonly provider: ThreadSettingsProviderCatalog; + } + | { + readonly kind: "model"; + readonly key: string; + readonly option: ModelOption; + readonly isFirst: boolean; + readonly isLast: boolean; + } + | { + readonly kind: "empty"; + readonly key: "empty"; + } + | { + readonly kind: "options"; + readonly key: "options"; + }; + +function ThreadSettingsModelListRow(props: { + readonly option: ModelOption; + readonly isFirst: boolean; + readonly isLast: boolean; +}) { + const session = useThreadSettingsSession(); + const onPress = useCallback( + () => session.pressModel(props.option), + [props.option, session.pressModel], + ); + + return ( + + ); +} + +function ThreadSettingsProviderListHeader(props: { + readonly provider: ThreadSettingsProviderCatalog; +}) { + const session = useThreadSettingsSession(); + const onToggle = useCallback( + () => session.toggleProvider(props.provider.key), + [props.provider.key, session.toggleProvider], + ); + + return ( + + ); +} + +function useThreadSettingsCatalogItems( + session: ThreadSettingsSessionValue, +): ReadonlyArray { + return useMemo( + () => + session.providerGroups.flatMap((group) => { + if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { + return []; + } + const driver = group.models[0]?.providerDriver; + const catalogModels = session.showLegacy + ? group.models + : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); + const visibleModels = catalogModels.filter((model) => + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ); + if (visibleModels.length === 0) { + return []; + } + const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); + // Staging a model must not change disclosure state. The applied model + // stays stable for the lifetime of this picker (Save closes it), so it + // is safe to use as the initial selected-provider default. + const containsAppliedSelection = group.models.some(session.isApplied); + const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0; + const collapsible = !isNarrowed; + const collapsed = providerSectionIsCollapsed({ + defaultExpanded: isPrimary || containsAppliedSelection, + hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey), + isNarrowed, + }); + const provider: ThreadSettingsProviderCatalog = { + key: group.providerKey, + driver, + label: group.providerLabel, + collapsible, + collapsed, + modelCount: visibleModels.length, + models: collapsed ? [] : visibleModels, + }; + return [ + { + kind: "provider" as const, + key: `provider:${group.providerKey}`, + provider, + }, + ...provider.models.map((option, index) => ({ + kind: "model" as const, + key: `model:${option.key}`, + option, + isFirst: index === 0, + isLast: index === provider.models.length - 1, + })), + ]; + }), + [ + session.isApplied, + session.isDisplayed, + session.providerExpansionOverrides, + session.providerFilter, + session.providerGroups, + session.searchQuery, + session.showLegacy, + ], + ); +} + +function ThreadSettingsOptionsItem(props: { + readonly animationsReady: boolean; + readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; +}) { + const insets = useSafeAreaInsets(); + const session = useThreadSettingsSession(); + const bottomToolbarInset = + Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + : 0; + + return ( + + Options + + {session.displayedDescriptors.map((descriptor) => { + if (descriptor.type === "select") { + return ( + + props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} + /> + + ); + } + return ( + + session.applyOptionChange(descriptor.id, value)} + /> + + ); + })} + + choice.mode === session.runtimeMode)?.label + } + onPress={() => props.onOpenSubmenu({ kind: "runtime" })} + /> + + + + {Platform.OS !== "ios" && session.hasLegacyModels ? ( + <> + + Catalog + + + + + + ) : null} + + ); +} + +/** One native scroll owner for the model catalog and its related settings. */ +function ThreadSettingsMainContent(props: { + readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; +}) { + const session = useThreadSettingsSession(); + const catalogItems = useThreadSettingsCatalogItems(session); + const [animationsReady, setAnimationsReady] = useState(false); + const nativeHeaderHeight = use(HeaderHeightContext) ?? 0; + const hasActiveCatalogFilter = + session.providerFilter !== null || session.searchQuery.trim().length > 0; + const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED; + const listItems = useMemo>( + () => [ + ...(catalogItems.length === 0 && hasActiveCatalogFilter + ? ([{ kind: "empty", key: "empty" }] as const) + : catalogItems), + { kind: "options", key: "options" }, + ], + [catalogItems, hasActiveCatalogFilter], + ); + const renderCatalogItem = useCallback( + (itemProps: LegendListRenderItemProps) => { + const item = itemProps.item; + let content: ReactNode; + + if (item.kind === "provider") { + content = ; + } else if (item.kind === "model") { + content = ( + + ); + } else if (item.kind === "empty") { + content = ( + + No matching models + + ); + } else { + content = ( + + ); + } + + return ( + + {content} + + ); + }, + [animationsReady, props.onOpenSubmenu], + ); + + return ( + item.kind} + itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} + keyExtractor={(item) => item.key} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} + ListHeaderComponent={ + <> + {usesTransparentNativeHeader ? : null} + {Platform.OS === "android" ? ( + + + + ) : null} + + } + recycleItems + onLoad={() => setAnimationsReady(true)} + renderItem={renderCatalogItem} + showsVerticalScrollIndicator={false} + /> + ); +} + +/** Compact choice page pushed by the picker navigator. */ +function ThreadSettingsChoiceContent(props: { + readonly submenu: ThreadSettingsSubmenuPage; + readonly onSelected: () => void; +}) { + const insets = useSafeAreaInsets(); + const session = useThreadSettingsSession(); + const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; const activeDescriptor = - submenu?.kind === "descriptor" - ? displayedDescriptors.find( - (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + descriptorId !== null + ? session.displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, ) : undefined; const submenuContent = - submenu?.kind === "runtime" + props.submenu.kind === "runtime" ? { - title: "Runtime", rows: RUNTIME_MODE_CHOICES.map((choice) => ({ id: choice.mode, label: choice.label, - selected: choice.mode === props.runtimeMode, + description: choice.description, + selected: choice.mode === session.runtimeMode, onPress: () => { void Haptics.selectionAsync(); - props.onUpdateRuntimeMode(choice.mode); - setSubmenu(null); + session.onUpdateRuntimeMode(choice.mode); + props.onSelected(); }, })), } : activeDescriptor?.type === "select" ? { - title: activeDescriptor.label, rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, + description: undefined, selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), onPress: () => { void Haptics.selectionAsync(); - handleOptionChange(activeDescriptor.id, choice.id); - setSubmenu(null); + session.applyOptionChange(activeDescriptor.id, choice.id); + props.onSelected(); }, })), } : null; + if (!submenuContent) { + return ; + } + return ( - setSubmenu(null) : () => props.onClose("dismiss")} + - - props.onClose("dismiss")} + + {submenuContent.rows.map((row, index) => ( + + ))} + + + ); +} + +type ThreadSettingsPickerStackParams = { + ThreadSettingsModels: undefined; + ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; +}; + +type ThreadSettingsPickerPresentation = { + readonly onClose: () => void; +}; + +const ThreadSettingsPickerStack = createNativeStackNavigator(); +const ThreadSettingsPickerPresentationContext = + createContext(null); + +function useThreadSettingsPickerPresentation() { + const value = use(ThreadSettingsPickerPresentationContext); + if (!value) { + throw new Error( + "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.", + ); + } + return value; +} + +function ThreadSettingsModelsScreen() { + const session = useThreadSettingsSession(); + const presentation = useThreadSettingsPickerPresentation(); + const navigation = useNavigation>(); + const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; + const commitAndClose = useCallback(() => { + session.commitPendingModel(); + presentation.onClose(); + }, [presentation, session]); + const filterMenu = useMemo( + () => ({ + title: "Model filters", + items: [ + { + type: "submenu" as const, + title: "Provider", + items: [ + { + type: "action" as const, + title: "All providers", + state: session.providerFilter === null ? ("on" as const) : ("off" as const), + onPress: () => session.setProviderFilter(null), + }, + ...session.providerGroups.map((group) => ({ + type: "action" as const, + title: group.providerLabel, + state: + session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const), + onPress: () => session.setProviderFilter(group.providerKey), + })), + ], + }, + ...(session.hasLegacyModels + ? [ + { + type: "action" as const, + title: "Show legacy models", + state: session.showLegacy ? ("on" as const) : ("off" as const), + onPress: () => session.setShowLegacy(!session.showLegacy), + }, + ] + : []), + ], + }), + [session], + ); + + return ( + <> + {Platform.OS === "android" ? ( + - - {/* The grabber doubles as the accessible close control: the dim - backdrop above a tall sheet is a sliver, and VoiceOver can't - reach it at all. */} - props.onClose("dismiss")} - className="items-center pb-1 pt-2.5" - > - - - {hasLegacyModels ? ( - - { - void Haptics.selectionAsync(); - setShowLegacyToggle(!showLegacy); - }} - className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" - > - - {showLegacy ? "Hide legacy models" : "Show legacy models"} - - - - ) : null} - {/* Only the model list scrolls. Provider catalogs can run to - hundreds of models (OpenRouter), so the rows below stay pinned - and reachable instead of living at the end of that scroll. */} - group.providerKey), + session.showLegacy, + ]} + options={{ + unstable_headerToolbarItems: usesNativeMailSearchToolbar + ? () => [ + createNativeMailSearchToolbarItem({ + filterButtonId: "thread-settings-model-filter", + filterMenu, + filterSystemImageName: hasCustomCatalogFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onSearchTextChange: session.setSearchQuery, + placeholder: "Find a model", + searchTextChangeId: "thread-settings-model-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerShown: Platform.OS !== "android", + headerSearchBarOptions: + Platform.OS === "ios" && !usesNativeMailSearchToolbar + ? { + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + onCancelButtonPress: () => session.setSearchQuery(""), + onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text), + placeholder: "Find a model", + } + : undefined, + }} + /> + { + const title = + submenu.kind === "runtime" + ? "Runtime" + : (session.displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + )?.label ?? "Option"); + navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); + }} + /> + + + + + + + {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( + + - {props.providerGroups.map((group) => { - const driver = group.models[0]?.providerDriver; - const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); - const visibleModels = showLegacy - ? group.models - : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); - if (visibleModels.length === 0) { - return null; - } - const containsSelection = group.models.some(isDisplayed); - const collapsible = !isPrimary && !containsSelection; - const collapsed = collapsible && !expandedProviders.has(group.providerKey); - return ( - - toggleProvider(group.providerKey)} - /> - {collapsed - ? null - : visibleModels.map((option) => ( - { - void Haptics.selectionAsync(); - // Re-tapping the applied model cancels staging. - setPendingModel((current) => - pendingModelAfterPress({ - current, - pressed: option, - pressedIsApplied: isApplied(option), - }), - ); - }} - /> - ))} - - ); - })} - - - - - - {descriptorTemplate.map((entry) => { - const live = displayedDescriptors.find( - (descriptor) => descriptor.label === entry.label, - ); - if ((live?.type ?? entry.type) === "select") { - return ( - { - if (live) { - setSubmenu({ kind: "descriptor", id: live.id }); - } - }} - /> - ); - } - return ( - { - if (live) { - handleOptionChange(live.id, value); - } - }} - /> - ); - })} - choice.mode === props.runtimeMode)?.label - } - onPress={() => setSubmenu({ kind: "runtime" })} - /> - - - {pendingModel ? "Save" : "Done"} - - - - - - {/* Submenus stack over the sheet instead of replacing its content, - so the main sheet keeps its size while drilling in and out. */} - {submenuContent ? ( - - setSubmenu(null)} - /> - - setSubmenu(null)} - className="items-center pb-1 pt-2.5" + + Provider + session.setProviderFilter(null)} > - - - - {submenuContent.title} - - + {session.providerGroups.map((group) => ( + session.setProviderFilter(group.providerKey)} + > + {group.providerLabel} + + ))} + + {session.hasLegacyModels ? ( + session.setShowLegacy(!session.showLegacy)} > - {submenuContent.rows.map((row) => ( - - ))} - - - - ) : null} - - + Show legacy models + + ) : null} + + + ) : null} + + ); +} + +function ThreadSettingsChoiceScreen() { + const navigation = useNavigation>(); + const route = useRoute>(); + + return ( + <> + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + navigation.goBack()} /> + + ); +} + +function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { + const sheetBackground = String(useThemeColor("--color-sheet")); + const foreground = String(useThemeColor("--color-foreground")); + const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground; + const presentation = useMemo( + () => ({ + onClose: props.onClose, + }), + [props.onClose], + ); + + return ( + + + + ({ title: route.params.title })} + /> + + + ); +} + +/** Existing-thread model picker hosted by the root RNS form-sheet route. */ +export function ExistingThreadSettingsRouteScreen() { + const navigation = useNavigation>>(); + const presentation = useExistingThreadSettingsRoutePresentation(); + const session = presentation.session; + + useEffect(() => { + if (session) { + return; + } + + navigation.goBack(); + }, [navigation, session]); + + if (!session) { + return ; + } + + const { ownerId: _ownerId, ...settings } = session; + + return ( + + navigation.goBack()} /> + + ); +} + +/** + * Native stack hosted by the New Task navigator's form-sheet route. Keeping + * the sheet presentation in RNS gives UIKit ownership of nested dismissal, + * while Reasoning and Runtime remain regular pushes inside this navigator. + */ +export function NewTaskThreadSettingsRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation>>(); + const optionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: flow.selectedModelOption?.capabilities, + selections: flow.selectedModel?.options, + }), + [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + ); + + return ( + flow.setSelectedModelKey(option.key, option.selection.options)} + optionDescriptors={optionDescriptors} + onUpdateOptionSelections={flow.setSelectedModelOptions} + runtimeMode={flow.runtimeMode} + onUpdateRuntimeMode={flow.setRuntimeMode} + > + navigation.goBack()} /> + ); } diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts new file mode 100644 index 000000000000..e556318855ff --- /dev/null +++ b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; + +describe("resolvePendingTaskInteractionMode", () => { + it("preserves a queued plan task while the preference is still loading", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: false, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: "plan", + }), + ).toBe("plan"); + }); + + it("forces build mode once the disabled preference has loaded", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: "plan", + }), + ).toBe("default"); + }); + + it("keeps a fresh draft in build mode while the preference is loading", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: false, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: undefined, + }), + ).toBe("default"); + }); + + it("honors the draft's mode when the plan preference is enabled", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: true, + draftInteractionMode: "plan", + queuedInteractionMode: undefined, + }), + ).toBe("plan"); + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: true, + draftInteractionMode: undefined, + queuedInteractionMode: "plan", + }), + ).toBe("default"); + }); +}); diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts new file mode 100644 index 000000000000..e7122125fb58 --- /dev/null +++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts @@ -0,0 +1,29 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type ProviderInteractionMode, +} from "@t3tools/contracts"; + +export function resolveLegacyPlanModeEnabled(input: { + readonly loaded: boolean; + readonly preference: boolean | undefined; +}): boolean { + return input.loaded && input.preference === true; +} + +export function resolvePendingTaskInteractionMode(input: { + readonly preferenceLoaded: boolean; + readonly planModeEnabled: boolean; + readonly draftInteractionMode: ProviderInteractionMode | undefined; + readonly queuedInteractionMode: ProviderInteractionMode | undefined; +}): ProviderInteractionMode { + if (input.planModeEnabled) { + return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + } + if (!input.preferenceLoaded) { + // Only an existing queued task may retain its previous mode while the + // preference is unknown. A fresh draft still defaults to Build so a stale + // persisted Plan selection cannot bypass a disabled preference at launch. + return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + } + return DEFAULT_PROVIDER_INTERACTION_MODE; +} diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts new file mode 100644 index 000000000000..3c81d8231216 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveNewTaskBranchWorktreePath, + resolveNewTaskBranchLabel, + resolveNewTaskLocalWorkspaceSelection, +} from "./new-task-context-presentation"; + +describe("resolveNewTaskLocalWorkspaceSelection", () => { + it("waits for refs instead of carrying a worktree base into Current checkout", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [], + projectCwd: "/repo", + }), + ).toEqual({ + branch: null, + worktreePath: null, + awaitsCurrentBranch: true, + }); + }); + + it("adopts the checkout's current branch once refs load", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [ + { name: "feature/worktree-base", current: false, worktreePath: "/worktree" }, + { name: "main", current: true, worktreePath: "/repo" }, + ], + projectCwd: "/repo", + }), + ).toEqual({ + branch: "main", + worktreePath: null, + awaitsCurrentBranch: false, + }); + }); + + it("carries the worktree path when the current branch lives in another worktree", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [ + { name: "feature/split", current: true, worktreePath: "/repo/.t3/worktrees/split" }, + { name: "main", current: false, worktreePath: "/repo" }, + ], + projectCwd: "/repo", + }), + ).toEqual({ + branch: "feature/split", + worktreePath: "/repo/.t3/worktrees/split", + awaitsCurrentBranch: false, + }); + }); +}); + +describe("resolveNewTaskBranchWorktreePath", () => { + it("moves Current checkout to the selected existing worktree", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: "/repo", + branchWorktreePath: "/repo/.t3/worktrees/feature", + }), + ).toBe("/repo/.t3/worktrees/feature"); + }); + + it("keeps the project checkout represented by a null override", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: "/repo", + branchWorktreePath: "/repo", + }), + ).toBeNull(); + }); + + it("does not reuse an existing worktree while creating a new one", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "worktree", + projectCwd: "/repo", + branchWorktreePath: "/repo/.t3/worktrees/feature", + }), + ).toBeNull(); + }); +}); + +describe("resolveNewTaskBranchLabel", () => { + it("shows the checked-out branch without a base-ref prefix", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "feature/mobile", + startFromOrigin: true, + workspaceMode: "local", + }), + ).toBe("feature/mobile"); + }); + + it("labels a local worktree base with From", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "main", + startFromOrigin: false, + workspaceMode: "worktree", + }), + ).toBe("From main"); + }); + + it("labels a remote worktree base with From origin", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "main", + startFromOrigin: true, + workspaceMode: "worktree", + }), + ).toBe("From origin/main"); + }); + + it("prompts when no branch is available", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: null, + startFromOrigin: true, + workspaceMode: "worktree", + }), + ).toBe("Choose branch"); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts new file mode 100644 index 000000000000..99eee3ea48ae --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -0,0 +1,83 @@ +type WorkspaceMode = "local" | "worktree"; + +export function resolveNewTaskWorkspaceLabel(input: { + readonly workspaceMode: WorkspaceMode; + readonly worktreePath: string | null; +}): "Current checkout" | "Current worktree" | "New worktree" { + if (input.workspaceMode === "worktree") { + return "New worktree"; + } + return input.worktreePath ? "Current worktree" : "Current checkout"; +} + +export function resolveNewTaskBranchWorktreePath(input: { + readonly workspaceMode: WorkspaceMode; + readonly projectCwd: string; + readonly branchWorktreePath: string | null | undefined; +}): string | null { + if ( + input.workspaceMode === "worktree" || + !input.branchWorktreePath || + input.branchWorktreePath === input.projectCwd + ) { + return null; + } + return input.branchWorktreePath; +} + +export function resolveNewTaskLocalWorkspaceSelection(input: { + readonly branches: ReadonlyArray<{ + readonly name: string; + readonly current: boolean; + readonly worktreePath?: string | null; + }>; + readonly projectCwd: string; +}): { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly awaitsCurrentBranch: boolean; +} { + const currentBranch = input.branches.find((branch) => branch.current) ?? null; + if (!currentBranch) { + return { + branch: null, + worktreePath: null, + awaitsCurrentBranch: true, + }; + } + + return { + branch: currentBranch.name, + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: input.projectCwd, + branchWorktreePath: currentBranch.worktreePath, + }), + awaitsCurrentBranch: false, + }; +} + +export function resolveNewTaskBranchLabel(input: { + readonly branchName: string | null; + readonly startFromOrigin: boolean; + readonly workspaceMode: WorkspaceMode; +}): string { + if (!input.branchName) { + return "Choose branch"; + } + + if (input.workspaceMode === "local") { + return input.branchName; + } + + const baseRef = input.startFromOrigin ? `origin/${input.branchName}` : input.branchName; + return `From ${baseRef}`; +} + +export function shouldCheckoutNewTaskBranch(input: { + readonly branchIsCurrent: boolean; + readonly branchWorktreePath: string | null | undefined; + readonly workspaceMode: WorkspaceMode; +}): boolean { + return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 7d79e9ecead9..44056ead3225 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, + copyComposerDraftContentIfEmpty, getComposerDraftSnapshot, isComposerDraftEmpty, removeComposerDraftAttachment, @@ -50,7 +51,7 @@ import { updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; -import { useBranches } from "../../state/queries"; +import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { flattenQueuedThreadMessages, threadOutboxManager, @@ -74,10 +75,16 @@ import { type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; +import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; +import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; +import { + resolveNewTaskBranchWorktreePath, + resolveNewTaskLocalWorkspaceSelection, +} from "./new-task-context-presentation"; type WorkspaceMode = "local" | "worktree"; -const EMPTY_BRANCH_REFS: ReadonlyArray = []; +const BRANCH_SEARCH_DEBOUNCE_MS = 150; function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; @@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { return message?.creation !== undefined ? message : null; } -function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { - if (!branch.worktreePath) { - return null; - } - - return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; -} - export function branchBadgeLabel(input: { readonly branch: VcsRef; readonly project: EnvironmentProject | null; @@ -117,9 +116,6 @@ export function branchBadgeLabel(input: { if (input.branch.isDefault) { return "default"; } - if (input.branch.isRemote) { - return "remote"; - } return null; } @@ -139,9 +135,13 @@ type NewTaskFlowContextValue = { readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; + readonly branchesError: string | null; + readonly branchesFetchingNextPage: boolean; + readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -175,7 +175,8 @@ type NewTaskFlowContextValue = { readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; readonly setBranchQuery: (value: string) => void; - readonly loadBranches: () => Promise; + readonly loadBranches: () => void; + readonly loadMoreBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( @@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); const [editingPendingTask, setEditingPendingTask] = useState(null); + const pendingLocalBranchSyncDraftKeysRef = useRef(new Set()); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. const editingPendingTaskRef = useRef(null); @@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); + pendingLocalBranchSyncDraftKeysRef.current.clear(); const editing = editingPendingTaskRef.current; editingPendingTaskRef.current = null; setEditingPendingTask(null); @@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + const interactionMode = planModeEnabled + ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently @@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } replaceComposerDraftAttachments(selectedProjectDraftKey, []); }, [selectedProjectDraftKey]); + const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); const branchTarget = useMemo( () => ({ environmentId: selectedProject?.environmentId ?? null, // `|| null` also skips the stand-in project's empty workspaceRoot. cwd: selectedProject?.workspaceRoot || null, - query: null, + query: debouncedBranchQuery, }), - [selectedProject?.environmentId, selectedProject?.workspaceRoot], + [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], ); - const branchState = useBranches(branchTarget); - const branchesLoading = branchState.isPending; - const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; + const branchState = usePaginatedBranches(branchTarget); + const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); + const branchesLoading = + branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); + const branchesFetchingNextPage = branchState.isFetchingNextPage; + const hasMoreBranches = + branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined; + const allBranchRefs = branchState.refs; const availableBranches = useMemo( () => pipe( @@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback((project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); - }, []); + const setProject = useCallback( + (project: EnvironmentProject) => { + const nextProjectKey = scopedProjectKey(project.environmentId, project.id); + const nextDraftKey = `new-task:${nextProjectKey}`; + if ( + selectedProjectDraftKey?.startsWith("new-task:") && + selectedProjectDraftKey !== nextDraftKey + ) { + void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); + } + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(nextProjectKey); + }, + [selectedProjectDraftKey], + ); const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { @@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!selectedProjectDraftKey) { return; } + if (!selectedProject) { + return; + } + const localSelection = resolveNewTaskLocalWorkspaceSelection({ + branches: availableBranches, + projectCwd: selectedProject.workspaceRoot, + }); + if (mode === "local" && localSelection.awaitsCurrentBranch) { + pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey); + } else { + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); + } updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, - branch: selectedBranchName, - worktreePath: selectedWorktreePath, + branch: mode === "local" ? localSelection.branch : selectedBranchName, + worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + [ + availableBranches, + draftStartFromOrigin, + selectedBranchName, + selectedProject, + selectedProjectDraftKey, + selectedWorktreePath, + ], ); + useEffect(() => { + if ( + workspaceMode !== "local" || + !selectedProject || + !selectedProjectDraftKey || + !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey) + ) { + return; + } + const localSelection = resolveNewTaskLocalWorkspaceSelection({ + branches: availableBranches, + projectCwd: selectedProject.workspaceRoot, + }); + if (localSelection.awaitsCurrentBranch) { + return; + } + + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: "local", + branch: localSelection.branch, + worktreePath: localSelection.worktreePath, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), + }, + }); + }, [ + availableBranches, + draftStartFromOrigin, + selectedProject, + selectedProjectDraftKey, + workspaceMode, + ]); + const selectBranch = useCallback( (branch: VcsRef) => { if (!selectedProject || !selectedProjectDraftKey) { return; } + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode: workspaceMode, branch: branch.name, - worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode, + projectCwd: selectedProject.workspaceRoot, + branchWorktreePath: branch.worktreePath, + }), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); @@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const refreshBranches = branchState.refresh; - const loadBranches = useCallback(async () => { + const loadMoreBranches = branchState.loadNext; + const loadBranches = useCallback(() => { if (!selectedProject) { return; } @@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { attachments: draft.attachments, modelSelection: draftModelSelection, runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + interactionMode: resolvePendingTaskInteractionMode({ + preferenceLoaded: planModePreferenceLoaded, + planModeEnabled, + draftInteractionMode: draft.interactionMode, + queuedInteractionMode: editingPendingTask?.interactionMode, + }), creation: { projectId: selectedProject.id, ...(projectTitle !== undefined ? { projectTitle } : {}), @@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + planModeEnabled, + planModePreferenceLoaded, startFromOrigin, workspaceMode, ], @@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { submitting, branchQuery, branchesLoading, + branchesError: branchState.error, + branchesFetchingNextPage, + hasMoreBranches, availableBranches, runtimeMode, interactionMode, + planModeEnabled, expandedProvider, environments, selectedProject, @@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSubmitting, setBranchQuery, loadBranches, + loadMoreBranches, setRuntimeMode, setInteractionMode, setSelectedModelOptions, @@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { beginEditingPendingTask, branchQuery, branchesLoading, + branchState.error, + branchesFetchingNextPage, buildPendingTaskMessage, cancelEditingPendingTask, editingPendingTask, @@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, finishEditingPendingTask, interactionMode, + planModeEnabled, loadBranches, + loadMoreBranches, projectScopes, modelOptions, prompt, @@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { reset, runtimeMode, selectedBranchName, + hasMoreBranches, selectedEnvironmentId, selectedModel, selectedModelKey, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index d8ed12bcc73a..7068a95d558a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { getOnlySelectableProject, + getProjectScopeSelectionTarget, resolveDraftProjectSelection, } from "./new-task-project-selection"; -function makeProject(id: string): EnvironmentProject { +function makeProject(id: string, environmentId = "environment"): EnvironmentProject { return { - environmentId: EnvironmentId.make("environment"), + environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), title: id, workspaceRoot: `/work/${id}`, @@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => { expect(getOnlySelectableProject([makeScope([project])])).toBe(project); }); - it("does not auto-select a representative when one group has multiple clones", () => { + it("selects the representative when one logical project has multiple workspaces", () => { const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBeNull(); + expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); + }); +}); + +describe("getProjectScopeSelectionTarget", () => { + it("keeps the current environment when it hosts the selected logical project", () => { + const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; + expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe( + projects[1], + ); + }); + + it("falls back to the representative when the current environment does not host the project", () => { + const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; + expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe( + projects[0], + ); }); }); @@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => { }); }); - it("opens the picker for multiple physical projects in one logical group", () => { + it("selects one logical project even when it has multiple physical workspaces", () => { const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ - kind: "pick", + kind: "select", + project: projects[0], }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 29ae3cf4f54f..7be899d62a1a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,18 +1,29 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; -export type DraftProjectSelectionResolution = +type DraftProjectSelectionResolution = | { readonly kind: "preserve" } | { readonly kind: "select"; readonly project: EnvironmentProject } | { readonly kind: "pick" }; +export function getProjectScopeSelectionTarget( + scope: HomeProjectScope, + preferredEnvironmentId: EnvironmentId | null, +): EnvironmentProject { + return ( + scope.projects.find((project) => project.environmentId === preferredEnvironmentId) ?? + scope.representative + ); +} + export function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; - return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null; + return onlyScope?.representative ?? null; } export function resolveDraftProjectSelection( diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts deleted file mode 100644 index 078be2df11bd..000000000000 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; - -import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; - -function modelOption( - model: string, - overrides: Partial> = {}, -): ModelOption { - const providerKey = overrides.providerKey ?? "codex"; - return { - key: `${providerKey}:${model}`, - label: model, - subtitle: providerKey, - providerKey, - providerLabel: providerKey === "codex" ? "Codex" : "Claude", - providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", - isDefault: overrides.isDefault ?? false, - isLegacy: overrides.isLegacy ?? false, - capabilities: null, - selection: { - instanceId: ProviderInstanceId.make(providerKey), - model, - options: [], - }, - }; -} - -function group(models: ReadonlyArray): ProviderGroup { - const first = models[0]; - if (!first) { - throw new Error("group requires at least one model"); - } - return { - providerKey: first.providerKey, - providerLabel: first.providerLabel, - models, - }; -} - -const effortDescriptor: ProviderOptionDescriptor = { - id: "effort", - label: "Reasoning", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - { id: "ultrathink", label: "Ultrathink" }, - { id: "ultracode", label: "Ultracode" }, - ], - currentValue: "high", - promptInjectedValues: ["ultrathink"], -}; - -const fastModeDescriptor: ProviderOptionDescriptor = { - id: "fastMode", - label: "Fast mode", - type: "boolean", - currentValue: false, -}; - -function baseInput() { - const models = [ - modelOption("gpt-current", { isDefault: true }), - modelOption("gpt-next"), - modelOption("gpt-old", { isLegacy: true }), - ]; - return { - providerGroups: [group(models)], - selectedModel: models[0]?.selection ?? null, - optionDescriptors: [effortDescriptor, fastModeDescriptor], - runtimeMode: "auto", - } as const; -} - -function eventFor(menu: ReturnType, id: string | undefined) { - return id === undefined ? undefined : menu.events.get(id); -} - -describe("buildThreadSettingsMenu", () => { - it("orders the top level as model, options, runtime", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - expect(menu.actions.map((action) => action.title)).toEqual([ - "Model", - "Reasoning", - "Fast mode", - "Runtime", - ]); - }); - - it("summarizes the current choice on each submenu row", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); - expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); - expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); - }); - - it("checkmarks the selected model and resolves selection events", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - const current = modelItems.find((action) => action.title === "gpt-current"); - expect(current?.state).toBe("on"); - expect(current?.subtitle).toBe("Default"); - expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); - - const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); - expect(event?.type).toBe("select-model"); - expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); - }); - - it("folds unselected legacy models behind a nested submenu", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect(modelItems.map((action) => action.title)).toEqual([ - "gpt-current", - "gpt-next", - "Legacy Models", - ]); - expect( - modelItems - .find((action) => action.title === "Legacy Models") - ?.subactions?.map((action) => action.title), - ).toEqual(["gpt-old"]); - }); - - it("keeps a selected legacy model in the main list", () => { - const input = baseInput(); - const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); - const menu = buildThreadSettingsMenu({ - ...input, - selectedModel: legacy?.selection ?? null, - }); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect(modelItems.map((action) => action.title)).toEqual([ - "gpt-current", - "gpt-next", - "gpt-old", - ]); - expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); - }); - - it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { - const menu = buildThreadSettingsMenu({ - ...baseInput(), - optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], - }); - - const reasoning = menu.actions.find((action) => action.title === "Reasoning"); - expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); - // The hidden value stays visible as the current summary; it just can't be - // picked from the phone. - expect(reasoning?.subtitle).toBe("Ultracode"); - expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); - }); - - it("resolves select-option and runtime events with checkmarked current values", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const reasoningItems = - menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; - expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); - expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ - type: "set-option", - optionId: "effort", - value: "low", - }); - - const runtimeItems = - menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; - expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); - expect( - eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), - ).toEqual({ type: "set-runtime", mode: "full-access" }); - }); - - it("toggles boolean options with the inverted current value", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const fastMode = menu.actions.find((action) => action.title === "Fast mode"); - expect(fastMode?.state).toBe("off"); - expect(fastMode?.subactions).toBeUndefined(); - expect(eventFor(menu, fastMode?.id)).toEqual({ - type: "set-option", - optionId: "fastMode", - value: true, - }); - - const enabled = buildThreadSettingsMenu({ - ...baseInput(), - optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], - }); - const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); - expect(enabledRow?.state).toBe("on"); - expect(eventFor(enabled, enabledRow?.id)).toEqual({ - type: "set-option", - optionId: "fastMode", - value: false, - }); - }); - - it("keeps the menu presented only for top-level toggles", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - // Root-level boolean toggles refresh in place with clean chrome, so they - // keep the menu presented. - expect( - menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, - ).toBe(true); - - // Picks inside nested submenus close the menu: staying presented leaves - // the submenu on screen with an expanded-submenu header, and the - // bottom-anchored collapse back out drops by the levels' height delta. - const expected = undefined; - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - const reasoningItems = - menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; - const runtimeItems = - menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; - const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( - (action) => action.subactions === undefined, - ); - expect(nestedPicks.length).toBeGreaterThan(0); - expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( - true, - ); - }); - - it("sections models by provider only when multiple groups are offered", () => { - const codexModels = [modelOption("gpt-current", { isDefault: true })]; - const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; - const menu = buildThreadSettingsMenu({ - providerGroups: [group(codexModels), group(claudeModels)], - selectedModel: codexModels[0]?.selection ?? null, - optionDescriptors: [], - runtimeMode: "auto", - }); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect( - modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), - ).toEqual([ - { title: "Codex", inline: true }, - { title: "Claude", inline: true }, - ]); - const claudeSection = modelItems.find((action) => action.title === "Claude"); - expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); - }); - - const eventTypes = (menu: ReturnType) => { - const types = new Set(); - for (const event of menu.events.values()) { - types.add(event.type); - } - return types; - }; - - it("registers an event for every leaf action id", () => { - const menu = buildThreadSettingsMenu(baseInput()); - const leafIds: string[] = []; - const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { - for (const item of items) { - if (Array.isArray(item.subactions) && item.subactions.length > 0) { - collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); - } else if (item.id !== undefined) { - leafIds.push(item.id); - } - } - }; - collect(menu.actions); - - for (const id of leafIds) { - expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); - } - expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts deleted file mode 100644 index 31b1c021c46f..000000000000 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; -import { - getProviderOptionCurrentLabel, - getProviderOptionCurrentValue, -} from "@t3tools/shared/model"; - -import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; - -/** - * Desktop-oriented effort keywords that don't belong in the phone picker. - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not - * a reasoning level. A value set elsewhere still displays, it just isn't - * offered. - */ -export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); - -export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - readonly shortLabel: string; -}> = [ - { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, - { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, - { mode: "auto", label: "Auto", shortLabel: "Auto" }, - { mode: "full-access", label: "Full access", shortLabel: "Full" }, -]; - -export function selectableChoices( - descriptor: Extract, -) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} - -export type ThreadSettingsMenuEvent = - | { readonly type: "select-model"; readonly option: ModelOption } - | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } - | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; - -export type ThreadSettingsMenu = { - readonly actions: MenuAction[]; - /** Menu action id → the change it applies, for the onPressAction dispatch. */ - readonly events: ReadonlyMap; -}; - -/** - * Native menu replacement for the thread settings sheet (model, select and - * boolean provider options, runtime mode). The menu presents from the - * composer pill without resigning the keyboard, so adjusting settings never - * bounces focus. A thread is bound to one harness, so the menu covers the - * sheet's full surface for existing threads; the sheet remains the Android - * and new-task-draft surface. - * - * Selections apply immediately — the sheet's stage-then-Save flow only exists - * because the sheet batches a model change with its option edits. - */ -export function buildThreadSettingsMenu(input: { - readonly providerGroups: ReadonlyArray; - readonly selectedModel: ModelSelection | null; - readonly optionDescriptors: ReadonlyArray; - readonly runtimeMode: RuntimeMode; -}): ThreadSettingsMenu { - const events = new Map(); - const actions: MenuAction[] = []; - - const isSelected = (option: ModelOption) => - option.selection.instanceId === input.selectedModel?.instanceId && - option.selection.model === input.selectedModel.model; - - // Only top-level leaves (boolean toggles) keep the menu presented (iOS - // 16+): the root refreshes in place with clean chrome. Picks inside nested - // submenus close the menu — keeping the submenu presented renders an - // expanded-submenu header with no way to pop back to the root, and the - // bottom-anchored collapse back out travels the levels' height difference. - const keepPresented = { keepsMenuPresented: true } as const; - - const modelAction = (option: ModelOption, id: string): MenuAction => { - events.set(id, { type: "select-model", option }); - return { - id, - title: option.label, - ...(option.isDefault ? { subtitle: "Default" } : {}), - state: isSelected(option) ? "on" : "off", - }; - }; - - const modelItems: MenuAction[] = []; - const legacyItems: MenuAction[] = []; - let selectedModelLabel: string | undefined; - input.providerGroups.forEach((group, groupIndex) => { - const groupItems: MenuAction[] = []; - group.models.forEach((option, modelIndex) => { - if (isSelected(option)) { - selectedModelLabel = option.label; - } - const id = `model:${groupIndex}:${modelIndex}`; - // A highlighted legacy model stays in the main list (mirroring the - // sheet) so the checkmark isn't hidden behind the Legacy fold. - if (option.isLegacy && !isSelected(option)) { - legacyItems.push(modelAction(option, id)); - } else { - groupItems.push(modelAction(option, id)); - } - }); - if (groupItems.length === 0) { - return; - } - // A thread is bound to one harness, so provider sections only appear for - // multi-group callers (the new-task draft, if it ever adopts the menu). - if (input.providerGroups.length > 1) { - modelItems.push({ - id: `model-group:${groupIndex}`, - title: group.providerLabel, - displayInline: true, - subactions: groupItems, - }); - } else { - modelItems.push(...groupItems); - } - }); - if (legacyItems.length > 0) { - modelItems.push({ - id: "legacy-models", - title: "Legacy Models", - subactions: legacyItems, - }); - } - if (modelItems.length > 0) { - actions.push({ - id: "model", - title: "Model", - ...(selectedModelLabel === undefined - ? input.selectedModel - ? { subtitle: input.selectedModel.model } - : {} - : { subtitle: selectedModelLabel }), - subactions: modelItems, - }); - } - - for (const descriptor of input.optionDescriptors) { - if (descriptor.type === "boolean") { - const id = `option:${descriptor.id}`; - events.set(id, { - type: "set-option", - optionId: descriptor.id, - value: !(descriptor.currentValue ?? false), - }); - actions.push({ - id, - title: descriptor.label, - state: descriptor.currentValue ? "on" : "off", - attributes: keepPresented, - }); - continue; - } - const currentValue = getProviderOptionCurrentValue(descriptor); - const choices = selectableChoices(descriptor).map((choice): MenuAction => { - const id = `option:${descriptor.id}:${choice.id}`; - events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); - return { - id, - title: choice.label, - state: choice.id === currentValue ? "on" : "off", - }; - }); - if (choices.length === 0) { - continue; - } - const currentLabel = getProviderOptionCurrentLabel(descriptor); - actions.push({ - id: `option:${descriptor.id}`, - title: descriptor.label, - ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), - subactions: choices, - }); - } - - const runtimeLabel = RUNTIME_MODE_CHOICES.find( - (choice) => choice.mode === input.runtimeMode, - )?.label; - actions.push({ - id: "runtime", - title: "Runtime", - ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), - subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { - const id = `runtime:${choice.mode}`; - events.set(id, { type: "set-runtime", mode: choice.mode }); - return { - id, - title: choice.label, - state: choice.mode === input.runtimeMode ? "on" : "off", - }; - }), - }); - - return { actions, events }; -} diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts new file mode 100644 index 000000000000..041f8b9de010 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -0,0 +1,29 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { selectableChoices } from "./thread-settings-options"; + +const effortDescriptor: Extract = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +describe("selectableChoices", () => { + it("hides prompt-injected and workflow-trigger choices, keeping declared order", () => { + expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([ + "low", + "medium", + "high", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts new file mode 100644 index 000000000000..b678154f83bb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -0,0 +1,46 @@ +import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly description: string; +}> = [ + { + mode: "approval-required", + label: "Supervised", + description: "Ask before commands and file changes.", + }, + { + mode: "auto-accept-edits", + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + }, + { + mode: "auto", + label: "Auto", + description: "Supported providers approve routine actions; others still ask.", + }, + { + mode: "full-access", + label: "Full access", + description: "Allow commands and edits without prompts.", + }, +]; + +export function selectableChoices( + descriptor: Extract, +) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 1264c75cd337..2e8fee98572a 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; import type { ModelOption } from "../../lib/modelOptions"; -import { pendingModelAfterPress } from "./thread-settings-sheet-state"; +import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state"; function modelOption( model: string, @@ -28,6 +28,26 @@ function modelOption( } describe("thread settings sheet state", () => { + it("matches visible model and provider terms", () => { + const model = modelOption("gpt-next"); + + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true); + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true); + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe( + false, + ); + }); + + it("treats whitespace-only catalog searches as empty", () => { + expect( + modelMatchesCatalogQuery({ + model: modelOption("gpt-next"), + providerLabel: "Codex", + query: " ", + }), + ).toBe(true); + }); + it("clears staging when the applied model is pressed", () => { expect( pendingModelAfterPress({ diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index f0540dc5a971..1e417b925d9e 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,5 +1,24 @@ import type { ModelOption } from "../../lib/modelOptions"; +/** Match the terms a user can actually see or recognize in the model picker. */ +export function modelMatchesCatalogQuery(input: { + readonly model: ModelOption; + readonly providerLabel: string; + readonly query: string; +}): boolean { + const query = input.query.trim().toLocaleLowerCase(); + if (query.length === 0) { + return true; + } + + return [ + input.model.label, + input.model.subtitle, + input.model.selection.model, + input.providerLabel, + ].some((value) => value.toLocaleLowerCase().includes(query)); +} + /** Preserve staged provider options when the highlighted model is tapped again. */ export function pendingModelAfterPress(input: { readonly current: ModelOption | null; @@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: { } return input.current?.key === input.pressed.key ? input.current : input.pressed; } + +/** + * Primary and selected providers start open; all other catalogs start closed. + * A user's disclosure tap inverts that default until the picker is dismissed. + */ +export function providerSectionIsCollapsed(input: { + readonly defaultExpanded: boolean; + readonly hasExpansionOverride: boolean; + readonly isNarrowed: boolean; +}): boolean { + if (input.isNarrowed) { + return false; + } + return input.defaultExpanded ? input.hasExpansionOverride : !input.hasExpansionOverride; +} diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts new file mode 100644 index 000000000000..25ec4ff0e7d8 --- /dev/null +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -0,0 +1,26 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; + +/** + * Mobile preferences are device-local, matching the desktop client setting. + * Keep the legacy composer mode hidden until the preference has loaded and is + * explicitly enabled. + */ +export function useLegacyPlanModeEnabled(): boolean { + return useLegacyPlanModeState().enabled; +} + +export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { + const preferences = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferences); + return { + enabled: resolveLegacyPlanModeEnabled({ + loaded, + preference: loaded ? preferences.value.planModeEnabled : undefined, + }), + loaded, + }; +} diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts index 3cc2ed184684..b5b4914ad11e 100644 --- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; -export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; +type PresentationPhase = "closed" | "opening" | "visible"; -type PresentationPhase = "closed" | "opening" | "visible" | "closing"; +/** + * The navigator-level UIKit completion event added by the repo's + * `@react-navigation/native-stack` patch; absent from upstream event maps. + */ +export type NavigationWithFinishTransitioning = { + readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void; +}; + +/** + * How long after the dismissal's state change the keyboard starts rising, so + * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way + * UIKit apps choreograph it. This is aesthetics, not correctness: without + * keepFocus-style inputView overrides a show started mid-dismissal completes + * cleanly, so a slower device merely gets more overlap — no failure mode. + * The navigator's `finishTransitioning` event (UIKit's real completion + * callback, surfaced by the repo's native-stack patch) additionally bounds + * the restore at the true landing moment should this timer ever lag it. + */ +const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300; + +/** + * A JS-initiated dismissal pops state before its animation runs; a + * gesture-driven one animates natively first and pops afterwards, with the + * navigator's completion event landing a few dozen milliseconds before the + * pop. A completion this fresh at pop time therefore means the sheet is + * already gone and the keyboard should return immediately. The two orderings + * are separated by the sheet's full ~500ms travel, so this window is a + * classification with wide margin, not an animation race. + */ +const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150; /** - * Keeps the custom native composer and the settings modal from owning focus at - * the same time. Opening waits for the keyboard dismissal to finish, while - * focus restoration waits for the modal's dismissal callback. + * Keeps the custom native composer and the settings sheet from owning focus at + * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it + * once the sheet has fully landed. A plain blur/focus pair costs one keyboard + * animation each way — keepFocus-style inputView overrides are avoided because + * removing them forces UIKit to reload input views, replaying the keyboard's + * show as a visible collapse/re-open. */ export function useThreadSettingsSheetPresentation(input: { readonly editorRef: RefObject; @@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: { const [phase, setPhase] = useState("closed"); const isActiveRef = useRef(false); const isMountedRef = useRef(true); + const isEditorFocusedRef = useRef(input.isEditorFocused); const openingIdRef = useRef(0); - const restoreFocusOnSaveRef = useRef(false); - const shouldRestoreAfterDismissRef = useRef(false); + const focusRestoreIdRef = useRef(0); + const restoreFocusAfterDismissRef = useRef(false); + const restorePendingRef = useRef(false); + const lastStackTransitionFinishedAtRef = useRef(0); + const dismissRestoreTimerRef = useRef | null>(null); + const clearDismissRestoreTimer = useCallback(() => { + if (dismissRestoreTimerRef.current !== null) { + clearTimeout(dismissRestoreTimerRef.current); + dismissRestoreTimerRef.current = null; + } + }, []); - useEffect( - () => () => { + useEffect(() => { + isEditorFocusedRef.current = input.isEditorFocused; + }, [input.isEditorFocused]); + + useEffect(() => { + // React Strict Mode and Fast Refresh both run an effect cleanup/setup + // cycle without recreating refs. Re-arm the mounted guard on every setup. + isMountedRef.current = true; + return () => { isMountedRef.current = false; isActiveRef.current = false; openingIdRef.current += 1; - }, - [], - ); + focusRestoreIdRef.current += 1; + clearDismissRestoreTimer(); + }; + }, [clearDismissRestoreTimer]); const open = useCallback(() => { if (isActiveRef.current) { @@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: { } isActiveRef.current = true; - restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); - shouldRestoreAfterDismissRef.current = false; + focusRestoreIdRef.current += 1; + clearDismissRestoreTimer(); + restorePendingRef.current = false; + restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; openingIdRef.current = openingId; - // Keyboard.dismiss() only tracks React Native TextInputs. The composer is - // a custom native text view, so explicitly resign its first responder too. + // Start the keyboard transition before the custom native editor resigns + // first responder, then present the sheet on the next frame. The sheet and + // keyboard animate together instead of serializing two native transitions. + void KeyboardController.dismiss({ animated: true }); input.editorRef.current?.blur(); - void KeyboardController.dismiss().then(() => { + + requestAnimationFrame(() => { if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { return; } setPhase("visible"); }); - }, [input.editorRef, input.isEditorFocused]); + }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); + + const restoreEditorFocus = useCallback(() => { + const focusRestoreId = focusRestoreIdRef.current + 1; + focusRestoreIdRef.current = focusRestoreId; + let attemptsRemaining = 20; + + // Restoration runs after the dismissal transition, so the first attempt + // normally succeeds; the retries are insurance against UIKit briefly + // refusing first-responder status right at the transition boundary. + const restoreFocus = () => { + if ( + !isMountedRef.current || + focusRestoreIdRef.current !== focusRestoreId || + isEditorFocusedRef.current || + attemptsRemaining <= 0 + ) { + return; + } - const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { - if (!isActiveRef.current) { + attemptsRemaining -= 1; + input.editorRef.current?.focus(); + setTimeout(restoreFocus, 50); + }; + requestAnimationFrame(restoreFocus); + }, [input.editorRef]); + + /** Runs the queued restore once — whichever completion signal arrives first. */ + const runPendingDismissalRestore = useCallback(() => { + if (!restorePendingRef.current) { return; } + restorePendingRef.current = false; + clearDismissRestoreTimer(); + // A reopened sheet owns focus again; drop the stale restore request. + if (!isMountedRef.current || isActiveRef.current) { + return; + } + restoreEditorFocus(); + }, [clearDismissRestoreTimer, restoreEditorFocus]); - openingIdRef.current += 1; - shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; - setPhase("closing"); - }, []); - + /** + * Marks the sheet closed and queues the keyboard's return for the moment + * the dismissal transition actually completes: the sheet slides away over a + * resting composer, then the keyboard lifts it in one continuous motion. + */ const onDismissed = useCallback(() => { - const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; - shouldRestoreAfterDismissRef.current = false; - restoreFocusOnSaveRef.current = false; isActiveRef.current = false; setPhase("closed"); - if (shouldRestoreFocus) { - input.editorRef.current?.focus(); + if (!restoreFocusAfterDismissRef.current) { + return; } - }, [input.editorRef]); - - // The new-task screen can have an autofocus queued before the sheet opens. - // Preserve that intent for Save without allowing it to focus under the modal. - const restoreFocusAfterSave = useCallback(() => { - if (isActiveRef.current) { - restoreFocusOnSaveRef.current = true; + restoreFocusAfterDismissRef.current = false; + restorePendingRef.current = true; + clearDismissRestoreTimer(); + if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) { + // A stack transition finished just before this pop reached JS: the pop + // is the state echo of a gesture-driven dismissal whose animation has + // already completed. The sheet is gone — bring the keyboard back now. + runPendingDismissalRestore(); + return; } - }, []); + dismissRestoreTimerRef.current = setTimeout(() => { + dismissRestoreTimerRef.current = null; + runPendingDismissalRestore(); + }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS); + }, [clearDismissRestoreTimer, runPendingDismissalRestore]); + + /** Wire to the navigator's `finishTransitioning` event. */ + const onStackTransitionsFinished = useCallback(() => { + lastStackTransitionFinishedAtRef.current = Date.now(); + runPendingDismissalRestore(); + }, [runPendingDismissalRestore]); return { isActive: phase !== "closed", - isActiveRef, isVisible: phase === "visible", open, - close, onDismissed, - restoreFocusAfterSave, + onStackTransitionsFinished, } as const; } diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts index 40b28076d360..18f221940a9a 100644 --- a/apps/mobile/src/native/native-glass.ts +++ b/apps/mobile/src/native/native-glass.ts @@ -1,9 +1,9 @@ -import { isLiquidGlassSupported } from "@callstack/liquid-glass"; +import { isGlassEffectAPIAvailable } from "expo-glass-effect"; import { Platform } from "react-native"; import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( Platform.OS, - isLiquidGlassSupported, + isGlassEffectAPIAvailable(), ); diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts new file mode 100644 index 000000000000..eb2e8a8d1898 --- /dev/null +++ b/apps/mobile/src/native/sheet-surface.ts @@ -0,0 +1,28 @@ +import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native"; + +/** + * One opaque surface for content rendered inside a native form sheet. + * + * UIKit owns the outer sheet material and rounded corners. The presented route + * owns this surface so nested navigators never expose a differently colored + * native container while their screens move. + */ +export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined = + Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined; + +export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = + NATIVE_SHEET_SURFACE_COLOR === undefined + ? undefined + : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; + +/** + * Paint the adaptive background on the presented screen itself. Nested stacks + * can stay transparent over this single surface, so a push never exposes an + * unpainted form-sheet host behind the moving child view controllers. + */ +export const FORM_SHEET_PRESENTATION_OPTIONS = { + presentation: "formSheet" as const, + ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined + ? null + : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), +}; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index b504fb190c6d..1da9c9f7ac6f 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -35,6 +35,8 @@ export interface Preferences { * default flat list — see `resolveThreadListV2Enabled`. */ readonly legacyThreadListEnabled?: boolean; + /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ + readonly planModeEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; + planModeEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } + if (typeof parsed.planModeEnabled === "boolean") { + preferences.planModeEnabled = parsed.planModeEnabled; + } return preferences; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index b02b190db259..0c0da1f847d5 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,14 +1,23 @@ -import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + OrchestrationThread, + ThreadId, + VcsListRefsResult, + VcsRef, +} from "@t3tools/contracts"; import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; -import { useEffect, useMemo, useState } from "react"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; @@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; +const EMPTY_REFS: ReadonlyArray = []; +const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, @@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget { readonly query: string | null; } -function useDebouncedValue(value: A, delayMs: number): A { +export function useDebouncedValue(value: A, delayMs: number): A { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -125,6 +136,113 @@ export function useBranches(input: { ); } +export function usePaginatedBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([target.environmentId, target.cwd, query]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }); + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) { + refs.set(ref.name, ref); + } + } + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + const data: VcsListRefsResult | null = + first === null || last === null + ? null + : { + refs: [...refs.values()], + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + totalCount: Math.max(...values.map((value) => value.totalCount)), + }; + const lastResult = results.at(-1); + const isFetchingNextPage = + results.length > 1 && + lastResult?.waiting === true && + Option.isNone(AsyncResult.value(lastResult)); + const failed = results.find((result) => result._tag === "Failure"); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + const firstPage = pageAtoms[0]; + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + if (firstPage !== undefined) { + appAtomRegistry.refresh(firstPage); + } + }, [pageAtoms, targetKey]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_REFS, + error, + isPending: results.some((result) => result.waiting), + isFetchingNextPage, + refresh, + loadNext, + }; +} + export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e08f..ae03141a8863 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,10 +1,57 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { vi } from "vite-plus/test"; + +const composerDraftFileMocks = vi.hoisted(() => { + let document = ""; + let releaseRead: (() => void) | null = null; + let readBarrier = Promise.resolve(); + + return { + blockRead() { + readBarrier = new Promise((resolve) => { + releaseRead = resolve; + }); + }, + releaseRead() { + releaseRead?.(); + releaseRead = null; + }, + setDocument(value: unknown) { + document = JSON.stringify(value); + }, + Directory: class { + create() {} + }, + File: class { + exists = true; + + create() {} + + async text() { + await readBarrier; + return document; + } + + write(value: string) { + document = value; + } + }, + }; +}); + +vi.mock("expo-file-system", () => ({ + Directory: composerDraftFileMocks.Directory, + File: composerDraftFileMocks.File, + Paths: { document: "/documents" }, +})); import { appAtomRegistry } from "./atom-registry"; import { clearComposerDraftContentState, composerDraftsAtom, + copyComposerDraftContentIfEmpty, + copyComposerDraftContentState, decodePersistedComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, @@ -165,6 +212,53 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); + it("carries unfinished content to a newly selected project without overwriting its settings", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const source: ComposerDraft = { + text: "Keep this task", + attachments: [], + importedShareIds: ["share-1"], + workspaceSelection: { + mode: "worktree", + branch: "feature/source", + worktreePath: null, + }, + }; + const target: ComposerDraft = { + text: "", + attachments: [], + runtimeMode: "approval-required", + }; + + expect( + copyComposerDraftContentState( + { [sourceKey]: source, [targetKey]: target }, + sourceKey, + targetKey, + ), + ).toEqual({ + [sourceKey]: source, + [targetKey]: { + ...target, + text: source.text, + attachments: source.attachments, + importedShareIds: source.importedShareIds, + }, + }); + }); + + it("does not overwrite unfinished content already stored for the selected project", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const drafts: Record = { + [sourceKey]: { text: "Source task", attachments: [] }, + [targetKey]: { text: "Target task", attachments: [] }, + }; + + expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); + }); + it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { @@ -268,4 +362,35 @@ describe("mobile composer drafts", () => { [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); + + it("waits for persisted drafts before copying content between projects", async () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const unrelatedKey = "environment-1:thread-1"; + const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; + const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; + const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; + + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + [targetKey]: target, + [unrelatedKey]: unrelated, + }, + }); + composerDraftFileMocks.blockRead(); + appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); + + const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); + + composerDraftFileMocks.releaseRead(); + await copy; + + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + [sourceKey]: source, + [targetKey]: target, + [unrelatedKey]: unrelated, + }); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 24fa547e2728..e9f8cde3cec2 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void { function updateComposerDrafts( update: (current: Record) => Record, ): void { - const next = update(appAtomRegistry.get(composerDraftsAtom)); + const current = appAtomRegistry.get(composerDraftsAtom); + const next = update(current); + if (next === current) { + return; + } appAtomRegistry.set(composerDraftsAtom, next); schedulePersistComposerDrafts(next); } @@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState( return next; } +export function copyComposerDraftContentState( + current: Record, + sourceDraftKey: string, + targetDraftKey: string, +): Record { + if (sourceDraftKey === targetDraftKey) { + return current; + } + const source = normalizeDraft(current[sourceDraftKey]); + const target = normalizeDraft(current[targetDraftKey]); + const sourceHasContent = + source.text.length > 0 || + source.attachments.length > 0 || + (source.importedShareIds?.length ?? 0) > 0; + const targetHasContent = + target.text.length > 0 || + target.attachments.length > 0 || + (target.importedShareIds?.length ?? 0) > 0; + if (!sourceHasContent || targetHasContent) { + return current; + } + return { + ...current, + [targetDraftKey]: { + ...target, + text: source.text, + attachments: source.attachments, + ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), + }, + }; +} + +export async function copyComposerDraftContentIfEmpty( + sourceDraftKey: string, + targetDraftKey: string, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + updateComposerDrafts((current) => + copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), + ); +} + function mergeComposerDraftText(existing: string, incoming: string): string { if (incoming.length === 0) { return existing; diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index cb69e45b5d7b..0648bafc8b77 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables labels above describe what you get; the exact per-provider translation is internal and may change. -Mobile offers the same four modes. It labels the first one **Approve actions** rather than -**Supervised**. +Mobile offers the same four modes with the same labels and descriptions. diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index 1ec4d978529f..e92ae4975631 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 -//# sourceMappingURL=useHeaderConfigProps.js.map \ No newline at end of file +//# sourceMappingURL=useHeaderConfigProps.js.map +diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js +index c342e90..5d3e440 100644 +--- a/lib/module/views/NativeStackView.native.js ++++ b/lib/module/views/NativeStackView.native.js +@@ -370,6 +370,17 @@ export function NativeStackView({ + return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { + children: /*#__PURE__*/_jsx(ScreenStack, { + style: styles.container, ++ onFinishTransitioning: () => { ++ // Surface UIKit's transition-completion callback to every route of ++ // this navigator. Unlike transitionEnd, this also fires when a modal ++ // finishes dismissing — where the presenting screen below receives no ++ // appearance callbacks — and for a gesture-driven dismissal it fires ++ // before the state pop, while the modal route is still the focused ++ // one, so the event must not be targeted at a single route. ++ navigation.emit({ ++ type: 'finishTransitioning' ++ }); ++ }, + children: state.routes.concat(state.preloadedRoutes).map((route, index) => { + const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; + const isFocused = state.index === index; diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 605366ff19a7..dc65d13b91bb 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + ]]; + [chromeHostView bringSubviewToFront:toolbarHost]; + -+ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { -+ BOOL isEditing = textField.isFirstResponder; -+ keyboardAvoidConstraint.priority = -+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; -+ restingBottomConstraint.priority = -+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; -+ -+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; -+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; -+ __weak UIView *weakChromeHostView = chromeHostView; -+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; -+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; -+ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; -+ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; -+ [textField addAction:[UIAction actionWithTitle:@"" -+ image:nil -+ identifier:beginActionIdentifier -+ handler:^(__kindof UIAction *_Nonnull action) { -+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; -+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; -+ [weakChromeHostView setNeedsLayout]; -+ }] -+ forControlEvents:UIControlEventEditingDidBegin]; -+ [textField addAction:[UIAction actionWithTitle:@"" -+ image:nil -+ identifier:endActionIdentifier -+ handler:^(__kindof UIAction *_Nonnull action) { -+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; -+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; -+ [weakChromeHostView setNeedsLayout]; -+ }] -+ forControlEvents:UIControlEventEditingDidEnd]; -+ }; -+ + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; @@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; ++ NSLayoutConstraint *glassLeadingConstraint = ++ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset]; ++ NSLayoutConstraint *glassTrailingConstraint = ++ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset]; + [NSLayoutConstraint activateConstraints:@[ -+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset], -+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset], ++ glassLeadingConstraint, ++ glassTrailingConstraint, + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], + ]]; @@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + UISearchBar *searchBar = + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; ++ UITextField *resolvedSearchTextField = nil; + if (searchBar != nil) { + if (placeholder != nil) { + searchBar.placeholder = placeholder; @@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; + searchBar.searchTextField.textColor = UIColor.labelColor; + searchBar.searchTextField.tintColor = UIColor.labelColor; -+ configureKeyboardTracking(searchBar.searchTextField); ++ resolvedSearchTextField = searchBar.searchTextField; + if (placeholder != nil) { + searchBar.searchTextField.attributedPlaceholder = + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; @@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + searchField.adjustsFontForContentSizeCategory = YES; + searchField.textColor = UIColor.labelColor; + searchField.tintColor = UIColor.labelColor; -+ configureKeyboardTracking(searchField); ++ resolvedSearchTextField = searchField; + searchField.translatesAutoresizingMaskIntoConstraints = NO; + [glassView.contentView addSubview:searchField]; + [NSLayoutConstraint activateConstraints:@[ @@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + ]]; + } + ++ UIButton *filterButton = nil; + if (hasFilterButton) { -+ UIButton *filterButton = makeGlassButton( ++ filterButton = makeGlassButton( + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", + mailSearchToolbarConfig[@"filterButtonId"], + mailSearchToolbarConfig[@"filterMenu"]); @@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + ]]; + } + ++ UIButton *composeButton = nil; + if (hasComposeButton) { -+ UIButton *composeButton = makeGlassButton( ++ composeButton = makeGlassButton( + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", + mailSearchToolbarConfig[@"composeButtonId"], + mailSearchToolbarConfig[@"composeMenu"]); @@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + [composeButton.heightAnchor constraintEqualToConstant:buttonSize], + ]]; + } ++ ++ BOOL showsSearchDismissButton = ++ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil; ++ UIButton *searchDismissButton = nil; ++ if (showsSearchDismissButton) { ++ searchDismissButton = makeGlassButton(@"xmark", nil, nil); ++ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard"; ++ searchDismissButton.alpha = 0.0; ++ searchDismissButton.hidden = YES; ++ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO; ++ [toolbarHost addSubview:searchDismissButton]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], ++ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], ++ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize], ++ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize], ++ ]]; ++ __weak UITextField *weakSearchTextField = resolvedSearchTextField; ++ [searchDismissButton ++ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { ++ [weakSearchTextField resignFirstResponder]; ++ }] ++ forControlEvents:UIControlEventTouchUpInside]; ++ } ++ ++ __weak UIButton *weakFilterButton = filterButton; ++ __weak UIButton *weakComposeButton = composeButton; ++ __weak UIButton *weakSearchDismissButton = searchDismissButton; ++ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint; ++ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint; ++ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; ++ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; ++ __weak UIView *weakToolbarHost = toolbarHost; ++ __weak UITextField *weakSearchTextField = resolvedSearchTextField; ++ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) { ++ weakKeyboardAvoidConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; ++ weakRestingBottomConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; ++ ++ if (showsSearchDismissButton) { ++ if (isEditing) { ++ weakSearchDismissButton.hidden = NO; ++ } else { ++ weakFilterButton.hidden = NO; ++ weakComposeButton.hidden = NO; ++ } ++ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset; ++ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset; ++ ++ void (^changes)(void) = ^{ ++ weakFilterButton.alpha = isEditing ? 0.0 : 1.0; ++ weakComposeButton.alpha = isEditing ? 0.0 : 1.0; ++ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0; ++ [weakToolbarHost layoutIfNeeded]; ++ }; ++ void (^completion)(BOOL) = ^(BOOL finished) { ++ if (!finished || weakSearchTextField.isFirstResponder != isEditing) { ++ return; ++ } ++ weakFilterButton.hidden = isEditing; ++ weakComposeButton.hidden = isEditing; ++ weakSearchDismissButton.hidden = !isEditing; ++ }; ++ if (animated) { ++ [UIView animateWithDuration:0.2 ++ delay:0.0 ++ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut ++ animations:changes ++ completion:completion]; ++ } else { ++ changes(); ++ completion(YES); ++ } ++ } ++ [weakToolbarHost setNeedsLayout]; ++ }; ++ ++ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO); ++ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; ++ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; ++ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier ++ forControlEvents:UIControlEventEditingDidBegin]; ++ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier ++ forControlEvents:UIControlEventEditingDidEnd]; ++ [resolvedSearchTextField ++ addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:beginActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ setSearchEditingAppearance(YES, YES); ++ }] ++ forControlEvents:UIControlEventEditingDidBegin]; ++ [resolvedSearchTextField ++ addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:endActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ setSearchEditingAppearance(NO, YES); ++ }] ++ forControlEvents:UIControlEventEditingDidEnd]; + } +#endif + } @@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1259,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -803,19 +1480,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * } imageLoader:_imageLoader]; NSNumber *index = dict[@"index"]; @@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 [items insertObject:item atIndex:index.integerValue]; } else { [items addObject:item]; -@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1735,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1762,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1809,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } @@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db /** * The tint color to apply to the item. * -@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { +@@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { export interface HeaderBarButtonItemSpacing { type: 'spacing'; spacing: number; @@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db + onSearchTextChange?: ((text: string) => void) | undefined; + placeholder?: string | undefined; + searchTextChangeId?: string | undefined; ++ showsSearchDismissButton?: boolean | undefined; + useFallbackSearchField?: boolean | undefined; + width?: number | undefined; } @@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 /** * The tint color to apply to the item. * -@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { +@@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { export interface HeaderBarButtonItemSpacing { type: 'spacing'; spacing: number; @@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 + onSearchTextChange?: ((text: string) => void) | undefined; + placeholder?: string | undefined; + searchTextChangeId?: string | undefined; ++ showsSearchDismissButton?: boolean | undefined; + useFallbackSearchField?: boolean | undefined; + width?: number | undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9abd0dc45de5..7eab1715c13e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,13 +80,13 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 - '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 + '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e + react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199 importers: @@ -237,7 +237,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) + version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -402,7 +402,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -12275,7 +12275,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) + expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12351,7 +12351,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) + expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12691,7 +12691,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) + expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12706,7 +12706,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) + expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -14291,7 +14291,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': + '@react-navigation/native-stack@7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14299,7 +14299,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17080,47 +17080,47 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): + expo-router@56.2.11(80beea6a31a5d2003a696c1401258797): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.3 + react: 19.2.6 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-dom: 19.2.6(react@19.2.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -17131,47 +17131,47 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(db5c693a26481047569df6781f34db9f): + expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.6 + react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-dom: 19.2.3(react@19.2.3) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -19977,14 +19977,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) From 5304f3e9d4c912bfa0eb2f5f41fa109b3646236b Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:01:09 +0000 Subject: [PATCH 016/144] chore(mobile): bump app version to 1.0.4 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 3813a10fa51a..9a51725478e1 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.3", + version: "1.0.4", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project — native deps, config plugins, AND patches/ — matches the update. From 59be6f78465d73d2d8bba5ddd7741018205dc675 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 13 Aug 2026 21:45:51 -0400 Subject: [PATCH 017/144] fix(web): simplify the desktop-managed server update banner copy (#6549) Co-authored-by: Claude Fable 5 --- apps/web/src/versionSkew.test.ts | 2 +- apps/web/src/versionSkew.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index da4ae1e22419..41a148bacd8c 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -101,7 +101,7 @@ describe("versionSkew", () => { "Update the Remote server so they stay in sync.", ); expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe( - "The Desktop server is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.", + "Update the desktop app that runs the Desktop server.", ); expect(serverUpdateGuidance(null, "Local server")).toBe( "Relaunch the Local server with the copied command to sync them.", diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 6cf2a474269d..f56f03ab7ad3 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -73,7 +73,7 @@ export function serverUpdateGuidance( case "respawn": return `Update the ${serverLabel} so they stay in sync.`; case "desktop-managed": - return `The ${serverLabel} is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.`; + return `Update the desktop app that runs the ${serverLabel}.`; default: return `Relaunch the ${serverLabel} with the copied command to sync them.`; } From e15f655ba423e4b0e50a5692cc06eb9421bff7df Mon Sep 17 00:00:00 2001 From: David Hu Date: Thu, 13 Aug 2026 19:01:11 -0700 Subject: [PATCH 018/144] fix(web): show background policy tooltips sooner (#6506) --- apps/web/src/components/settings/settingsLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index cf532a772125..0e0ae0a042b0 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -88,6 +88,7 @@ export function PolicyTooltip({ children }: { readonly children: string }) { return ( Date: Fri, 14 Aug 2026 03:02:09 +0100 Subject: [PATCH 019/144] feat(desktop): add favicons to the Browser panel (#5644) --- .../src/preview/FaviconCapture.test.ts | 999 ++++++++++++++++++ apps/desktop/src/preview/FaviconCapture.ts | 679 ++++++++++++ apps/desktop/src/preview/Manager.test.ts | 604 +++++++++++ apps/desktop/src/preview/Manager.ts | 225 +++- .../src/browser/browserTargetResolver.test.ts | 131 +++ apps/web/src/browser/browserTargetResolver.ts | 134 ++- apps/web/src/browserFaviconLogic.test.ts | 124 +++ apps/web/src/browserFaviconLogic.ts | 201 ++++ apps/web/src/browserFaviconStore.test.ts | 316 ++++++ apps/web/src/browserFaviconStore.ts | 342 ++++++ apps/web/src/components/ChatView.tsx | 15 +- .../src/components/RightPanelTabs.test.tsx | 115 ++ apps/web/src/components/RightPanelTabs.tsx | 36 +- .../preview/PreviewEmptyState.test.tsx | 7 +- .../components/preview/PreviewEmptyState.tsx | 6 +- .../preview/PreviewFaviconIcon.test.tsx | 51 + .../components/preview/PreviewFaviconIcon.tsx | 66 ++ .../preview/PreviewLocalServerCard.tsx | 9 +- .../preview/PreviewRecentUrlCard.tsx | 8 +- .../src/components/preview/PreviewView.tsx | 1 + .../preview/usePreviewBridge.test.ts | 48 + .../components/preview/usePreviewBridge.ts | 66 +- apps/web/src/lib/favicon.test.ts | 42 + apps/web/src/lib/favicon.ts | 3 + apps/web/src/previewStateStore.test.ts | 4 + apps/web/src/previewStateStore.ts | 2 + apps/web/src/routes/_chat.pull-requests.tsx | 2 + packages/contracts/src/ipc.ts | 24 + 28 files changed, 4189 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/src/preview/FaviconCapture.test.ts create mode 100644 apps/desktop/src/preview/FaviconCapture.ts create mode 100644 apps/web/src/browserFaviconLogic.test.ts create mode 100644 apps/web/src/browserFaviconLogic.ts create mode 100644 apps/web/src/browserFaviconStore.test.ts create mode 100644 apps/web/src/browserFaviconStore.ts create mode 100644 apps/web/src/components/RightPanelTabs.test.tsx create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.test.tsx create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.tsx create mode 100644 apps/web/src/components/preview/usePreviewBridge.test.ts create mode 100644 apps/web/src/lib/favicon.test.ts diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts new file mode 100644 index 000000000000..a18c839a712a --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.test.ts @@ -0,0 +1,999 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + MAX_FAVICON_CANDIDATES, + MAX_FAVICON_RESPONSE_BYTES, + captureFavicon, + selectFaviconCandidates, +} from "./FaviconCapture.ts"; + +const PNG = "data:image/png;base64,cG5n"; +const SOURCE_PNG = Buffer.alloc(24); +Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG); +SOURCE_PNG.writeUInt32BE(1, 16); +SOURCE_PNG.writeUInt32BE(1, 20); +const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`; + +function sourceGif( + width: number, + height: number, + frameWidth = width, + frameHeight = height, + additionalFrames: ReadonlyArray<{ + readonly left?: number; + readonly top?: number; + readonly width: number; + readonly height: number; + }> = [], +): Buffer { + const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames]; + const buffer = Buffer.alloc(13 + frames.length * 12 + 1); + buffer.write("GIF89a", 0, "ascii"); + buffer.writeUInt16LE(width, 6); + buffer.writeUInt16LE(height, 8); + let offset = 13; + for (const frame of frames) { + buffer[offset] = 0x2c; + buffer.writeUInt16LE(frame.left ?? 0, offset + 1); + buffer.writeUInt16LE(frame.top ?? 0, offset + 3); + buffer.writeUInt16LE(frame.width, offset + 5); + buffer.writeUInt16LE(frame.height, offset + 7); + offset += 10; + buffer[offset] = 2; + buffer[offset + 1] = 0; + offset += 2; + } + buffer[offset] = 0x3b; + return buffer; +} + +function sourceJpeg( + width: number, + height: number, + orientations: number | ReadonlyArray = [], +): Buffer { + const frame = Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x07, + 0x08, + height >>> 8, + height & 0xff, + width >>> 8, + width & 0xff, + ]); + const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map( + (orientation) => sourceJpegExifSegment([orientation]), + ); + return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]); +} + +function sourceJpegApp1Segment(payload: Buffer): Buffer { + const app1 = Buffer.alloc(4 + payload.byteLength); + app1[0] = 0xff; + app1[1] = 0xe1; + app1.writeUInt16BE(payload.byteLength + 2, 2); + payload.copy(app1, 4); + return app1; +} + +function sourceJpegExifSegment( + orientations: ReadonlyArray, + options?: { + readonly byteOrder?: "II" | "MM"; + readonly magic?: number; + readonly padding?: number; + }, +): Buffer { + const exif = Buffer.alloc(20 + orientations.length * 12); + exif.write("Exif\0\0", 0, "binary"); + exif[5] = options?.padding ?? 0; + const littleEndian = options?.byteOrder !== "MM"; + exif.write(littleEndian ? "II" : "MM", 6, "ascii"); + const writeUInt16 = (value: number, offset: number) => + littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset); + const writeUInt32 = (value: number, offset: number) => + littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset); + writeUInt16(options?.magic ?? 42, 8); + writeUInt32(8, 10); + writeUInt16(orientations.length, 14); + orientations.forEach((orientation, index) => { + const entryOffset = 16 + index * 12; + writeUInt16(0x0112, entryOffset); + writeUInt16(3, entryOffset + 2); + writeUInt32(1, entryOffset + 4); + writeUInt16(orientation, entryOffset + 8); + }); + return sourceJpegApp1Segment(exif); +} + +function sourceJpegWithApp1Segments( + width: number, + height: number, + segments: ReadonlyArray, +): Buffer { + const frame = sourceJpeg(width, height); + return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]); +} + +function sourceJpegWithOrientationEntries( + width: number, + height: number, + orientations: ReadonlyArray, +): Buffer { + return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); +} + +function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer { + const exif = sourceJpegExifSegment([6], { byteOrder }); + exif[10] = alias; + exif[11] = alias; + return sourceJpegWithApp1Segments(64, 32, [exif]); +} + +function sourceJpegExifWithSubIfd(options: { + readonly rootOrientation?: number; + readonly subIfdFirst?: boolean; + readonly subIfdOrientation: number; +}): Buffer { + const rootEntries = options.rootOrientation === undefined ? 1 : 2; + const rootIfdOffset = 14; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt16LE(42, 8); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + + const writeOrientation = (offset: number, orientation: number) => { + exif.writeUInt16LE(0x0112, offset); + exif.writeUInt16LE(3, offset + 2); + exif.writeUInt32LE(1, offset + 4); + exif.writeUInt16LE(orientation, offset + 8); + }; + const writeSubIfdPointer = (offset: number) => { + exif.writeUInt16LE(0x8769, offset); + exif.writeUInt16LE(4, offset + 2); + exif.writeUInt32LE(1, offset + 4); + exif.writeUInt32LE(subIfdOffset - 6, offset + 8); + }; + + const firstRootEntryOffset = rootIfdOffset + 2; + if (options.rootOrientation === undefined) { + writeSubIfdPointer(firstRootEntryOffset); + } else if (options.subIfdFirst) { + writeSubIfdPointer(firstRootEntryOffset); + writeOrientation(firstRootEntryOffset + 12, options.rootOrientation); + } else { + writeOrientation(firstRootEntryOffset, options.rootOrientation); + writeSubIfdPointer(firstRootEntryOffset + 12); + } + + exif.writeUInt16LE(1, subIfdOffset); + writeOrientation(subIfdOffset + 2, options.subIfdOrientation); + return sourceJpegApp1Segment(exif); +} + +function sourceJpegExifWithSubIfdPointers(options: { + readonly pointerCount: number; + readonly subIfdEntries: number; +}): Buffer { + const { pointerCount, subIfdEntries } = options; + const rootIfdOffset = 14; + const rootEntries = pointerCount + 1; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt16LE(42, 8); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + for (let index = 0; index < pointerCount; index += 1) { + const entryOffset = rootIfdOffset + 2 + index * 12; + exif.writeUInt16LE(0x8769, entryOffset); + exif.writeUInt16LE(4, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8); + } + const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; + exif.writeUInt16LE(0x0112, orientationOffset); + exif.writeUInt16LE(3, orientationOffset + 2); + exif.writeUInt32LE(1, orientationOffset + 4); + exif.writeUInt16LE(6, orientationOffset + 8); + + exif.writeUInt16LE(subIfdEntries, subIfdOffset); + for (let index = 0; index < subIfdEntries; index += 1) { + const entryOffset = subIfdOffset + 2 + index * 12; + exif.writeUInt16LE(1, entryOffset); + exif.writeUInt16LE(3, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + } + return sourceJpegApp1Segment(exif); +} + +function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer { + const rootIfdOffset = 14; + const rootEntries = pointerCount + 1; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + for (let index = 0; index < pointerCount; index += 1) { + const entryOffset = rootIfdOffset + 2 + index * 12; + exif.writeUInt16LE(0x8769, entryOffset); + exif.writeUInt16LE(4, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8); + exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2); + } + const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; + exif.writeUInt16LE(0x0112, orientationOffset); + exif.writeUInt16LE(3, orientationOffset + 2); + exif.writeUInt32LE(1, orientationOffset + 4); + exif.writeUInt16LE(6, orientationOffset + 8); + return sourceJpegApp1Segment(exif); +} + +function sourceWebp(width: number, height: number): Buffer { + const buffer = Buffer.alloc(30); + buffer.write("RIFF", 0, "ascii"); + buffer.write("WEBP", 8, "ascii"); + buffer.write("VP8X", 12, "ascii"); + buffer.writeUIntLE(width - 1, 24, 3); + buffer.writeUIntLE(height - 1, 27, 3); + return buffer; +} + +function sourceIco(embedded: Buffer): Buffer { + const buffer = Buffer.alloc(22 + embedded.byteLength); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(embedded.byteLength, 14); + buffer.writeUInt32LE(22, 18); + embedded.copy(buffer, 22); + return buffer; +} + +function makeUnsafePng(): Buffer { + const buffer = Buffer.from(SOURCE_PNG); + buffer.writeUInt32BE(4096, 16); + buffer.writeUInt32BE(4096, 20); + return buffer; +} + +function sourcePng(width: number, height: number): Buffer { + const buffer = Buffer.from(SOURCE_PNG); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +function makeUnsafeDib(): Buffer { + const buffer = Buffer.alloc(40); + buffer.writeUInt32LE(40, 0); + buffer.writeInt32LE(4096, 4); + buffer.writeInt32LE(4096, 8); + return buffer; +} + +function makeWebContents(options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly rasterize?: (code: string) => Promise; +}) { + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG, + ); + return { + webContents: { + session: { fetch }, + executeJavaScriptInIsolatedWorld, + } as never, + executeJavaScriptInIsolatedWorld, + fetch, + }; +} + +const JPEG_LANDSCAPE_LAYOUT = { + draw: "context.drawImage(bitmap, 0, 8, 32, 16)", + resizeHeight: 16, + resizeWidth: 32, +} as const; +const JPEG_PORTRAIT_LAYOUT = { + draw: "context.drawImage(bitmap, 8, 0, 16, 32)", + resizeHeight: 32, + resizeWidth: 16, +} as const; + +async function expectJpegLayout( + source: Buffer, + layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT, +): Promise { + const { webContents } = makeWebContents({ + rasterize: async (code) => { + expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`); + expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`); + expect(code).toContain(layout.draw); + return PNG; + }, + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); +} + +describe("selectFaviconCandidates", () => { + it("filters and deduplicates before applying the candidate cap", () => { + const valid = Array.from( + { length: MAX_FAVICON_CANDIDATES + 2 }, + (_, index) => `https://example.com/favicon-${index}.png`, + ); + expect( + selectFaviconCandidates([ + ...Array.from({ length: 64 }, () => "javascript:alert(1)"), + valid[0]!, + valid[0]!, + ...valid.slice(1), + ]), + ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES)); + }); + + it("bounds raw candidate scanning independently of the usable-candidate cap", () => { + const oversizedInvalid = `javascript:${"x".repeat(2_048)}`; + expect( + selectFaviconCandidates([ + ...Array.from({ length: 128 }, () => oversizedInvalid), + "https://example.com/too-late.png", + ]), + ).toEqual([]); + }); +}); + +describe("captureFavicon", () => { + it.each([ + { + label: "same-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://example.com/favicon.png", + credentials: "include", + }, + { + label: "cross-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://cdn.example.net/favicon.png", + credentials: "omit", + }, + ])("uses the explicit credential policy for $label requests", async (testCase) => { + const { webContents, fetch } = makeWebContents(); + const result = await captureFavicon({ + webContents, + pageUrl: testCase.pageUrl, + candidates: [testCase.faviconUrl], + signal: new AbortController().signal, + }); + + expect(result).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledWith( + testCase.faviconUrl, + expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }), + ); + }); + + it("decodes base64 and percent-encoded inline images without fetching", async () => { + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents(); + const percentEncodedPng = [...SOURCE_PNG] + .map((byte) => `%${byte.toString(16).padStart(2, "0")}`) + .join(""); + + for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + + expect(fetch).not.toHaveBeenCalled(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2); + }); + + it("tries the next candidate after an ordinary rejection", async () => { + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(null, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("cancels a rejected response body before trying the next candidate", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(body, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("stops a pending fetch when its capture is aborted", async () => { + const controller = new AbortController(); + const { webContents } = makeWebContents({ + fetch: (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: controller.signal, + }); + controller.abort(); + expect(await capture).toEqual({ kind: "none" }); + }); + + it("ends candidate fallback when the overall capture deadline expires", async () => { + const timeoutController = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const { webContents, fetch } = makeWebContents({ + fetch: (url, init) => { + if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 })); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); + }, + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [ + "https://example.com/first.png", + "https://example.com/second.png", + "https://example.com/third.png", + ], + signal: new AbortController().signal, + }); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(timeout).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); + + it("does not publish a rasterization that completes after the capture deadline", async () => { + const captureTimeoutController = new AbortController(); + const rasterTimeoutController = new AbortController(); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation((milliseconds) => + milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal, + ); + let resolveRasterization!: (value: unknown) => void; + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + rasterize: () => + new Promise((resolve) => { + resolveRasterization = resolve; + }), + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }); + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce()); + captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + resolveRasterization(PNG); + } finally { + timeout.mockRestore(); + } + }); + + it("cancels a stalled response body when the capture deadline expires", async () => { + const timeoutController = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const cancel = vi.fn(); + const { webContents } = makeWebContents({ + fetch: async () => + new Response( + new ReadableStream({ + cancel, + }), + { headers: { "content-type": "image/png" } }, + ), + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }); + timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); + + it("rejects and cancels an oversized streamed response", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES)); + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(cancel).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("retains bounded compatibility with common favicon formats", async () => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + for (const [mime, buffer] of [ + ["image/gif", sourceGif(32, 32)], + ["image/jpeg", sourceJpeg(32, 32)], + ["image/webp", sourceWebp(32, 32)], + ["image/x-icon", sourceIco(SOURCE_PNG)], + ] as const) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:${mime};base64,${buffer.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); + }); + + it.each([ + { + label: "landscape", + source: sourcePng(64, 32), + resizeWidth: 32, + resizeHeight: 16, + draw: "context.drawImage(bitmap, 0, 8, 32, 16)", + }, + { + label: "portrait", + source: sourcePng(32, 64), + resizeWidth: 16, + resizeHeight: 32, + draw: "context.drawImage(bitmap, 8, 0, 16, 32)", + }, + ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => { + const { webContents } = makeWebContents({ + rasterize: async (code) => { + expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`); + expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`); + expect(code).toContain('resizeQuality: "high"'); + expect(code).toContain(testCase.draw); + return PNG; + }, + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + }); + + it.each([ + ...[1, 2, 3, 4].map((orientation) => ({ + label: `keeps stored dimensions for orientation ${orientation}`, + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, orientation), + })), + ...[5, 6, 7, 8].map((orientation) => ({ + label: `uses display dimensions for orientation ${orientation}`, + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpeg(64, 32, orientation), + })), + { + label: "uses the first separate EXIF segment when it is transposed", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpeg(64, 32, [6, 1]), + }, + { + label: "uses the first separate EXIF segment when it is untransposed", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, [1, 6]), + }, + { + label: "does not consult a later EXIF segment after an invalid orientation", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, [9, 6]), + }, + { + label: "uses a later valid orientation in the same IFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithOrientationEntries(64, 32, [9, 6]), + }, + { + label: "skips a non-EXIF APP1 segment", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("not-exif")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "skips an empty EXIF APP1 segment", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "stops after a malformed qualifying EXIF APP1 segment", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "ignores the EXIF padding byte", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]), + }, + { + label: "reads big-endian EXIF", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]), + }, + { + label: "matches Chromium for a nonstandard TIFF magic field", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]), + }, + { + label: "rejects a high-bit little-endian alias", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithEndianAlias(0xc9, "II"), + }, + { + label: "rejects a high-bit big-endian alias", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithEndianAlias(0xcd, "MM"), + }, + { + label: "reads an orientation from a SubIFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }), + ]), + }, + { + label: "uses a SubIFD orientation before a later root orientation", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ + rootOrientation: 1, + subIfdFirst: true, + subIfdOrientation: 6, + }), + ]), + }, + { + label: "uses a root orientation before a later SubIFD orientation", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }), + ]), + }, + { + label: "memoizes repeated aliases to the same SubIFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }), + ]), + }, + ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => { + await expectJpegLayout(source, layout); + }); + + it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => { + const source = sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithOverlappingSubIfds(32, 32), + ]); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("rejects JPEGs with multiple frame headers before rasterization", async () => { + const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("rejects an unsafe PNG size before rasterization", async () => { + const buffer = makeUnsafePng(); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => + new Response(new Uint8Array(buffer), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it.each([ + ["GIF", "image/gif", sourceGif(4096, 4096)], + ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], + ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])], + [ + "GIF cumulative frames", + "image/gif", + sourceGif( + 64, + 64, + 64, + 64, + Array.from({ length: 256 }, () => ({ width: 64, height: 64 })), + ), + ], + ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)], + ["WebP", "image/webp", sourceWebp(4096, 4096)], + ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())], + ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())], + ["SVG", "image/svg+xml", Buffer.from('')], + [ + "SVG with embedded bitmap", + "image/svg+xml", + Buffer.from( + ``, + ), + ], + [ + "ICO invalid payload span", + "image/x-icon", + (() => { + const buffer = Buffer.alloc(22); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(100, 14); + buffer.writeUInt32LE(22, 18); + return buffer; + })(), + ], + ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + const candidate = `data:${mime};base64,${buffer.toString("base64")}`; + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("ignores output that is not a bounded PNG data URL", async () => { + const { webContents } = makeWebContents({ + rasterize: async () => "data:image/svg+xml;base64,c3Zn", + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + }); + + it("waits for physical rasterization settlement after a logical timeout", async () => { + vi.useFakeTimers(); + try { + let resolveOld!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveOld = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const timedOut = captureFavicon(input); + await vi.advanceTimersByTimeAsync(1_001); + expect(await timedOut).toEqual({ kind: "timed-out" }); + + const newer = captureFavicon(input); + await Promise.resolve(); + expect(executions).toBe(1); + resolveOld(PNG); + expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); + expect(executions).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("ends candidate fallback after a rasterization timeout", async () => { + vi.useFakeTimers(); + try { + let resolveRasterization!: (value: unknown) => void; + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ + rasterize: () => + new Promise((resolve) => { + resolveRasterization = resolve; + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }); + + await vi.advanceTimersByTimeAsync(1_001); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(fetch).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); + resolveRasterization(PNG); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces queued rasterizations so only the latest pending capture launches", async () => { + let resolveFirst!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveFirst = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const first = captureFavicon(input); + const superseded = captureFavicon(input); + const newest = captureFavicon(input); + + expect(executions).toBe(1); + resolveFirst(PNG); + expect(await first).toEqual({ kind: "captured", dataUrl: PNG }); + expect(await superseded).toEqual({ kind: "none" }); + expect(await newest).toEqual({ kind: "captured", dataUrl: PNG }); + expect(executions).toBe(2); + }); +}); diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts new file mode 100644 index 000000000000..c7266282268e --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -0,0 +1,679 @@ +import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +export const MAX_FAVICON_RESPONSE_BYTES = 100_000; +export const MAX_FAVICON_CANDIDATES = 8; +export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; + +const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144; +const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256; +const MAX_FAVICON_SOURCE_PIXELS = 1_048_576; +const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128; +const FAVICON_CAPTURE_TIMEOUT_MS = 5_000; +const FAVICON_RASTER_WORLD_ID = 1001; +const FAVICON_RASTER_TIMEOUT_MS = 1_000; +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +interface RasterizationGate { + generation: number; + launchAllowed?: Promise; +} + +const rasterizationGates = new WeakMap(); + +async function waitForRasterLaunch(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return; + await new Promise((resolve) => { + const finish = () => { + signal.removeEventListener("abort", finish); + resolve(); + }; + signal.addEventListener("abort", finish, { once: true }); + void previous.then(finish); + }); +} + +export type FaviconCaptureResult = + | { readonly kind: "captured"; readonly dataUrl: string } + | { readonly kind: "none" } + | { readonly kind: "timed-out" }; + +type RasterizationResult = + | { readonly kind: "completed"; readonly value: unknown } + | { readonly kind: "timed-out" }; + +export function safeHttpOrigin(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null; + } catch { + return null; + } +} + +export function selectFaviconCandidates(candidates: ReadonlyArray): ReadonlyArray { + const selected: string[] = []; + const seen = new Set(); + let inputUnits = 0; + for (const candidate of candidates) { + // Charge a minimum per entry so a large array of tiny malformed values is bounded too. + inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length); + if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break; + if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue; + seen.add(candidate); + selected.push(candidate); + if (selected.length === MAX_FAVICON_CANDIDATES) break; + } + return selected; +} + +export async function captureFavicon(input: { + readonly webContents: Electron.WebContents; + readonly pageUrl: string; + readonly candidates: ReadonlyArray; + readonly signal: AbortSignal; +}): Promise { + const pageOrigin = safeHttpOrigin(input.pageUrl); + if (!pageOrigin) return { kind: "none" }; + const captureTimeout = AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS); + const captureSignal = AbortSignal.any([input.signal, captureTimeout]); + + for (const candidate of selectFaviconCandidates(input.candidates)) { + if (captureSignal.aborted) { + return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" }; + } + const captured = await captureCandidate({ + webContents: input.webContents, + pageOrigin, + candidate, + signal: captureSignal, + }); + if (captureSignal.aborted) { + return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" }; + } + if (captured.kind === "captured" || captured.kind === "timed-out") return captured; + } + + return { kind: "none" }; +} + +async function captureCandidate(input: { + readonly webContents: Electron.WebContents; + readonly pageOrigin: string; + readonly candidate: string; + readonly signal: AbortSignal; +}): Promise { + try { + const inline = parseInlineFavicon(input.candidate); + if (inline) { + return await normalizeFaviconBuffer( + input.webContents, + inline.mime, + inline.buffer, + input.signal, + ); + } + + const candidateOrigin = safeHttpOrigin(input.candidate); + if (!candidateOrigin) return { kind: "none" }; + const response = await input.webContents.session.fetch(input.candidate, { + credentials: candidateOrigin === input.pageOrigin ? "include" : "omit", + redirect: "error", + signal: input.signal, + }); + if (!response.ok) { + await response.body?.cancel(); + return { kind: "none" }; + } + const buffer = await readFaviconResponse(response, input.signal); + if (!buffer || input.signal.aborted) return { kind: "none" }; + const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null; + return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal); + } catch { + return { kind: "none" }; + } +} + +async function readFaviconResponse( + response: Response, + signal: AbortSignal, +): Promise { + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) { + await response.body?.cancel(); + return null; + } + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null; + } + + const reader = response.body.getReader(); + const cancelForAbort = () => { + void reader.cancel(signal.reason).catch(() => undefined); + }; + signal.addEventListener("abort", cancelForAbort, { once: true }); + if (signal.aborted) cancelForAbort(); + const chunks: Buffer[] = []; + let byteLength = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) return Buffer.concat(chunks, byteLength); + byteLength += next.value.byteLength; + if (byteLength > MAX_FAVICON_RESPONSE_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(Buffer.from(next.value)); + } + } finally { + signal.removeEventListener("abort", cancelForAbort); + reader.releaseLock(); + } +} + +function isSupportedFaviconUrl(url: string): boolean { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false; + if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url); + try { + const protocol = new URL(url).protocol; + return ( + (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH + ); + } catch { + return false; + } +} + +function decodeInlineFaviconPayload(payload: string): Buffer | null { + const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload)); + let inputOffset = 0; + let outputOffset = 0; + while (inputOffset < payload.length) { + const escapeOffset = payload.indexOf("%", inputOffset); + const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset; + outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8"); + if (escapeOffset === -1) break; + const hex = payload.slice(escapeOffset + 1, escapeOffset + 3); + if (!/^[0-9a-f]{2}$/i.test(hex)) return null; + decoded[outputOffset] = Number.parseInt(hex, 16); + outputOffset += 1; + inputOffset = escapeOffset + 3; + } + return decoded.subarray(0, outputOffset); +} + +function parseInlineFavicon( + url: string, +): { readonly buffer: Buffer; readonly mime: string } | null { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null; + const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url); + if (!match) return null; + const mime = match[1]?.toLowerCase(); + const parameters = match[2] + ?.split(";") + .filter(Boolean) + .map((parameter) => parameter.toLowerCase()); + const payload = match[3]; + if (!mime || !parameters || !payload) return null; + const base64 = parameters.at(-1) === "base64"; + if (parameters.includes("base64") && !base64) return null; + + let buffer: Buffer; + try { + if (base64) { + if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null; + buffer = Buffer.from(payload, "base64"); + if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) { + return null; + } + } else { + const decoded = decodeInlineFaviconPayload(payload); + if (!decoded) return null; + buffer = decoded; + } + } catch { + return null; + } + + return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES + ? { buffer, mime } + : null; +} + +interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions { + return ( + dimensions !== null && + Number.isSafeInteger(dimensions.width) && + Number.isSafeInteger(dimensions.height) && + dimensions.width > 0 && + dimensions.height > 0 && + dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS + ); +} + +function pngDimensions(buffer: Buffer): ImageDimensions | null { + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null { + let offset = startOffset; + while (offset < buffer.byteLength) { + const blockLength = buffer[offset]!; + offset += 1; + if (blockLength === 0) return offset; + if (offset + blockLength > buffer.byteLength) return null; + offset += blockLength; + } + return null; +} + +function gifDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) { + return null; + } + const logicalWidth = buffer.readUInt16LE(6); + const logicalHeight = buffer.readUInt16LE(8); + if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null; + const packed = buffer[10]!; + let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1)); + if (offset > buffer.byteLength) return null; + let width = logicalWidth; + let height = logicalHeight; + let frameCount = 0; + let framePixels = 0; + while (offset < buffer.byteLength) { + const marker = buffer[offset]; + if (marker === 0x3b) return frameCount > 0 ? { width, height } : null; + if (marker === 0x2c) { + if (offset + 10 > buffer.byteLength) return null; + const left = buffer.readUInt16LE(offset + 1); + const top = buffer.readUInt16LE(offset + 3); + const frameWidth = buffer.readUInt16LE(offset + 5); + const frameHeight = buffer.readUInt16LE(offset + 7); + if (frameWidth === 0 || frameHeight === 0) return null; + framePixels += frameWidth * frameHeight; + if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null; + width = Math.max(width, left + frameWidth); + height = Math.max(height, top + frameHeight); + if (!safeDimensions({ width, height })) return null; + const framePacked = buffer[offset + 9]!; + offset += 10; + if ((framePacked & 0x80) !== 0) { + offset += 3 * 2 ** ((framePacked & 0x07) + 1); + } + if (offset >= buffer.byteLength) return null; + const minimumCodeSize = buffer[offset]!; + if (minimumCodeSize < 2 || minimumCodeSize > 8) return null; + offset += 1; + const nextOffset = skipGifSubBlocks(buffer, offset); + if (nextOffset === null) return null; + offset = nextOffset; + frameCount += 1; + continue; + } + if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null; + const nextOffset = skipGifSubBlocks(buffer, offset + 2); + if (nextOffset === null) return null; + offset = nextOffset; + } + return null; +} + +interface JpegExifMetadata { + readonly complete: boolean; + readonly orientation: number | null; +} + +function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null { + if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") { + return null; + } + const metadataWithoutOrientation = (): JpegExifMetadata => ({ + complete: true, + orientation: null, + }); + if (segment.byteLength < 14) return metadataWithoutOrientation(); + const tiffOffset = 6; + const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49; + const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d; + if (!littleEndian && !bigEndian) return metadataWithoutOrientation(); + const readUInt16 = (offset: number): number | null => { + if (offset < 0 || offset + 2 > segment.byteLength) return null; + return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset); + }; + const readUInt32 = (offset: number): number | null => { + if (offset < 0 || offset + 4 > segment.byteLength) return null; + return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset); + }; + const relativeIfdOffset = readUInt32(tiffOffset + 4); + if (relativeIfdOffset === null) return metadataWithoutOrientation(); + // Keep untrusted metadata parsing linear even when IFD pointers overlap. + let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12); + const budgetExhausted = Symbol("ifd-entry-budget-exhausted"); + type IfdOrientation = number | null | typeof budgetExhausted; + const subIfdOrientationByOffset = new Map(); + const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => { + if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) { + return subIfdOrientationByOffset.get(ifdOffset) ?? null; + } + const entryCount = readUInt16(ifdOffset); + if (entryCount === null) return null; + let result: IfdOrientation = null; + for (let index = 0; index < entryCount; index += 1) { + if (remainingIfdEntryVisits === 0) return budgetExhausted; + remainingIfdEntryVisits -= 1; + const entryOffset = ifdOffset + 2 + index * 12; + if (entryOffset + 12 > segment.byteLength) break; + const tag = readUInt16(entryOffset); + const type = readUInt16(entryOffset + 2); + const count = readUInt32(entryOffset + 4); + if (tag === 0x0112 && type === 3 && count === 1) { + const orientation = readUInt16(entryOffset + 8); + if (orientation !== null && orientation >= 1 && orientation <= 8) { + result = orientation; + break; + } + } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) { + const relativeSubIfdOffset = readUInt32(entryOffset + 8); + if (relativeSubIfdOffset !== null) { + const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false); + if (orientation === budgetExhausted) return budgetExhausted; + if (orientation !== null) { + result = orientation; + break; + } + } + } + } + if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result); + return result; + }; + const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true); + return orientation === budgetExhausted + ? { complete: false, orientation: null } + : { complete: true, orientation }; +} + +function jpegDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]); + let offset = 2; + let dimensions: ImageDimensions | null = null; + let exifMetadata: JpegExifMetadata | null = null; + while (offset + 3 < buffer.byteLength) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + while (buffer[offset] === 0xff) offset += 1; + const marker = buffer[offset]; + offset += 1; + if (marker === undefined || marker === 0xd9 || marker === 0xda) break; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + if (offset + 1 >= buffer.byteLength) return null; + const length = buffer.readUInt16BE(offset); + if (length < 2 || offset + length > buffer.byteLength) return null; + if (marker === 0xe1 && exifMetadata === null) { + exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length)); + } + if (startOfFrameMarkers.has(marker)) { + if (length < 7) return null; + if (dimensions !== null) return null; + dimensions = { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + offset += length; + } + if (!dimensions) return null; + if (exifMetadata?.complete === false) return null; + const orientation = exifMetadata?.orientation; + return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8 + ? { width: dimensions.height, height: dimensions.width } + : dimensions; +} + +function webpDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 30 || + buffer.subarray(0, 4).toString("ascii") !== "RIFF" || + buffer.subarray(8, 12).toString("ascii") !== "WEBP" + ) { + return null; + } + const kind = buffer.subarray(12, 16).toString("ascii"); + if (kind === "VP8X") { + return { + width: 1 + buffer.readUIntLE(24, 3), + height: 1 + buffer.readUIntLE(27, 3), + }; + } + if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + }; + } + if (kind === "VP8L" && buffer[20] === 0x2f) { + return { + width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8), + height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10), + }; + } + return null; +} + +function dibDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 12) return null; + const headerSize = buffer.readUInt32LE(0); + if (headerSize === 12) { + return { + width: buffer.readUInt16LE(4), + height: buffer.readUInt16LE(6), + }; + } + if (headerSize < 40 || buffer.byteLength < 12) return null; + return { + width: Math.abs(buffer.readInt32LE(4)), + height: Math.abs(buffer.readInt32LE(8)), + }; +} + +function icoDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 22 || + buffer.readUInt16LE(0) !== 0 || + (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2) + ) { + return null; + } + const count = buffer.readUInt16LE(4); + if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null; + let width = 0; + let height = 0; + for (let index = 0; index < count; index += 1) { + const offset = 6 + index * 16; + width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!); + height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!); + if (!safeDimensions({ width, height })) return null; + const byteLength = buffer.readUInt32LE(offset + 8); + const imageOffset = buffer.readUInt32LE(offset + 12); + if ( + byteLength === 0 || + imageOffset < 6 + count * 16 || + imageOffset > buffer.byteLength || + byteLength > buffer.byteLength - imageOffset + ) + return null; + const embedded = buffer.subarray(imageOffset, imageOffset + byteLength); + const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded); + if (!safeDimensions(embeddedDimensions)) return null; + } + return { width, height }; +} + +function sourceDimensions(buffer: Buffer): ImageDimensions | null { + return ( + pngDimensions(buffer) ?? + gifDimensions(buffer) ?? + jpegDimensions(buffer) ?? + webpDimensions(buffer) ?? + icoDimensions(buffer) + ); +} + +async function normalizeFaviconBuffer( + webContents: Electron.WebContents, + mime: string | null, + buffer: Buffer, + signal: AbortSignal, +): Promise { + const declaredMime = mime?.trim().toLowerCase() || null; + const normalizedMime = + declaredMime === "application/x-icon" + ? "image/x-icon" + : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream" + ? null + : declaredMime; + const dimensions = sourceDimensions(buffer); + if ( + (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) || + normalizedMime === "image/svg+xml" || + buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES || + !safeDimensions(dimensions) + ) { + return { kind: "none" }; + } + + const rasterized = await rasterizeFavicon( + webContents, + normalizedMime, + buffer, + dimensions, + signal, + ); + if (rasterized.kind === "timed-out") return rasterized; + return typeof rasterized.value === "string" && + rasterized.value.startsWith("data:image/png;base64,") && + rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH + ? { kind: "captured", dataUrl: rasterized.value } + : { kind: "none" }; +} + +async function rasterizeFavicon( + webContents: Electron.WebContents, + mime: string | null, + buffer: Buffer, + dimensions: ImageDimensions, + signal: AbortSignal, +): Promise { + const gate = rasterizationGates.get(webContents) ?? { generation: 0 }; + rasterizationGates.set(webContents, gate); + const generation = ++gate.generation; + const previousLaunchAllowed = gate.launchAllowed; + if (previousLaunchAllowed) { + await waitForRasterLaunch(previousLaunchAllowed, signal); + } + if (signal.aborted || generation !== gate.generation) { + return { kind: "completed", value: null }; + } + + const payload = buffer.toString("base64"); + const blobType = mime ?? ""; + const scale = Math.min(32 / dimensions.width, 32 / dimensions.height); + const decodeWidth = Math.max(1, Math.round(dimensions.width * scale)); + const decodeHeight = Math.max(1, Math.round(dimensions.height * scale)); + const drawX = (32 - decodeWidth) / 2; + const drawY = (32 - decodeHeight) / 2; + const code = ` + (() => { + const rasterize = async () => { + try { + const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), { + resizeWidth: ${decodeWidth}, + resizeHeight: ${decodeHeight}, + resizeQuality: "high", + }); + try { + if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) { + return null; + } + const canvas = new OffscreenCanvas(32, 32); + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight}); + const blob = await canvas.convertToBlob({ type: "image/png" }); + const output = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (const byte of output) binary += String.fromCharCode(byte); + return "data:image/png;base64," + btoa(binary); + } finally { + bitmap.close(); + } + } catch { + return null; + } + }; + return rasterize(); + })() + `; + + const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [ + { code }, + ]); + + const result = new Promise((resolve, reject) => { + // Electron cannot cancel isolated-world execution. This timeout ends only + // the logical attempt; renderer work may finish after a newer attempt starts. + const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); + let settled = false; + const finish = (complete: () => void) => { + if (settled) return; + settled = true; + timeout.removeEventListener("abort", onTimeout); + signal.removeEventListener("abort", onAbort); + complete(); + }; + const onTimeout = () => { + finish(() => resolve({ kind: "timed-out" })); + }; + const onAbort = () => { + finish(() => resolve({ kind: "completed", value: null })); + }; + timeout.addEventListener("abort", onTimeout, { once: true }); + signal.addEventListener("abort", onAbort, { once: true }); + void execution.then( + (value) => { + finish(() => resolve({ kind: "completed", value })); + }, + (cause: unknown) => { + finish(() => reject(cause)); + }, + ); + if (signal.aborted) onAbort(); + }); + // The logical timeout does not cancel Electron's renderer work. Keep the + // gate closed until that physical execution actually settles. + const launchAllowed = execution.then( + () => undefined, + () => undefined, + ); + gate.launchAllowed = launchAllowed; + void launchAllowed.then(() => { + if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed; + }); + return await result; +} diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c2742a..c24dca802c58 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -186,6 +186,102 @@ const makeTestPreviewWebContents = ( capturePage, }) as never; +const TEST_FAVICON = "data:image/png;base64,cG5n"; + +const makeSourcePng = (width = 1, height = 1): Buffer => { + const buffer = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +}; + +const makeFaviconWebContents = (options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly id?: number; + readonly rasterize?: (code: string) => Promise; + readonly url?: string; +}) => { + const sourcePng = makeSourcePng(); + const listeners = new Map void>(); + let currentUrl = options?.url ?? "http://localhost:3200/"; + let destroyed = false; + let loading = false; + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(sourcePng), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON, + ); + const reload = vi.fn(); + const loadURL = vi.fn(async (url: string) => { + currentUrl = url; + }); + const off = vi.fn(); + const debuggerOff = vi.fn(); + const webContents = { + id: options?.id ?? 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => currentUrl, + getTitle: () => "Preview", + isLoading: () => loading, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + reload, + reloadIgnoringCache: vi.fn(), + loadURL, + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + session: { fetch }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + executeJavaScriptInIsolatedWorld, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }, + }; + return { + executeJavaScriptInIsolatedWorld, + fetch, + debuggerOff, + listeners, + loadURL, + off, + reload, + setDestroyed: (value: boolean) => { + destroyed = value; + }, + setLoading: (value: boolean) => { + loading = value; + }, + setUrl: (url: string) => { + currentUrl = url; + }, + webContents: webContents as never, + }; +}; + +const settle = function* (until: () => boolean) { + for (let attempt = 0; attempt < 30 && !until(); attempt++) { + yield* Effect.promise(() => Promise.resolve()); + } +}; + const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { const listeners = new Map void>(); const send = vi.fn(); @@ -257,6 +353,32 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rejects a destroyed webview during registration", () => + withManager((manager) => + Effect.gen(function* () { + const getType = vi.fn(() => "webview" as const); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => true, + getType, + } as never); + yield* manager.createTab("tab_destroyed_registration"); + + const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42)); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewWebContentsNotFoundError", + tabId: "tab_destroyed_registration", + webContentsId: 42, + }); + } + expect(getType).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -375,6 +497,488 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches a destroyed webview instead of navigating it", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_destroyed_navigation"); + yield* manager.registerWebview("tab_destroyed_navigation", 42); + yield* manager.setColorScheme("tab_destroyed_navigation", "dark"); + preview.setDestroyed(true); + + yield* manager.navigate("tab_destroyed_navigation", "https://example.com/"); + + expect(preview.loadURL).not.toHaveBeenCalled(); + expect(preview.reload).not.toHaveBeenCalled(); + expect(preview.off).toHaveBeenCalled(); + expect(preview.debuggerOff).toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: null, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => + withManager((manager) => + Effect.gen(function* () { + const previous = makeFaviconWebContents(); + const replacement = makeFaviconWebContents({ url: "https://example.com/" }); + let current = previous.webContents; + let startReplacementRegistration: () => void = () => void 0; + const replacementReady = new Promise((resolve) => { + startReplacementRegistration = resolve; + }); + fromId.mockImplementation(() => current); + yield* manager.createTab("tab_destroyed_replacement_race"); + yield* manager.registerWebview("tab_destroyed_replacement_race", 42); + yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark"); + const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe( + Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)), + Effect.forkChild({ startImmediately: true }), + ); + previous.setDestroyed(true); + previous.debuggerOff.mockImplementationOnce(() => { + current = replacement.webContents; + startReplacementRegistration(); + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/"); + const registrationExit = yield* Fiber.await(replacementRegistration); + + expect(Exit.isSuccess(registrationExit)).toBe(true); + expect(previous.off).toHaveBeenCalled(); + expect(replacement.off).not.toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: 42, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("publishes a canonical favicon origin while the page is loading", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents({ + url: `http://localhost:3200/${"x".repeat(3_000)}`, + }); + preview.setLoading(true); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_loading"); + yield* manager.registerWebview("tab_favicon_loading", 42); + + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toMatchObject({ + dataUrl: TEST_FAVICON, + pageUrl: "http://localhost:3200", + }); + expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number)); + }), + ), + ); + + effectIt.effect("shares an identical in-flight event and lets a changed event win", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFirst!: (response: Response) => void; + const firstResponse = new Promise((resolve) => { + resolveFirst = resolve; + }); + const preview = makeFaviconWebContents({ + fetch: (url) => + url.endsWith("first.png") + ? firstResponse + : Promise.resolve( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_latest"); + yield* manager.registerWebview("tab_favicon_latest", 42); + + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + yield* settle(() => preview.fetch.mock.calls.length === 1); + faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + resolveFirst( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(preview.fetch).toHaveBeenCalledTimes(2); + expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1); + }), + ), + ); + + effectIt.effect("allows an identical retry after an undecodable capture", () => + withManager((manager) => + Effect.gen(function* () { + let rasterizations = 0; + const preview = makeFaviconWebContents({ + rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_retry"); + yield* manager.registerWebview("tab_favicon_retry", 42); + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => rasterizations === 1); + yield* settle(() => false); + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(rasterizations).toBe(2); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not publish a capture invalidated by navigation", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const preview = makeFaviconWebContents({ + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_navigation"); + yield* manager.registerWebview("tab_favicon_navigation", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => preview.fetch.mock.calls.length === 1); + preview.listeners.get("did-start-navigation")?.({ + isMainFrame: true, + isSameDocument: false, + } as never); + preview.setUrl("https://example.com/"); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.some((state) => state.favicon !== undefined)).toBe(false); + }), + ), + ); + + effectIt.effect("retains a favicon when reloading the current URL without a new event", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reload"); + yield* manager.registerWebview("tab_favicon_reload", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/"); + + expect(preview.reload).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_origin"); + yield* manager.registerWebview("tab_favicon_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("https://example.com/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect( + "retains the previous document icon across a failed cross-origin navigation", + () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_failed_origin"); + yield* manager.registerWebview("tab_favicon_failed_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.listeners.get("did-fail-load")?.( + {} as never, + -105 as never, + "Name not resolved" as never, + "https://unreachable.example/" as never, + true as never, + ); + yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_blank"); + yield* manager.registerWebview("tab_favicon_blank", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("about:blank"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Idle"); + expect(states.at(-1)?.favicon).toBeUndefined(); + + preview.setUrl("http://localhost:3200/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("clears a published favicon when a replacement webview attaches", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => { + if (id === 42) return initial.webContents; + if (id === 43) return replacement.webContents; + return null; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_replace"); + yield* manager.registerWebview("tab_favicon_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_replace", 43); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("ignores an old capture that completes after webview replacement", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const initial = makeFaviconWebContents({ + id: 42, + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => + id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_late_replace"); + yield* manager.registerWebview("tab_favicon_late_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => initial.fetch.mock.calls.length === 1); + + yield* manager.registerWebview("tab_favicon_late_replace", 43); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect( + states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), + ).toBe(false); + }), + ), + ); + + effectIt.effect("treats a reused WebContents id as a new attachment", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 42 }); + let active = initial.webContents; + fromId.mockImplementation(() => active); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + active = replacement.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); + + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(initial.off).toHaveBeenCalled(); + expect(replacement.listeners.has("page-favicon-updated")).toBe(true); + }), + ), + ); + + effectIt.effect("preserves a favicon when the active attachment registers again", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reregister"); + yield* manager.registerWebview("tab_favicon_reregister", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_reregister", 42); + + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 169fe2992dca..4799a7dfac26 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -8,6 +8,7 @@ import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, + DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -62,6 +63,7 @@ import { import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; +import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -85,6 +87,7 @@ export interface PreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: DesktopPreviewFavicon; updatedAt: string; } @@ -346,7 +349,10 @@ type PreviewInputSignal = | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { + readonly attachmentId: symbol; + readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; + readonly webContents: Electron.WebContents; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; @@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* ( + tabId: string, + state: PreviewTabState, + ) { + if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) { + yield* emit(tabId, state); + } + }); + const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, @@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.delete(webContentsId); }), ]); - if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + if (managed) { + managed.cancelFaviconCapture(); + yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + } }); const isAppShortcut = (input: Electron.Input): boolean => @@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); + const attachmentId = Symbol(); + let documentId = 0; + let nextRequestId = 0; + let activeCapture: { + readonly controller: AbortController; + readonly documentId: number; + readonly eventKey: string; + readonly requestId: number; + } | null = null; + const cancelFaviconCapture = () => { + documentId += 1; + activeCapture?.controller.abort(); + activeCapture = null; + }; const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( preserveLoadFailure: boolean, + confirmedNavigation = false, ) { if (wc.isDestroyed()) return; const zoomFactor = yield* attempt( @@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); - if (!current) return [Option.none(), tabs] as const; + if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { + return [Option.none(), tabs] as const; + } // Electron emits did-stop-loading after did-fail-load. At that point the // failed guest is no longer "loading", but it has not successfully // navigated anywhere. Keep the failure until a new load actually starts. @@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function computedNavStatus.kind === "Success" ? current.navStatus : computedNavStatus; + const clearFavicon = + confirmedNavigation && + current.favicon !== undefined && + safeHttpOrigin(current.favicon.pageUrl) !== + safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url); + const { favicon: _favicon, ...currentWithoutFavicon } = current; const state: PreviewTabState = { - ...current, + ...(clearFavicon ? currentWithoutFavicon : current), navStatus, canGoBack, canGoForward, @@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - if (Option.isSome(next)) yield* emit(tabId, next.value); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const sync = () => runFork(syncState(true)); - const syncNavigation = () => runFork(syncState(false)); + const syncNavigation = () => runFork(syncState(false, true)); + const syncInPageNavigation = () => runFork(syncState(false)); + const navigationStarted = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); + }; + const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { + readonly captureDocumentId: number; + readonly dataUrl: string; + readonly pageUrl: string; + readonly requestId: number; + }) { + const pageOrigin = safeHttpOrigin(input.pageUrl); + const managed = (yield* Ref.get(attachedRef)).get(wc.id); + if ( + !pageOrigin || + wc.isDestroyed() || + webContents.fromId(wc.id) !== wc || + managed?.attachmentId !== attachmentId || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId || + safeHttpOrigin(wc.getURL()) !== pageOrigin + ) { + return; + } + const capturedAt = yield* currentMillis; + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + webContents.fromId(wc.id) !== wc || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId + ) { + return [Option.none(), tabs] as const; + } + const state: PreviewTabState = { + ...current, + favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt }, + updatedAt, + }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + }); + const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray): void => { + const pageUrl = wc.getURL(); + if (!safeHttpOrigin(pageUrl)) return; + const candidates = selectFaviconCandidates(rawCandidates); + if (candidates.length === 0) return; + const eventKey = JSON.stringify([pageUrl, ...candidates]); + if (activeCapture?.eventKey === eventKey) return; + activeCapture?.controller.abort(); + const captureDocumentId = documentId; + const requestId = ++nextRequestId; + const controller = new AbortController(); + activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId }; + runFork( + Effect.tryPromise({ + try: () => + captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureFavicon", + tabId, + webContentsId: wc.id, + cause, + }), + }).pipe( + Effect.flatMap((result) => + result.kind === "captured" + ? publishFavicon({ + captureDocumentId, + dataUrl: result.dataUrl, + pageUrl, + requestId, + }) + : Effect.void, + ), + Effect.catch((error) => + controller.signal.aborted + ? Effect.void + : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }), + ), + Effect.ensuring( + Effect.sync(() => { + if (activeCapture?.requestId === requestId) activeCapture = null; + }), + ), + ), + ); + }; const failed = ( _event: Event, code: number, @@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { + cancelFaviconCapture(); + wc.off("did-start-navigation", navigationStarted); wc.off("did-navigate", syncNavigation); - wc.off("did-navigate-in-page", syncNavigation); + wc.off("did-navigate-in-page", syncInPageNavigation); wc.off("page-title-updated", sync); + wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); @@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); - wc.on("did-navigate-in-page", syncNavigation); + wc.on("did-navigate-in-page", syncInPageNavigation); wc.on("page-title-updated", sync); + wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); @@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { - copy.set(wc.id, { scope }); + copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc }); }), ); }); @@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const mainWindow = yield* Ref.get(mainWindowRef); if ( !wc || + wc.isDestroyed() || wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { @@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); - if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + const currentAttachment = attached.get(webContentsId); + if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { const zoomFactor = yield* attempt( { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => wc.getZoomFactor(), @@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const replacedWebContentsId = - tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; + tab.webContentsId != null && + (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc) + ? tab.webContentsId + : null; if (replacedWebContentsId !== null) { yield* Effect.all( [ @@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { - ...current, + ...currentWithoutFavicon, webContentsId, navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), @@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", + ...(current?.favicon ? { favicon: current.favicon } : {}), updatedAt, }; return [ @@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* emit(tabId, pending); if (pending.webContentsId == null) return; - const wc = webContents.fromId(pending.webContentsId); - if (!wc) { - const detached = { ...pending, webContentsId: null }; - yield* SynchronizedRef.update(tabsRef, (tabs) => - tabs.get(tabId)?.webContentsId !== pending.webContentsId - ? tabs - : replaceMap(tabs, (copy) => { - copy.set(tabId, detached); - }), + const webContentsId = pending.webContentsId; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) { + const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + const currentWebContents = webContents.fromId(webContentsId); + if ( + currentTab?.webContentsId !== webContentsId || + currentAttachment !== expectedAttachment || + (currentWebContents && !currentWebContents.isDestroyed()) + ) { + return; + } + yield* Effect.all( + [ + detachControlSession(webContentsId), + detachListeners(webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (current?.webContentsId !== webContentsId) { + return [Option.none(), tabs] as const; + } + const { favicon: _favicon, ...currentWithoutFavicon } = current; + const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null }; + return [ + Option.some(next), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; + }); + if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value); + }), ); - yield* emit(tabId, detached); return; } if (wc.getURL() === url) { diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 6f89d86df88a..558924b63da6 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -180,4 +180,135 @@ describe("browser target resolver", () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); }); + + it("classifies exact private IPv4 and IPv6 boundaries", async () => { + const { isPrivateNetworkHost } = await import("./browserTargetResolver"); + const privateHosts = [ + "0.0.0.0", + "10.0.0.0", + "10.255.255.255", + "100.64.0.0", + "100.127.255.255", + "127.0.0.0", + "127.255.255.255", + "169.254.0.0", + "169.254.255.255", + "172.16.0.0", + "172.31.255.255", + "192.168.0.0", + "192.168.255.255", + "198.18.0.0", + "198.19.255.255", + "fc00::", + "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "fe80::", + "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "::ffff:192.168.1.1", + "localhost.", + "localhost..", + "devbox.", + "devbox..", + "printer.local.", + "printer.local..", + "printer.home.arpa.", + "printer.home.arpa..", + "devbox.example.ts.net.", + "devbox.example.ts.net..", + ]; + const publicHosts = [ + "1.0.0.0", + "100.63.255.255", + "100.128.0.0", + "169.253.255.255", + "169.255.0.0", + "172.15.255.255", + "172.32.0.0", + "192.167.255.255", + "192.169.0.0", + "198.17.255.255", + "198.20.0.0", + "fbff:ffff::", + "fec0::", + "2001:4860:4860::8888", + "::ffff:8.8.8.8", + "example.com.", + ]; + expect(privateHosts.filter((host) => !isPrivateNetworkHost(host))).toEqual([]); + expect(publicHosts.filter(isPrivateNetworkHost)).toEqual([]); + }); + + it("allows only globally routable hosts to reach a public favicon provider", async () => { + const { isPublicFaviconHost } = await import("./browserTargetResolver"); + const nonPublic = [ + "192.0.0.0", + "192.0.0.255", + "192.0.2.0", + "192.0.2.255", + "192.88.99.0", + "192.88.99.255", + "198.51.100.0", + "198.51.100.255", + "203.0.113.0", + "203.0.113.255", + "224.0.0.0", + "255.255.255.255", + "::2", + "100::", + "100::ffff:ffff:ffff:ffff", + "100:0:0:1::", + "100:0:0:1:ffff:ffff:ffff:ffff", + "64:ff9b:1::1", + "64:ff9b::a00:1", + "64:ff9b::7f00:1", + "64:ff9b::c0a8:101", + "64:ff9b::c000:201", + "2001:5::1", + "2001:2::", + "2001:2:0:ffff:ffff:ffff:ffff:ffff", + "2001:db8::", + "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + "3fff::", + "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff", + "5f00::1", + "fec0::", + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "::ffff:192.0.2.1", + "app.test", + "app.test..", + "printer.local..", + "printer.home.arpa..", + "devbox.example.ts.net..", + "127.0.0.1..", + "127.1..", + "10.1..", + "172.16.1..", + "192.168.1..", + "service.internal", + "hidden.onion", + ]; + const publicHosts = [ + "191.255.255.255", + "192.0.1.255", + "192.0.3.0", + "198.51.99.255", + "198.51.101.0", + "203.0.112.255", + "203.0.114.0", + "223.255.255.255", + "1.1.1.1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "64:ff9b::808:808", + "2001:1::1", + "2001:3::1", + "2001:4:112::1", + "2001:20::1", + "2001:30::1", + "::ffff:8.8.8.8", + "example.com", + "example.com.", + ]; + expect(nonPublic.filter(isPublicFaviconHost)).toEqual([]); + expect(publicHosts.filter((host) => !isPublicFaviconHost(host))).toEqual([]); + }); }); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 3c3be59b4578..149248d17609 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; export const normalizeHostname = (host: string): string => - host.toLowerCase().replace(/^\[|\]$/g, ""); + host + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.+$/u, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; +const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.startsWith("::ffff:")) return null; + const suffix = normalized.slice("::ffff:".length); + const dotted = parseIpv4Address(suffix); + if (dotted) return dotted; + const hextets = suffix.split(":"); + if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const high = Number.parseInt(hextets[0]!, 16); + const low = Number.parseInt(hextets[1]!, 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; +}; + +const parseIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.includes(":")) return null; + const halves = normalized.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves[1] ? halves[1].split(":") : []; + if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const missing = 8 - head.length - tail.length; + if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; + return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => + Number.parseInt(part, 16), + ); +}; + +const ipv6PrefixMatches = ( + address: readonly number[], + prefix: readonly number[], + prefixLength: number, +): boolean => { + const fullHextets = Math.floor(prefixLength / 16); + if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; + const remainingBits = prefixLength % 16; + if (remainingBits === 0) return true; + const mask = (0xffff << (16 - remainingBits)) & 0xffff; + return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); +}; + +const isPrivateIpv4Address = (parts: readonly number[]): boolean => + parts[0] === 0 || + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); + +const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => + isPrivateIpv4Address(parts) || + parts[0]! >= 224 || + // Deliberately suppress the whole protocol-assignment block. IANA marks + // .9 and .10 globally reachable, but privacy-safe false negatives are + // preferable to disclosing another special-purpose address by mistake. + (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || + (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || + (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || + (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); + export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; }; -const isPrivateNetworkHost = (host: string): boolean => { +export const isPrivateNetworkHost = (host: string): boolean => { const normalized = normalizeHostname(host); - if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { + if ( + normalized === "::" || + isLocalLoopbackHost(normalized) || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized === "home.arpa" || + normalized.endsWith(".home.arpa") || + (!normalized.includes(".") && !normalized.includes(":")) + ) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized); - if (parts) { - return ( - parts[0] === 10 || - (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) - ); - } + const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (parts) return isPrivateIpv4Address(parts); const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); @@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ +export const isPublicFaviconHost = (host: string): boolean => { + // A single trailing dot is a valid absolute DNS name. Repeated trailing + // dots are malformed and can conceal legacy numeric forms such as 127.1. + if (host.endsWith("..")) return false; + const normalized = normalizeHostname(host); + if (isPrivateNetworkHost(normalized)) return false; + if ( + [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), + ) + ) { + return false; + } + const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); + if (!normalized.includes(":")) return true; + const ipv6 = parseIpv6Address(normalized); + if (!ipv6) return false; + if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) { + const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff]; + return !isSpecialPurposeIpv4Address(embeddedIpv4); + } + const first = ipv6[0]!; + if ((first & 0xe000) !== 0x2000) return false; + if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { + const publicProtocolAssignment = + (ipv6[1] === 1 && + ipv6.slice(2, 7).every((part) => part === 0) && + [1, 2, 3].includes(ipv6[7]!)) || + ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || + ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || + ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || + ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); + return publicProtocolAssignment; + } + if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; + if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; + if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; + return true; +}; + const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); diff --git a/apps/web/src/browserFaviconLogic.test.ts b/apps/web/src/browserFaviconLogic.test.ts new file mode 100644 index 000000000000..39ee76ef1ef3 --- /dev/null +++ b/apps/web/src/browserFaviconLogic.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + BROWSER_FAVICON_MAX_ENTRIES, + type BrowserFaviconEntry, + evictExcessFavicons, + faviconKey, + faviconStorageLocation, + isStorableFaviconDataUrl, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; + +const PNG = "data:image/png;base64,AAAA"; + +function entry(capturedAt = 0): BrowserFaviconEntry { + return { dataUrl: PNG, capturedAt }; +} + +describe("browser favicon logic", () => { + it("keys valid origins canonically while keeping distinct scopes separate", () => { + expect(faviconKey("env:project", "http://myapp.test:3000/admin?x=1", null)).toBe( + "env:project http://myapp.test:3000", + ); + expect(faviconKey("env:project", "http://192.168.64.2:3000/", "192.168.64.2")).toBe( + faviconKey("env:project", "http://localhost:3000/", "192.168.64.2"), + ); + expect(faviconKey("env:project", "http://127.0.0.1:3000/", null)).toBe( + faviconKey("env:project", "http://0.0.0.0:3000/", null), + ); + const keys = [ + faviconKey("env:a", "http://localhost:3000/", null), + faviconKey("env:b", "http://localhost:3000/", null), + faviconKey("env:a", "http://localhost:5173/", null), + faviconKey("env:a", "https://localhost:3000/", null), + faviconKey("env:a", "http://192.168.1.50:3000/", "192.168.64.2"), + ]; + expect(new Set(keys).size).toBe(keys.length); + expect(faviconKey("env:a", "not a url", null)).toBeNull(); + expect(faviconKey("env:a", "ftp://example.com/", null)).toBeNull(); + expect(faviconKey("", "http://localhost/", null)).toBeNull(); + expect(faviconKey("env:a", "http://local:3000/", null)).not.toBe( + faviconKey("env:a", "http://localhost:3000/", null), + ); + }); + + it("retains an exact environment-host alias for offline lookup", () => { + const expected = { + aliases: ["192.168.64.2"], + key: "env:project http://localhost:3000", + }; + expect( + faviconStorageLocation("env:project", "http://localhost:3000/app", "192.168.64.2"), + ).toEqual(expected); + expect( + faviconStorageLocation("env:project", "http://192.168.64.2:3000/app", "192.168.64.2"), + ).toEqual(expected); + expect( + faviconStorageLocation("env:project", "https://example.com/app", "192.168.64.2"), + ).toEqual({ aliases: [], key: "env:project https://example.com:443" }); + expect(faviconStorageLocation("env:project", "http://localhost:3000/app", "fd00::1")).toEqual({ + aliases: ["fd00::1"], + key: "env:project http://localhost:3000", + }); + expect(faviconKey("env:project", "http://[2001:4860:4860::8888]/", null)).toBe( + "env:project http://[2001:4860:4860::8888]:80", + ); + }); + + it("accepts only bounded base64 PNG data", () => { + expect(isStorableFaviconDataUrl(PNG)).toBe(true); + expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false); + expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false); + }); + + it("evicts old entries and sanitizes hydrated state", () => { + const byKey = Object.fromEntries( + Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, index) => [ + `key-${index}`, + entry(index), + ]), + ); + const result = evictExcessFavicons(byKey); + expect(Object.keys(result)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES); + expect(result["key-0"]).toBeUndefined(); + expect(result["key-1"]).toBeUndefined(); + expect( + migratePersistedBrowserFaviconState({ + byKey: { + "env:project http://local:3000": entry(4), + "env:project http://localhost:3000": entry(5), + "env:project http://localhost:3003": { + ...entry(6), + aliases: [ + "192.168.64.2", + "192.168.64.2", + "192.168.64.3", + "192.168.64.4", + "192.168.64.5", + "192.168.64.6", + "Not Normalized", + "not a host/", + "x".repeat(5_000), + ], + }, + "env:project http://localhost:3001": { + dataUrl: "https://example.com/icon.png", + capturedAt: 6, + }, + "env:project http://localhost:3002": { dataUrl: PNG, capturedAt: Number.NaN }, + }, + }), + ).toEqual({ + byKey: { + "env:project http://localhost:3000": entry(5), + "env:project http://localhost:3003": { + ...entry(6), + aliases: ["192.168.64.2", "192.168.64.3", "192.168.64.4", "192.168.64.5"], + }, + }, + }); + }); +}); diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts new file mode 100644 index 000000000000..695bcff20e95 --- /dev/null +++ b/apps/web/src/browserFaviconLogic.ts @@ -0,0 +1,201 @@ +import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; + +export type BrowserFaviconEntry = { + dataUrl: string; + capturedAt: number; + aliases?: ReadonlyArray; +}; + +export const BROWSER_FAVICON_MAX_ENTRIES = 40; +export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; +export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; +const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; + +function formatFaviconHost(host: string): string { + return host.includes(":") ? `[${host}]` : host; +} + +export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean { + try { + const parsed = new URL(url); + const host = normalizeHostname(parsed.hostname); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + (isLocalLoopbackHost(host) || host === "0.0.0.0") + ); + } catch { + return false; + } +} + +export function isValidFaviconCapturedAt(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value >= 0 && + value <= FAVICON_CAPTURED_AT_MAX && + value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS + ); +} + +function migratePersistedFaviconKey(key: string): string | null { + if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return null; + const separator = key.indexOf(" "); + if (separator <= 0) return null; + const scope = key.slice(0, separator); + const origin = key.slice(separator + 1); + try { + const parsed = new URL(origin); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" || + parsed.username !== "" || + parsed.password !== "" + ) + return null; + if (normalizeHostname(parsed.hostname) !== "local") return key; + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + return `${scope} ${parsed.protocol}//localhost:${port}`; + } catch { + return null; + } +} + +function persistedFaviconAlias(value: unknown): string | null { + if (typeof value !== "string" || value.length > BROWSER_FAVICON_MAX_ALIAS_LENGTH) return null; + const normalized = normalizeHostname(value); + if (!normalized || normalized !== value) return null; + try { + const parsed = new URL(`http://${formatFaviconHost(normalized)}`); + return normalizeHostname(parsed.hostname) === normalized ? normalized : null; + } catch { + return null; + } +} + +export function faviconKey( + projectRefKey: string, + url: string, + environmentHostname: string | null, +): string | null { + if (projectRefKey.length === 0) return null; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + const host = normalizeHostname(parsed.hostname); + const canonicalHost = + isLocalLoopbackHost(host) || + host === "0.0.0.0" || + (environmentHostname !== null && host === normalizeHostname(environmentHostname)) + ? "localhost" + : host; + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + return `${projectRefKey} ${parsed.protocol}//${formatFaviconHost(canonicalHost)}:${port}`; + } catch { + return null; + } +} + +export function faviconStorageLocation( + projectRefKey: string, + url: string, + environmentHostname: string | null, +): { readonly aliases: ReadonlyArray; readonly key: string } | null { + const canonicalKey = faviconKey(projectRefKey, url, environmentHostname); + if (!canonicalKey) return null; + if (environmentHostname === null) return { aliases: [], key: canonicalKey }; + try { + const parsed = new URL(url); + const host = normalizeHostname(parsed.hostname); + const normalizedEnvironmentHostname = normalizeHostname(environmentHostname); + if ( + normalizedEnvironmentHostname.length === 0 || + (!isLocalLoopbackHost(host) && host !== "0.0.0.0" && host !== normalizedEnvironmentHostname) + ) { + return { aliases: [], key: canonicalKey }; + } + return { + aliases: + isLocalLoopbackHost(normalizedEnvironmentHostname) || + normalizedEnvironmentHostname === "0.0.0.0" + ? [] + : [normalizedEnvironmentHostname], + key: canonicalKey, + }; + } catch { + return { aliases: [], key: canonicalKey }; + } +} + +export function isStorableFaviconDataUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + !value.startsWith("data:image/png;base64,") || + value.length > FAVICON_DATA_URL_MAX_LENGTH + ) { + return false; + } + const payload = value.slice("data:image/png;base64,".length); + return ( + payload.length > 0 && + payload.length % 4 !== 1 && + !/[^a-z0-9+/=]/i.test(payload) && + /^[a-z0-9+/]*={0,2}$/i.test(payload) + ); +} + +export function evictExcessFavicons( + byKey: Record, +): Record { + const keys = Object.keys(byKey); + if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey; + return Object.fromEntries( + keys + .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0)) + .slice(0, BROWSER_FAVICON_MAX_ENTRIES) + .map((key) => [key, byKey[key] as BrowserFaviconEntry]), + ); +} + +export function migratePersistedBrowserFaviconState(persistedState: unknown): { + byKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byKey: {} }; + const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} }; + const byKey: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + const migratedKey = migratePersistedFaviconKey(key); + if (!migratedKey) continue; + if (!value || typeof value !== "object") continue; + const { dataUrl, capturedAt } = value as Record; + if (!isStorableFaviconDataUrl(dataUrl)) continue; + if (!isValidFaviconCapturedAt(capturedAt)) continue; + const rawAliases = (value as Record).aliases; + const aliases = Array.isArray(rawAliases) + ? [...new Set(rawAliases.map(persistedFaviconAlias).filter((alias) => alias !== null))].slice( + 0, + BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, + ) + : []; + const existing = byKey[migratedKey]; + const newest = !existing || capturedAt > existing.capturedAt; + const mergedAliases = [ + ...new Set([ + ...(newest ? aliases : (existing?.aliases ?? [])), + ...(newest ? (existing?.aliases ?? []) : aliases), + ]), + ].slice(0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY); + byKey[migratedKey] = { + dataUrl: newest ? dataUrl : existing.dataUrl, + capturedAt: newest ? capturedAt : existing.capturedAt, + ...(mergedAliases.length > 0 ? { aliases: mergedAliases } : {}), + }; + } + return { byKey: evictExcessFavicons(byKey) }; +} diff --git a/apps/web/src/browserFaviconStore.test.ts b/apps/web/src/browserFaviconStore.test.ts new file mode 100644 index 000000000000..8aef763ddd4a --- /dev/null +++ b/apps/web/src/browserFaviconStore.test.ts @@ -0,0 +1,316 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); +vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) })); + +import { + flushPendingFaviconsForThread, + lookupFavicon, + mergeBrowserFaviconState, + recordFaviconForProject, + recordFaviconForThread, + registerFaviconProjectForThread, + resetBrowserFaviconsForTests, + resolveBrowserFaviconStorage, + useBrowserFaviconStore, +} from "./browserFaviconStore"; +import { + BROWSER_FAVICON_MAX_ENTRIES, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; + +const environmentId = EnvironmentId.make("env-1"); +const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); +const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; +const PNG = "data:image/png;base64,AAAA"; +const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({ + pageUrl, + capturedAt, + dataUrl, +}); + +describe("browser favicon store", () => { + beforeEach(resetBrowserFaviconsForTests); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("keeps the newest capture for an origin and permits an identical later revisit", () => { + const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); + recordFaviconForProject( + projectRef, + favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), + null, + ); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); + expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ + { dataUrl: PNG, capturedAt: 30 }, + ]); + expect(recordFavicon).toHaveBeenCalledTimes(2); + }); + + it("does not share localhost icons across environments or physical projects", () => { + const otherEnvironment = scopeProjectRef( + EnvironmentId.make("env-2"), + ProjectId.make("project-1"), + ); + const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2")); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null); + recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null); + recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null); + expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([ + "env-1:project-1 http://localhost:3000", + "env-2:project-1 http://localhost:3000", + "env-1:project-2 http://localhost:3000", + ]); + }); + + it("finds a persisted environment icon after shell hydration without a live connection", () => { + recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2"); + const byKey = useBrowserFaviconStore.getState().byKey; + expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull(); + expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG); + expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG); + expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull(); + expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull(); + expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull(); + expect( + lookupFavicon( + byKey, + scopeProjectRef(environmentId, ProjectId.make("project-2")), + "http://192.168.64.2:3000/app", + null, + ), + ).toBeNull(); + }); + + it("finds a persisted IPv6 environment icon without a live connection", () => { + recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1"); + const migrated = migratePersistedBrowserFaviconState({ + byKey: useBrowserFaviconStore.getState().byKey, + }).byKey; + expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG); + }); + + it("keeps exact host aliases attached to the newest canonical icon", () => { + const olderPng = "data:image/png;base64,QkJCQg=="; + const newerPng = "data:image/png;base64,Q0NDQw=="; + recordFaviconForProject( + projectRef, + favicon("http://192.168.64.2:3000/", 10, olderPng), + "192.168.64.2", + ); + recordFaviconForProject( + projectRef, + favicon("http://192.168.64.3:3000/", 20, newerPng), + "192.168.64.3", + ); + recordFaviconForProject( + projectRef, + favicon("http://192.168.64.4:3000/", 15, olderPng), + "192.168.64.4", + ); + const byKey = useBrowserFaviconStore.getState().byKey; + for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) { + expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng); + } + expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20); + }); + + it("evicts an icon and its exact host aliases atomically", () => { + recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2"); + for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) { + recordFaviconForProject( + projectRef, + favicon(`https://example-${index}.com/`, 100 + index), + null, + ); + } + expect( + lookupFavicon( + useBrowserFaviconStore.getState().byKey, + projectRef, + "http://192.168.64.2:3000/", + null, + ), + ).toBe(PNG); + + recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null); + expect( + lookupFavicon( + useBrowserFaviconStore.getState().byKey, + projectRef, + "http://192.168.64.2:3000/", + null, + ), + ).toBeNull(); + }); + + it("retains multiple origins until project and connection metadata hydrate", () => { + expect( + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), + ).toBe(false); + expect( + recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined), + ).toBe(false); + expect( + Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}), + ).toHaveLength(2); + + expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true); + expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([ + "env-1:project-1 http://localhost:3000", + "env-1:project-1 http://localhost:5173", + ]); + expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); + }); + + it("persists unambiguous loopback captures while the environment is offline", () => { + expect( + recordFaviconForThread( + threadRef, + favicon("http://localhost:3000/", 1), + projectRef, + undefined, + ), + ).toBe(true); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 }, + }); + + recordFaviconForThread( + threadRef, + favicon("http://192.168.64.2:5173/", 2), + projectRef, + undefined, + ); + expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false); + expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); + }); + + it("keeps pending captures in store-owned state independent of bridge lifetime", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); + const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; + useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); + flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 }, + }); + }); + + it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); + registerFaviconProjectForThread(threadRef, projectRef); + const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; + expect(registered).toEqual(projectRef); + expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); + expect( + lookupFavicon( + useBrowserFaviconStore.getState().byKey, + registered!, + "http://localhost:8025/", + null, + ), + ).toBe(PNG); + }); + + it("bounds pending memory by origin and thread", () => { + for (let thread = 0; thread < 22; thread += 1) { + for (let port = 3000; port < 3012; port += 1) { + recordFaviconForThread( + { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, + favicon(`http://localhost:${port}/`, port), + null, + undefined, + ); + } + } + const pending = useBrowserFaviconStore.getState().pendingByThreadKey; + expect(Object.keys(pending)).toHaveLength(20); + expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( + true, + ); + }); + + it("sanitizes hydrated state while preserving actions and transient pending data", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); + const current = useBrowserFaviconStore.getState(); + const merged = mergeBrowserFaviconState( + { + byKey: { + "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, + "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 }, + "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 }, + ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, + }, + }, + current, + ); + expect(merged.byKey).toEqual({ + "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, + }); + expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); + expect(typeof merged.recordFavicon).toBe("function"); + }); + + it("falls back to memory when localStorage access throws", () => { + vi.stubGlobal( + "window", + Object.defineProperty({}, "localStorage", { + get: () => { + throw new Error("storage blocked"); + }, + }), + ); + const storage = resolveBrowserFaviconStorage(); + storage.setItem("key", "value"); + expect(storage.getItem("key")).toBe("value"); + }); + + it("falls back to memory when localStorage operations throw", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: vi.fn(() => { + throw new Error("read blocked"); + }), + setItem: vi.fn(() => { + throw new Error("quota exceeded"); + }), + removeItem: vi.fn(() => { + throw new Error("remove blocked"); + }), + }, + }); + const storage = resolveBrowserFaviconStorage(); + + storage.setItem("key", "value"); + expect(storage.getItem("key")).toBe("value"); + storage.removeItem("key"); + expect(storage.getItem("key")).toBeNull(); + }); + + it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: vi.fn(() => "stale"), + setItem: vi.fn(() => { + throw new Error("quota exceeded"); + }), + removeItem: vi.fn(() => { + throw new Error("remove blocked"); + }), + }, + }); + const storage = resolveBrowserFaviconStorage(); + + storage.setItem("key", "fresh"); + expect(storage.getItem("key")).toBe("fresh"); + storage.removeItem("key"); + expect(storage.getItem("key")).toBeNull(); + }); +}); diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts new file mode 100644 index 000000000000..3354cea50ce7 --- /dev/null +++ b/apps/web/src/browserFaviconStore.ts @@ -0,0 +1,342 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, +} from "@t3tools/client-runtime/environment"; +import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useMemo } from "react"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { normalizeHostname } from "~/browser/browserTargetResolver"; +import { useThreadShell } from "~/state/entities"; +import { usePreparedConnection } from "~/state/session"; + +import { + BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, + type BrowserFaviconEntry, + canCanonicalizeFaviconWithoutEnvironment, + evictExcessFavicons, + faviconKey, + faviconStorageLocation, + isStorableFaviconDataUrl, + isValidFaviconCapturedAt, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; + +const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; +const MAX_PENDING_ORIGINS_PER_THREAD = 10; +const MAX_PENDING_THREADS = 20; +const MAX_REGISTERED_THREADS = 100; + +type PendingFavicon = DesktopPreviewFavicon; +type PendingFaviconsByOrigin = Record; + +export interface BrowserFaviconStoreState { + byKey: Record; + /** Capture buffering only. */ + pendingByThreadKey: Record; + /** Non-persisted fallback for draft/background threads without a hydrated shell. */ + projectRefByThreadKey: Record; + recordFavicon: ( + key: string, + dataUrl: string, + capturedAt: number, + aliases?: ReadonlyArray, + ) => void; +} + +function pendingOriginKey(pageUrl: string): string | null { + return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null; +} + +function addPendingFavicon( + pendingByThreadKey: Record, + threadKey: string, + favicon: PendingFavicon, +): Record { + const originKey = pendingOriginKey(favicon.pageUrl); + if (!originKey) return pendingByThreadKey; + const current = pendingByThreadKey[threadKey] ?? {}; + const existing = current[originKey]; + if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey; + const nextForThread = { + ...current, + [originKey]: favicon, + }; + const boundedForThread = Object.fromEntries( + Object.entries(nextForThread) + .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt) + .slice(0, MAX_PENDING_ORIGINS_PER_THREAD), + ); + + const withoutThread = { ...pendingByThreadKey }; + delete withoutThread[threadKey]; + return Object.fromEntries( + [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS), + ); +} + +export function resolveBrowserFaviconStorage(): StateStorage { + const fallback = createMemoryStorage(); + const shadowedNames = new Set(); + let primary: Storage; + try { + if (typeof window === "undefined") return fallback; + primary = window.localStorage; + } catch { + return fallback; + } + return { + getItem: (name) => { + if (shadowedNames.has(name)) return fallback.getItem(name); + try { + return primary.getItem(name); + } catch { + return fallback.getItem(name); + } + }, + setItem: (name, value) => { + fallback.setItem(name, value); + try { + primary.setItem(name, value); + shadowedNames.delete(name); + } catch { + shadowedNames.add(name); + } + }, + removeItem: (name) => { + fallback.removeItem(name); + try { + primary.removeItem(name); + shadowedNames.delete(name); + } catch { + shadowedNames.add(name); + } + }, + }; +} + +export const useBrowserFaviconStore = create()( + persist( + (set) => ({ + byKey: {}, + pendingByThreadKey: {}, + projectRefByThreadKey: {}, + recordFavicon: (key, dataUrl, capturedAt, aliases = []) => + set((state) => { + if (!isStorableFaviconDataUrl(dataUrl)) return state; + if (!isValidFaviconCapturedAt(capturedAt)) return state; + const existing = state.byKey[key]; + const aliasCandidates = + capturedAt > (existing?.capturedAt ?? -1) + ? [...aliases, ...(existing?.aliases ?? [])] + : [...(existing?.aliases ?? []), ...aliases]; + const nextAliases = [...new Set(aliasCandidates)].slice( + 0, + BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, + ); + const aliasesUnchanged = + nextAliases.length === (existing?.aliases?.length ?? 0) && + nextAliases.every((alias, index) => alias === existing?.aliases?.[index]); + if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state; + return { + byKey: evictExcessFavicons({ + ...state.byKey, + [key]: { + dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl, + capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1), + ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}), + }, + }), + }; + }), + }), + { + name: BROWSER_FAVICON_STORAGE_KEY, + version: 1, + storage: createJSONStorage(resolveBrowserFaviconStorage), + partialize: (state) => ({ byKey: state.byKey }), + migrate: migratePersistedBrowserFaviconState, + merge: mergeBrowserFaviconState, + }, + ), +); + +export function mergeBrowserFaviconState( + persistedState: unknown, + currentState: BrowserFaviconStoreState, +): BrowserFaviconStoreState { + return { + ...currentState, + ...migratePersistedBrowserFaviconState(persistedState), + }; +} + +export function registerFaviconProjectForThread( + threadRef: ScopedThreadRef, + projectRef: ScopedProjectRef, +): void { + const threadKey = scopedThreadKey(threadRef); + const state = useBrowserFaviconStore.getState(); + const current = state.projectRefByThreadKey[threadKey]; + if ( + current?.environmentId === projectRef.environmentId && + current.projectId === projectRef.projectId + ) { + return; + } + useBrowserFaviconStore.setState({ + projectRefByThreadKey: Object.fromEntries( + [ + ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey), + [threadKey, projectRef], + ].slice(-MAX_REGISTERED_THREADS), + ), + }); +} + +export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null { + const shell = useThreadShell(threadRef); + const shellProjectId = shell?.projectId ?? null; + const shellProjectRef = useMemo( + () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null), + [shellProjectId, threadRef.environmentId], + ); + const registered = useBrowserFaviconStore( + (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null, + ); + return shellProjectRef ?? registered; +} + +export function recordFaviconForProject( + projectRef: ScopedProjectRef, + favicon: DesktopPreviewFavicon, + environmentHostname: string | null, +): boolean { + if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) { + return false; + } + const location = faviconStorageLocation( + scopedProjectKey(projectRef), + favicon.pageUrl, + environmentHostname, + ); + if (!location) return false; + const state = useBrowserFaviconStore.getState(); + const existing = state.byKey[location.key]; + if ( + existing && + existing.capturedAt >= favicon.capturedAt && + location.aliases.every((alias) => existing.aliases?.includes(alias)) + ) + return true; + state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases); + return true; +} + +export function recordFaviconForThread( + threadRef: ScopedThreadRef, + favicon: DesktopPreviewFavicon, + projectRef: ScopedProjectRef | null, + environmentHostname: string | undefined, +): boolean { + if ( + !isStorableFaviconDataUrl(favicon.dataUrl) || + !isValidFaviconCapturedAt(favicon.capturedAt) || + !pendingOriginKey(favicon.pageUrl) + ) + return false; + const hostname = + environmentHostname !== undefined + ? environmentHostname + : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) + ? null + : undefined; + if ( + projectRef && + hostname !== undefined && + recordFaviconForProject(projectRef, favicon, hostname) + ) { + return true; + } + const threadKey = scopedThreadKey(threadRef); + const state = useBrowserFaviconStore.getState(); + const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon); + if (pendingByThreadKey !== state.pendingByThreadKey) { + useBrowserFaviconStore.setState({ pendingByThreadKey }); + } + return false; +} + +export function flushPendingFaviconsForThread( + threadRef: ScopedThreadRef, + projectRef: ScopedProjectRef, + environmentHostname: string | undefined, +): boolean { + const threadKey = scopedThreadKey(threadRef); + const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey]; + if (!pending) return true; + const remaining = Object.fromEntries( + Object.entries(pending).filter(([, favicon]) => { + const hostname = + environmentHostname !== undefined + ? environmentHostname + : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) + ? null + : undefined; + return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname); + }), + ); + useBrowserFaviconStore.setState((state) => { + const pendingByThreadKey = { ...state.pendingByThreadKey }; + if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey]; + else pendingByThreadKey[threadKey] = remaining; + return { pendingByThreadKey }; + }); + return Object.keys(remaining).length === 0; +} + +export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null { + const projectRef = useFaviconProjectRefForThread(threadRef); + const preparedConnection = usePreparedConnection(threadRef.environmentId); + const environmentHostname = Option.isSome(preparedConnection) + ? new URL(preparedConnection.value.httpBaseUrl).hostname + : null; + return useBrowserFaviconStore((state) => + lookupFavicon(state.byKey, projectRef, url, environmentHostname), + ); +} + +export function lookupFavicon( + byKey: Record, + projectRef: ScopedProjectRef | null, + url: string, + environmentHostname: string | null, +): string | null { + const key = projectRef + ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname) + : null; + if (!key) return null; + const direct = byKey[key]; + if (direct) return direct.dataUrl; + try { + const requestedHost = normalizeHostname(new URL(url).hostname); + const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost); + const localEntry = localKey ? byKey[localKey] : null; + return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null; + } catch { + return null; + } +} + +export function resetBrowserFaviconsForTests(): void { + useBrowserFaviconStore.setState({ + byKey: {}, + pendingByThreadKey: {}, + projectRefByThreadKey: {}, + }); + useBrowserFaviconStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdc7e7dee382..e459d6d09c7b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -179,6 +179,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; +import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { @@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) { }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProjectRef = activeThread - ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : null; + const activeProjectRef = useMemo( + () => + activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, + [activeThread?.environmentId, activeThread?.projectId], + ); const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!activeThreadRef || !activeProjectRef) return; + registerFaviconProjectForThread(activeThreadRef, activeProjectRef); + }, [activeProjectRef, activeThreadRef]); useEffect(() => { if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; // Reuse the sidebar's grouping so history follows the project rows the user @@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx new file mode 100644 index 000000000000..7312f0b8c651 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,115 @@ +import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { RightPanelTabs } from "./RightPanelTabs"; + +const previewSurface = { + id: "browser:tab-1" as const, + kind: "preview" as const, + resourceId: "tab-1", +}; +const secondSurface = { + id: "browser:tab-2" as const, + kind: "preview" as const, + resourceId: "tab-2", +}; +const sessions: Readonly> = { + "tab-1": { + threadId: "thread-1", + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-09T00:00:00.000Z", + }, + "tab-2": { + threadId: "thread-1", + tabId: "tab-2", + navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-09T00:00:00.000Z", + }, +}; + +const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ + dataUrl, + pageUrl, + capturedAt: 1, +}); + +function overlay(icon: DesktopPreviewFavicon | null) { + return { + hasWebContents: true, + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system" as const, + controller: "none" as const, + favicon: icon, + }; +} + +function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) { + return renderToStaticMarkup( + undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + onAddBrowser={() => undefined} + onAddTerminal={() => undefined} + onAddPullRequest={() => undefined} + onAddDiff={() => undefined} + onAddFiles={() => undefined} + onAddAgents={() => undefined} + liveAgentCount={0} + browserAvailable + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + agentsAvailable={false} + > +
content
+
, + ); +} + +describe("RightPanelTabs preview favicon", () => { + it("prefers a live capture and never asks Google about a private hostname", () => { + const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/")); + expect(captured).toContain("data:image/png;base64,AAAA"); + expect(captured).not.toContain("s2/favicons"); + expect(renderTabs(null)).not.toContain("s2/favicons"); + }); + + it("keeps route-specific captures isolated between live tabs on one origin", () => { + const html = renderTabs( + favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"), + favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"), + ); + expect(html).toContain("data:image/png;base64,AAAA"); + expect(html).toContain("data:image/png;base64,BBBB"); + }); + + it("hides a capture while the server session still describes another origin", () => { + const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/")); + expect(html).not.toContain("data:image/png;base64,AAAA"); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 32c66dca6aee..21abd873cb3a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -22,6 +22,7 @@ import { } from "react"; import { isElectron } from "~/env"; +import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; +import { FaviconImage } from "./preview/PreviewFaviconIcon"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { @@ -48,6 +50,7 @@ interface RightPanelTabsProps { activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; + desktopByTabId: Readonly>; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; @@ -435,30 +438,35 @@ function surfaceTitle( } } -function PreviewFavicon({ url }: { url: string | null }) { - const faviconUrl = faviconUrlForOrigin(url, 32); - const [failedUrl, setFailedUrl] = useState(null); - if (!faviconUrl || failedUrl === faviconUrl) return ; +function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { + const publicProviderUrl = faviconUrlForOrigin(url, 32); return ( - setFailedUrl(faviconUrl)} + } + className="size-3 shrink-0 rounded-sm object-contain" /> ); } +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +} + function SurfaceIcon({ surface, sessions, + desktopByTabId, theme, pullRequestStatuses, }: { surface: RightPanelSurface; sessions: Readonly>; + desktopByTabId: Readonly>; theme: "light" | "dark"; pullRequestStatuses: Readonly> | undefined; }) { @@ -466,7 +474,10 @@ function SurfaceIcon({ case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - return ; + const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; + const capturedUrl = + favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; + return ; } case "diff": return ; @@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 86cab6dbe2b8..95e21c0266a4 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({ vi.mock("./useDiscoveredLocalServers", () => ({ useDiscoveredLocalServers: () => mocks.servers, })); +vi.mock("./PreviewFaviconIcon", () => ({ + PreviewFaviconIcon: () => , +})); import { PreviewEmptyState } from "./PreviewEmptyState"; const environmentId = EnvironmentId.make("env-1"); +const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; function server(port: number) { return { @@ -41,6 +45,7 @@ function server(port: number) { function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { return renderToStaticMarkup( undefined} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 3b9aacf4dfd6..4e74f44cb2aa 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { Globe, History, RadioTower } from "lucide-react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; @@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { + threadRef: ScopedThreadRef; environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentlySeenUrls?: ReadonlyArray | undefined; @@ -18,6 +19,7 @@ interface Props { } export function PreviewEmptyState({ + threadRef, environmentId, configuredUrls, recentlySeenUrls, @@ -60,6 +62,7 @@ export function PreviewEmptyState({ {recents.map((entry) => ( onOpenUrl(entry.url)} onRemove={() => onRemoveRecent(entry.url)} @@ -78,6 +81,7 @@ export function PreviewEmptyState({ {servers.map((server) => ( onOpenUrl(server.requestedUrl)} /> diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx new file mode 100644 index 000000000000..d950a99b59fc --- /dev/null +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -0,0 +1,51 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ favicon: null as string | null })); + +vi.mock("~/browserFaviconStore", () => ({ + useFaviconForThreadUrl: () => mocks.favicon, +})); + +import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("preview favicon image", () => { + it("renders a captured source before later fallback sources", () => { + expect( + renderToStaticMarkup( + fallback} + />, + ), + ).toContain('src="data:image/png;base64,AAAA"'); + const captured = "data:image/png;base64,AAAA"; + const google = "https://public.example/icon"; + expect(selectFaviconSource([captured, google], new Set())).toBe(captured); + expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); + expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); + expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( + "data:image/png;base64,BBBB", + ); + }); + + it("uses a stored project icon or falls back to the browser mockup", () => { + mocks.favicon = null; + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(", + ); + expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); + }); +}); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx new file mode 100644 index 000000000000..111facfd82dd --- /dev/null +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -0,0 +1,66 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { type ReactNode, useState } from "react"; + +import { useFaviconForThreadUrl } from "~/browserFaviconStore"; +import { cn } from "~/lib/utils"; + +import { BrowserMockup } from "./BrowserMockup"; + +export function selectFaviconSource( + sources: ReadonlyArray, + failed: ReadonlySet, +): string | null { + return sources.find((candidate) => !failed.has(candidate)) ?? null; +} + +export function FaviconImage(props: { + sources: ReadonlyArray; + fallback: ReactNode; + className?: string | undefined; +}) { + const sources = props.sources.filter((source): source is string => Boolean(source)); + return ( + + ); +} + +function FaviconImageAttempt(props: { + sources: ReadonlyArray; + fallback: ReactNode; + className?: string | undefined; +}) { + const [failed, setFailed] = useState>(() => new Set()); + const source = selectFaviconSource(props.sources, failed); + if (!source) return props.fallback; + return ( + setFailed((current) => new Set(current).add(source))} + /> + ); +} + +export function PreviewFaviconIcon(props: { + threadRef: ScopedThreadRef; + url: string; + className?: string | undefined; +}) { + const source = useFaviconForThreadUrl(props.threadRef, props.url); + const fallback = ; + return ( + + ); +} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index c7b08ad2893d..1e0f01324424 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,12 +1,15 @@ -import { BrowserMockup } from "./BrowserMockup"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; interface Props { + threadRef: ScopedThreadRef; server: PreviewableServer; onOpen: () => void; } -export function PreviewLocalServerCard({ server, onOpen }: Props) { +export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { const subtitle = describeServer(server); return ( ); } function describeServer(server: PreviewableServer): string { if (server.processName) return server.processName; - if (server.listening) return "Listening"; - if (server.source === "configured") return "Configured"; - return "Recently seen"; -} - -function PulsingDot() { - return ( - - - - - ); -} - -function DimDot() { - return ( - - ); + return "Listening"; } diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 723fd59a916c..0805037a14f9 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -713,7 +713,6 @@ export function PreviewView({ threadRef={threadRef} environmentId={threadRef.environmentId} configuredUrls={configuredUrls} - recentlySeenUrls={previewState.recentlySeenUrls} recentEntries={recentHistoryEntries} onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} onOpenUrl={(next) => void handleOpenServerUrl(next)} diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index cdc927140257..ba1846902324 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -1,7 +1,7 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; +import { mergeServers } from "./useDiscoveredLocalServers"; const scannerServer = ( overrides: Partial, @@ -21,7 +21,6 @@ describe("mergeServers", () => { const result = mergeServers({ scanner: [scannerServer({})], configuredUrls: [], - recentlySeenUrls: [], }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ @@ -29,7 +28,6 @@ describe("mergeServers", () => { port: 5173, requestedUrl: "http://localhost:5173", source: "scanner", - listening: true, processName: "vite", }); }); @@ -38,102 +36,111 @@ describe("mergeServers", () => { const result = mergeServers({ scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })], configuredUrls: ["http://localhost:5173"], - recentlySeenUrls: [], }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ port: 5173, source: "configured", - listening: true, processName: "node", pid: 9999, }); }); - it("keeps configured entries that the scanner doesn't see, with listening=false", () => { + it("excludes configured entries that the live scanner doesn't see", () => { const result = mergeServers({ scanner: [], configuredUrls: ["http://localhost:5173"], - recentlySeenUrls: [], }); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - source: "configured", - listening: false, - requestedUrl: "http://localhost:5173/", - }); - }); - - it("dedupes recently-seen URLs against scanner+configured entries", () => { - const result = mergeServers({ - scanner: [scannerServer({ port: 5173 })], - configuredUrls: [], - recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"], - }); - expect(result.map((s) => s.port)).toEqual([5173, 8080]); - expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); - expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); - expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/"); + expect(result).toHaveLength(0); }); - it("ignores non-loopback URLs in configured/recent inputs", () => { + it("ignores non-loopback configured URLs", () => { const result = mergeServers({ - scanner: [], + scanner: [scannerServer({})], configuredUrls: ["https://example.com", "ws://localhost:5173"], - recentlySeenUrls: ["https://api.example.com"], }); - expect(result).toHaveLength(0); + expect(result).toHaveLength(1); + expect(result[0]?.source).toBe("scanner"); }); - it("sorts: configured before scanner before recent, then by port", () => { + it("sorts configured live servers before scanner-only servers", () => { const result = mergeServers({ scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })], - configuredUrls: ["http://localhost:5173"], - recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"], + configuredUrls: ["http://localhost:8080"], }); - expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([ - "configured:5173", - "scanner:3000", - "scanner:8080", - "recent:4321", - "recent:9000", - ]); + expect(result.map((s) => `${s.source}:${s.port}`)).toEqual(["configured:8080", "scanner:3000"]); }); it("dedupes by lowercased host", () => { const result = mergeServers({ scanner: [scannerServer({ host: "Localhost", port: 5173 })], configuredUrls: ["http://localhost:5173"], - recentlySeenUrls: [], }); expect(result).toHaveLength(1); + expect(result[0]?.source).toBe("configured"); }); - it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { + it.each(["127.0.0.1", "0.0.0.0", "[::1]"])( + "matches configured loopback alias %s to a live localhost server", + (host) => { + const result = mergeServers({ + scanner: [ + scannerServer({ requestedUrl: `http://localhost:5173/dashboard?mode=test#results` }), + ], + configuredUrls: [`http://${host}:5173/dashboard?mode=test#results`], + }); + expect(result).toHaveLength(1); + expect(result[0]?.source).toBe("configured"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/dashboard?mode=test#results"); + }, + ); + + it("keeps the scanner-verified path and protocol", () => { const result = mergeServers({ scanner: [ scannerServer({ - port: 5173, url: "https://env-42.example.dev:5173/", - requestedUrl: "http://localhost:5173/", + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", }), ], - configuredUrls: [], - recentlySeenUrls: [], + configuredUrls: ["https://localhost:5173/dashboard?mode=test#results"], }); expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/dashboard?mode=test#results"); + }); + + it("overlays a configured path when an older server does not advertise path probing", () => { + const result = mergeServers({ + scanner: [scannerServer({ requestedUrl: "http://localhost:5173/" })], + configuredUrls: ["https://localhost:5173/docs?mode=test#results"], + configuredUrlProbing: false, + }); + + expect(result[0]?.requestedUrl).toBe("https://localhost:5173/docs?mode=test#results"); + }); + + it("does not overlay an unverified configured path when the server probes paths", () => { + const result = mergeServers({ + scanner: [scannerServer({ requestedUrl: "http://localhost:5173/" })], + configuredUrls: ["http://localhost:5173/docs"], + configuredUrlProbing: true, + }); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); }); -}); -describe("PreviewableServer interface", () => { - it("preserves listening flag through enrichment", () => { + it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { const result = mergeServers({ - scanner: [scannerServer({})], - configuredUrls: ["http://localhost:5173"], - recentlySeenUrls: [], + scanner: [ + scannerServer({ + port: 5173, + url: "https://env-42.example.dev:5173/", + requestedUrl: "http://localhost:5173/", + }), + ], + configuredUrls: [], }); - const merged: PreviewableServer | undefined = result[0]; - expect(merged?.listening).toBe(true); + expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); }); }); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 77491a93c10c..c2907a5b6a6d 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -4,15 +4,10 @@ import { useMemo } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import { useDiscoveredPorts } from "~/portDiscoveryState"; +import { useDiscoveredPortsState } from "~/portDiscoveryState"; export interface PreviewableServer extends DiscoveredLocalServer { - source: "scanner" | "configured" | "recent"; - /** - * True when the port scanner currently sees this server listening. A - * `configured` entry can also be `listening` when the scan enriched it. - */ - listening: boolean; + source: "scanner" | "configured"; /** * Pre-resolution loopback url. `url` is the resolved navigation target * (volatile on a remote environment); history must key off this instead. @@ -23,99 +18,62 @@ export interface PreviewableServer extends DiscoveredLocalServer { interface UseDiscoveredLocalServersInput { environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; - recentlySeenUrls?: ReadonlyArray | undefined; } /** - * Merge the environment-level port snapshot with configured / recently-seen + * Enrich the environment-level live server snapshot with matching configured * URLs and return a stable sorted list. */ export function useDiscoveredLocalServers( input: UseDiscoveredLocalServersInput, ): ReadonlyArray { - const scannerSnapshot = useDiscoveredPorts(input.environmentId); + const scannerState = useDiscoveredPortsState(input.environmentId, input.configuredUrls); return useMemo( () => mergeServers({ - scanner: scannerSnapshot.map((server) => ({ + scanner: scannerState.servers.map((server) => ({ ...server, url: resolveDiscoveredServerUrl(input.environmentId, server.url), requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], - recentlySeenUrls: input.recentlySeenUrls ?? [], + configuredUrlProbing: scannerState.configuredUrlProbing, }), - [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], + [input.environmentId, scannerState, input.configuredUrls], ); } export function mergeServers(input: { scanner: ReadonlyArray; configuredUrls: ReadonlyArray; - recentlySeenUrls: ReadonlyArray; + configuredUrlProbing?: boolean; }): ReadonlyArray { - const seen = new Map(); + const configuredByServer = new Map(); for (const url of input.configuredUrls) { const parsed = parseLocalUrl(url); if (!parsed) continue; const key = canonicalKey(parsed.host, parsed.port); - if (seen.has(key)) continue; - seen.set(key, { - host: parsed.host, - port: parsed.port, - url: parsed.url, - requestedUrl: parsed.url, - processName: null, - pid: null, - terminal: null, - source: "configured", - listening: false, - }); + if (!configuredByServer.has(key)) configuredByServer.set(key, parsed); } + const live: PreviewableServer[] = []; for (const server of input.scanner) { const key = canonicalKey(server.host, server.port); - const existing = seen.get(key); - if (existing) { - // Enrich a configured entry with live process metadata; flip - // `listening` so it pulses green like a scanner-discovered entry. - seen.set(key, { - ...existing, - processName: server.processName ?? existing.processName, - pid: server.pid ?? existing.pid, - terminal: server.terminal ?? existing.terminal, - listening: true, - }); - continue; - } - seen.set(key, { ...server, source: "scanner", listening: true }); - } - - for (const url of input.recentlySeenUrls) { - const parsed = parseLocalUrl(url); - if (!parsed) continue; - const key = canonicalKey(parsed.host, parsed.port); - if (seen.has(key)) continue; - seen.set(key, { - host: parsed.host, - port: parsed.port, - url: parsed.url, - requestedUrl: parsed.url, - processName: null, - pid: null, - terminal: null, - source: "recent", - listening: false, + const configured = configuredByServer.get(key); + live.push({ + ...server, + requestedUrl: + configured && input.configuredUrlProbing === false ? configured.url : server.requestedUrl, + source: configured ? "configured" : "scanner", }); } - return Array.from(seen.values()).toSorted((a, b) => { + return live.toSorted((a, b) => { const sourceOrder: Record = { configured: 0, scanner: 1, - recent: 2, }; if (sourceOrder[a.source] !== sourceOrder[b.source]) { return sourceOrder[a.source] - sourceOrder[b.source]; @@ -125,7 +83,8 @@ export function mergeServers(input: { } function canonicalKey(host: string, port: number): string { - return `${host.toLowerCase()}:${port}`; + const normalizedHost = host.toLowerCase(); + return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`; } function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { diff --git a/apps/web/src/portDiscoveryState.test.ts b/apps/web/src/portDiscoveryState.test.ts new file mode 100644 index 000000000000..82ab1828dc63 --- /dev/null +++ b/apps/web/src/portDiscoveryState.test.ts @@ -0,0 +1,35 @@ +import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { boundConfiguredLocalServerUrls } from "./portDiscoveryState"; + +describe("boundConfiguredLocalServerUrls", () => { + it("keeps subscription payloads within the discovery RPC bounds", () => { + const urls = Array.from( + { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS + 1 }, + (_, index) => `http://localhost:${3_000 + index}`, + ); + urls.unshift( + "https://example.com", + "not a URL", + `http://localhost/${"a".repeat(PREVIEW_URL_MAX_LENGTH)}`, + ); + + const bounded = boundConfiguredLocalServerUrls(urls); + + expect(bounded).toHaveLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS); + expect(bounded.every((url) => url.startsWith("http://localhost:"))).toBe(true); + }); + + it("does not let fragment-only variants crowd out another server", () => { + const fragments = Array.from( + { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS }, + (_, index) => `http://localhost:3000/docs#section-${index}`, + ); + + expect(boundConfiguredLocalServerUrls([...fragments, "http://localhost:4000/app"])).toEqual([ + "http://localhost:3000/docs#section-0", + "http://localhost:4000/app", + ]); + }); +}); diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index 014d220860de..d0a701766dd1 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -1,4 +1,11 @@ -import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, + PREVIEW_URL_MAX_LENGTH, + type DiscoveredLocalServer, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; import { useMemo } from "react"; import { previewEnvironment } from "./state/preview"; @@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query"; const EMPTY_PORTS: ReadonlyArray = Object.freeze([]); +interface DiscoveredPortsState { + readonly servers: ReadonlyArray; + readonly configuredUrlProbing: boolean; +} + +export function boundConfiguredLocalServerUrls( + urls: ReadonlyArray | undefined, +): ReadonlyArray { + const bounded: string[] = []; + const seen = new Set(); + for (const raw of urls ?? []) { + if (raw.length === 0 || raw.length > PREVIEW_URL_MAX_LENGTH || raw.trim().length !== raw.length) + continue; + try { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + if (!isLoopbackHost(url.hostname) || url.href.length > PREVIEW_URL_MAX_LENGTH) continue; + const resourceUrl = new URL(url.href); + resourceUrl.hash = ""; + if (seen.has(resourceUrl.href)) continue; + seen.add(resourceUrl.href); + bounded.push(url.href); + if (bounded.length >= CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) break; + } catch { + // Invalid and non-local project preview URLs are not discovery candidates. + } + } + return bounded; +} + export function useDiscoveredPorts( environmentId: EnvironmentId | null, + configuredUrls?: ReadonlyArray, ): ReadonlyArray { + return useDiscoveredPortsState(environmentId, configuredUrls).servers; +} + +export function useDiscoveredPortsState( + environmentId: EnvironmentId | null, + configuredUrls?: ReadonlyArray, +): DiscoveredPortsState { + const boundedConfiguredUrls = boundConfiguredLocalServerUrls(configuredUrls); const query = useEnvironmentQuery( environmentId === null ? null - : previewEnvironment.discoveredServers({ environmentId, input: {} }), + : previewEnvironment.discoveredServers({ + environmentId, + input: boundedConfiguredUrls.length ? { configuredUrls: boundedConfiguredUrls } : {}, + }), + ); + return useMemo( + () => ({ + servers: query.data?.servers ?? EMPTY_PORTS, + configuredUrlProbing: query.data?.configuredUrlProbing === true, + }), + [query.data?.configuredUrlProbing, query.data?.servers], ); - return query.data?.servers ?? EMPTY_PORTS; } export function useThreadDiscoveredPorts(input: { diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index f9469ee96a5f..86ca157047ba 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -41,6 +41,9 @@ export function createPreviewEnvironmentAtoms( discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:preview:discovered-servers", tag: WS_METHODS.subscribeDiscoveredLocalServers, + // Configured URLs are part of this atom's key. Dispose immediately so + // unmounted projects stop contributing probe candidates on the server. + idleTtlMs: 0, }), automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:preview:automation-requests", diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 09a13cd31da1..24f429745ef8 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -2,7 +2,10 @@ import { Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { + ConfiguredLocalServerUrls, + CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, DiscoveredLocalServer, + PREVIEW_URL_MAX_LENGTH, PreviewEvent, PreviewNavStatus, PreviewSessionSnapshot, @@ -21,6 +24,7 @@ const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); +const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls); const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); @@ -342,3 +346,19 @@ describe("DiscoveredLocalServer", () => { ).toThrow(); }); }); + +describe("ConfiguredLocalServerUrls", () => { + it("bounds the number and length of probe candidates", () => { + expect(() => + decodeConfiguredLocalServerUrls( + Array.from( + { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS + 1 }, + (_, index) => `http://localhost:${3_000 + index}`, + ), + ), + ).toThrow(); + expect(() => + decodeConfiguredLocalServerUrls([`http://localhost/${"a".repeat(PREVIEW_URL_MAX_LENGTH)}`]), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index dfc10e0b9b7a..b8c5741a69dd 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -11,7 +11,14 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +export const PREVIEW_URL_MAX_LENGTH = 2_048; +export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; + +const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(PREVIEW_URL_MAX_LENGTH)); + +export const ConfiguredLocalServerUrls = Schema.Array(Url).check( + Schema.isMaxLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS), +); const Title = Schema.String.check(Schema.isMaxLength(512)); export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); @@ -272,6 +279,7 @@ export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; export const DiscoveredLocalServerList = Schema.Struct({ servers: Schema.Array(DiscoveredLocalServer), scannedAt: Schema.String, + configuredUrlProbing: Schema.optional(Schema.Literal(true)), }); export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b5bd91cad59c..115fc8a13114 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -129,6 +129,7 @@ import { } from "./terminal.ts"; import { DiscoveredLocalServerList, + ConfiguredLocalServerUrls, PreviewCloseInput, PreviewError, PreviewEvent, @@ -849,7 +850,9 @@ export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewE export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( WS_METHODS.subscribeDiscoveredLocalServers, { - payload: Schema.Struct({}), + payload: Schema.Struct({ + configuredUrls: Schema.optional(ConfiguredLocalServerUrls), + }), success: DiscoveredLocalServerList, error: EnvironmentAuthorizationError, stream: true, From 7e01d33f0eeb9435299791392d546756cc09c5d3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 13 Aug 2026 22:07:57 -0500 Subject: [PATCH 021/144] perf(build): stop unpacking node_modules wholesale from the Windows asar (#5877) Co-authored-by: tsouth89 Co-authored-by: t3-code[bot] Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> --- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 34 +- apps/server/vite.config.ts | 29 +- scripts/build-desktop-artifact.test.ts | 86 ++++ scripts/build-desktop-artifact.ts | 425 +++++++++++++++++- scripts/lib/cli-external-packages.test.ts | 275 ++++++++++++ scripts/lib/cli-external-packages.ts | 156 +++++++ 6 files changed, 974 insertions(+), 31 deletions(-) create mode 100644 scripts/lib/cli-external-packages.test.ts create mode 100644 scripts/lib/cli-external-packages.ts diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500b..164117727eaa 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -229,15 +229,18 @@ const NODE_PTY_PROBE_SCRIPT = ( printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 -// The server bundle externalizes its deps to node_modules, and the WSL Node -// can't read inside app.asar, so confirm those deps are unpacked on the real -// filesystem before reporting the backend healthy. "effect" is the framework -// every server module imports; resolving it validates the whole node_modules -// tree. Exit 3 marks this distinct from a node-pty problem so the caller can -// report it accurately instead of letting the server crash on -// ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to -// launch with no fallback). -try { require.resolve("effect"); } catch (_e) { process.exit(3); } +// The WSL Node can't read inside app.asar, so confirm what the server needs is +// unpacked on the real filesystem before reporting the backend healthy. Exit 3 +// marks this distinct from a node-pty prebuild problem so the caller can report +// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at +// launch (which, in wsl-only mode, would just fail to launch with no fallback). +// +// The sentinel must be a package the CLI bundle leaves external. It used to be +// "effect", back when the bundle externalized its runtime deps and the whole +// node_modules tree was unpacked. The bundle now inlines its JS dependencies, +// so "effect" no longer exists on disk and only the native packages do — +// resolving node-pty is what actually validates the unpacked tree. +try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); @@ -462,16 +465,17 @@ const ensureNodePtyImpl = ( } as const; } - // Server dependencies (e.g. "effect") couldn't be resolved on the WSL - // filesystem — a packaging regression, since the server bundle needs its - // node_modules unpacked from the asar. Fatal so wsl-only mode falls back to - // Windows and dual mode surfaces the reason inline, instead of the server - // crash-looping on ERR_MODULE_NOT_FOUND once it actually launches. + // The packages the server bundle leaves external (node-pty and the other + // native addons) couldn't be resolved on the WSL filesystem — a packaging + // regression, since those must be unpacked from the asar. Fatal so wsl-only + // mode falls back to Windows and dual mode surfaces the reason inline, + // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it + // actually launches. if (probe.exitCode === 3) { return { ok: false, reason: - "WSL server dependencies could not be loaded (for example \"effect\"). The server's bundled node_modules is not readable by the WSL distro's Node — this is a packaging problem with this build. Please report it.", + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', fatal: true, } as const; } diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279f..647af2a889d5 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,16 +5,20 @@ import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; -const bundledPackagePrefixes = [ - "@pierre/diffs", - "@t3tools/", - "effect-acp", - "effect-codex-app-server", -]; +// The bundle used to inline only workspace packages, leaving every third-party +// runtime dep external. External deps must exist on the real filesystem (the WSL +// backend runs plain `wsl.exe -- node`, which cannot read inside an asar), so the +// desktop build unpacked `**\/node_modules\/**` wholesale: 13,875 loose files to +// support 20 native binaries. NSIS install time tracks file count, not bytes. +// +// Inverted here — bundle everything except the packages that genuinely cannot be +// inlined. See scripts/lib/cli-external-packages.ts for what earns an exemption. +import { + isExternalCliDependency, + shouldBundleCliDependency, +} from "../../scripts/lib/cli-external-packages.ts"; -export function shouldBundleCliDependency(id: string): boolean { - return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); -} +export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; @@ -37,7 +41,14 @@ export default mergeConfig( sourcemap: true, clean: true, deps: { + // Both halves are required. `alwaysBundle` forces the JS dependencies in + // (declared deps are external by default, which is what this change is + // undoing). `neverBundle` forces the native packages out: returning + // false from `alwaysBundle` only means "no opinion", so a transitive + // dependency would still be bundled — which silently inlined + // msgpackr-extract and its loader, losing native acceleration. alwaysBundle: shouldBundleCliDependency, + neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, }, banner: { diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 7d2b7410a9e7..6b04d6587086 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -43,6 +45,8 @@ import { stageLinuxIconSize, STAGE_INSTALL_ARGS, WINDOWS_ASAR_UNPACK, + ancestorNodeModulesPaths, + copyDirectoryPreservingSymlinks, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -767,3 +771,85 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); }); + +// The self-containment check runs the packaged tree in a scratch directory. Its +// own node_modules holds the unpacked externals and must be ignored, but any +// node_modules *above* it would let Node's parent walk satisfy an import that is +// missing from the package, so the probe refuses to run in that case. +it("lists ancestor node_modules, nearest first, excluding the start directory", () => { + assert.deepStrictEqual(ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), [ + "C:\\tmp\\probe\\node_modules", + "C:\\tmp\\node_modules", + "C:\\node_modules", + ]); +}); + +it("includes the filesystem root for posix paths", () => { + assert.deepStrictEqual(ancestorNodeModulesPaths("/tmp/probe", "/"), [ + "/tmp/node_modules", + "/node_modules", + ]); +}); + +// A UNC root must keep its \\server\share prefix. Rebuilding it from segments +// produced relative paths, which fs.exists resolves against the build cwd, so +// the guard checked directories that do not exist and silently passed. +it("keeps the prefix of a UNC path instead of going relative", () => { + const paths = ancestorNodeModulesPaths("\\\\server\\share\\tmp\\app", "\\"); + for (const candidate of paths) { + assert.ok(candidate.startsWith("\\\\server\\share"), candidate); + } + assert.deepStrictEqual(paths[0], "\\\\server\\share\\tmp\\node_modules"); +}); + +it.effect("rebases packaged links into the isolated tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-copy-symlinks-" }); + const source = path.join(root, "source"); + const destination = path.join(root, "destination"); + const packageDir = path.join(source, "node_modules/.pnpm/example@1/node_modules/example"); + const relativePackageLink = path.join(source, "node_modules/example-relative"); + const absolutePackageLink = path.join(source, "node_modules/example-absolute"); + + yield* fs.makeDirectory(packageDir, { recursive: true }); + yield* fs.writeFileString(path.join(packageDir, "index.js"), "module.exports = true;\n"); + yield* fs.symlink( + path.join(".pnpm", "example@1", "node_modules", "example"), + relativePackageLink, + ); + yield* fs.symlink(packageDir, absolutePackageLink); + + yield* copyDirectoryPreservingSymlinks(source, destination); + + const copiedPackage = path.join( + destination, + "node_modules/.pnpm/example@1/node_modules/example", + ); + const resolvedCopiedPackage = yield* fs.realPath(copiedPackage); + assert.equal( + yield* fs.readLink(path.join(destination, "node_modules/example-relative")), + copiedPackage, + ); + assert.equal( + yield* fs.readLink(path.join(destination, "node_modules/example-absolute")), + copiedPackage, + ); + assert.equal( + yield* fs.realPath(path.join(destination, "node_modules/example-relative")), + resolvedCopiedPackage, + ); + assert.equal( + yield* fs.realPath(path.join(destination, "node_modules/example-absolute")), + resolvedCopiedPackage, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it("ignores trailing separators", () => { + assert.deepStrictEqual( + ancestorNodeModulesPaths("C:\\tmp\\probe\\app\\", "\\"), + ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), + ); +}); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a30b6d4a90a2..c86f0c38cb52 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated. +import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -17,17 +19,23 @@ import { type WebAssetBrand, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + findInlinedExternalPackages, +} from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import type { PlatformError } from "effect/PlatformError"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -375,6 +383,52 @@ const desktopBuildInputArtifactNames = { "bundled-server-client": "bundled server client", } satisfies Record; +/** + * Imported by every server module, so it is inlined in any correctly bundled + * build. Its absence means the bundle went back to externalizing its + * dependencies, which the unpack globs do not cover. + */ +const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; + +const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); + +export class ExternalizedBundleError extends Schema.TaggedErrorClass()( + "ExternalizedBundleError", + { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, +) { + override get message(): string { + return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; + } +} + +export class BundleNotSelfContainedError extends Schema.TaggedErrorClass()( + "BundleNotSelfContainedError", + { exitCode: Schema.Number, output: Schema.String }, +) { + override get message(): string { + return `The packaged server bundle could not load with only its unpacked dependencies present (exit ${this.exitCode}). Anything it imports that is neither a Node built-in nor an unpacked external is unreachable to the WSL backend, which runs plain node and cannot read app.asar. Output: +${this.output}`; + } +} + +export class InlinedNativePackageError extends Schema.TaggedErrorClass()( + "InlinedNativePackageError", + { packages: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `The server bundle inlined packages that load native binaries: ${this.packages.join(", ")}. A node-gyp-build style loader resolves prebuilds relative to its own file, so inlined into a chunk it finds none and the importer quietly falls back to a slower JS path. Add them to CLI_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/cli-external-packages.ts so they stay external and get unpacked.`; + } +} + +export class InlinedExternalPackageError extends Schema.TaggedErrorClass()( + "InlinedExternalPackageError", + { packages: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `The server bundle inlined packages that must stay external: ${this.packages.join(", ")}. These are native addons or their loaders; inlined, they resolve prebuilds relative to the bundle and silently lose native acceleration. Check the deps.neverBundle wiring in apps/server/vite.config.ts.`; + } +} + export class MissingDesktopBuildInputError extends Schema.TaggedErrorClass()( "MissingDesktopBuildInputError", { @@ -633,13 +687,21 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", ] as const; -// The WSL backend launches the server with plain `wsl.exe -- node`, which -// cannot read inside an asar archive — and the server bundle externalizes its -// runtime deps, so the whole node_modules tree must be unpacked, not just the -// bundle (otherwise ERR_MODULE_NOT_FOUND: "Cannot find package 'effect'"). -// The Windows primary backend reads the same files through the asar redirect, -// so nothing is duplicated. -export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const; +// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot +// read inside an asar archive, so everything it loads must be on the real +// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the +// server bundle externalized its runtime deps and the Linux Node would fail with +// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached +// node-pty. +// +// The CLI bundle now inlines its JS dependencies, so the only things that still +// have to be loose are the server bundle itself and the packages the bundle +// leaves external — derived from the same list the bundler uses, so the two +// cannot drift apart. +export const WINDOWS_ASAR_UNPACK = [ + "apps/server/dist/**", + ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, +] as const; export const DESKTOP_EXTRA_RESOURCES = [ { from: "apps/desktop/prod-resources/resource-monitor", @@ -1178,6 +1240,278 @@ const runCommand = Effect.fn("runCommand")(function* ( } }); +/** + * Every `node_modules` directory that would be visible from `startDir`. + * + * The self-containment check is only meaningful in a directory with none of + * these: Node walks parents when resolving a bare import, so a stray + * node_modules above the probe would satisfy imports that are missing from the + * packaged tree and turn the check into a silent pass. + */ +function trimTrailingSeparators(value: string): string { + let end = value.length; + while (end > 1 && (value[end - 1] === "/" || value[end - 1] === "\\")) end -= 1; + return value.slice(0, end); +} + +/** + * Length of the `\\server\share` prefix, or 0 when the path is not UNC. + * + * The share is the highest real directory on a UNC path: `\\server` on its own + * is not one, so the ancestor walk must stop there. + */ +function uncShareRootLength(value: string): number { + const isUnc = value.startsWith("\\\\") || value.startsWith("//"); + if (!isUnc) return 0; + const separator = /[\\/]/; + const serverEnd = value.slice(2).search(separator); + if (serverEnd < 0) return value.length; + const shareStart = 2 + serverEnd + 1; + const shareEnd = value.slice(shareStart).search(separator); + return shareEnd < 0 ? value.length : shareStart + shareEnd; +} + +export function ancestorNodeModulesPaths( + startDir: string, + separator: string, +): ReadonlyArray { + // Walks with lastIndexOf rather than splitting into segments so UNC roots + // (\\server\share) and drive roots keep their prefix instead of being + // rebuilt into a relative path that silently resolves against the build cwd. + const paths: string[] = []; + let current = trimTrailingSeparators(startDir); + // On a UNC path the share itself is the root: \\server is not a directory, so + // walking past \\server\share would emit paths that cannot exist. + const uncRootLength = uncShareRootLength(current); + for (;;) { + const cut = Math.max(current.lastIndexOf("/"), current.lastIndexOf("\\")); + if (cut < 0 || (uncRootLength > 0 && cut < uncRootLength)) break; + const parent = cut === 0 ? current.slice(0, 1) : current.slice(0, cut); + if (parent === current) break; + paths.push( + parent.endsWith(separator) ? `${parent}node_modules` : `${parent}${separator}node_modules`, + ); + if (cut === 0) break; + current = parent; + } + return paths; +} + +const NativeMarkerManifest = Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); +const decodeNativeMarkerManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(NativeMarkerManifest), +); + +/** Locate a package inside the pnpm store, which is where the real files live. */ +const findStorePackageDirectory = Effect.fn("findStorePackageDirectory")(function* ( + repoRoot: string, + packageName: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const storeDir = path.join(repoRoot, "node_modules/.pnpm"); + const exists = (candidate: string) => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!(yield* exists(storeDir))) return null; + + const flattened = `${packageName.replace("/", "+")}@`; + const entries = yield* fs + .readDirectory(storeDir) + .pipe(Effect.orElseSucceed(() => [] as string[])); + for (const entry of entries) { + if (!entry.startsWith(flattened)) continue; + const candidate = path.join(storeDir, entry, "node_modules", packageName); + if (yield* exists(candidate)) return candidate; + } + return null; +}); + +/** Whether a package builds or ships a native addon it loads at runtime. */ +const hasNativeLoaderMarkers = Effect.fn("hasNativeLoaderMarkers")(function* (packageDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = (candidate: string) => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + + if (yield* exists(path.join(packageDir, "binding.gyp"))) return true; + if (yield* exists(path.join(packageDir, "prebuilds"))) return true; + + const manifestPath = path.join(packageDir, "package.json"); + if (!(yield* exists(manifestPath))) return false; + const source = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => "")); + if (source === "") return false; + const manifest = yield* Effect.try(() => decodeNativeMarkerManifest(source)).pipe( + Effect.orElseSucceed(() => null), + ); + if (manifest === null) return false; + return Object.keys({ ...manifest.dependencies, ...manifest.optionalDependencies }).some( + (dependency) => dependency.startsWith("node-gyp-build"), + ); +}); + +export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservingSymlinks")( + function* (source: string, destination: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Effect's Node implementation delegates directory copies to fs.cp, whose + // default rewrites links into absolute source-tree references. Recreate every + // in-tree directory link as a junction rooted in the isolated copy so the + // probe cannot resolve through staging and Windows needs no symlink privilege. + yield* fs.copy(source, destination); + + const restoreRelativeSymlinks = ( + sourceDirectory: string, + destinationDirectory: string, + ): Effect.Effect => + Effect.gen(function* () { + for (const entry of yield* fs.readDirectory(sourceDirectory)) { + const sourceEntry = path.join(sourceDirectory, entry); + const destinationEntry = path.join(destinationDirectory, entry); + const linkTarget = yield* fs.readLink(sourceEntry).pipe(Effect.option); + if (Option.isSome(linkTarget)) { + const absoluteSourceTarget = path.isAbsolute(linkTarget.value) + ? linkTarget.value + : path.resolve(path.dirname(sourceEntry), linkTarget.value); + const sourceRelativeTarget = path.relative(source, absoluteSourceTarget); + if ( + sourceRelativeTarget === ".." || + sourceRelativeTarget.startsWith(`..${path.sep}`) || + path.isAbsolute(sourceRelativeTarget) + ) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `Refusing to copy symlink ${sourceEntry}: its target ${absoluteSourceTarget} escapes the packaged tree.`, + }); + } + const target = path.join(destination, sourceRelativeTarget); + yield* fs.remove(destinationEntry, { recursive: true, force: true }); + yield* Effect.tryPromise({ + try: () => NodeFSP.symlink(target, destinationEntry, "junction"), + catch: (cause) => + new BundleNotSelfContainedError({ + exitCode: -1, + output: `Could not isolate ${sourceEntry}: ${String(cause)}`, + }), + }); + } else { + const info = yield* fs.stat(sourceEntry); + if (info.type === "Directory") { + yield* restoreRelativeSymlinks(sourceEntry, destinationEntry); + } + } + } + }); + + yield* restoreRelativeSymlinks(source, destination); + }, +); + +const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( + function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // electron-builder names this win-unpacked, win-arm64-unpacked, and so on. + const distEntries = yield* fs + .readDirectory(input.stageDistDir) + .pipe(Effect.orElseSucceed(() => [] as Array)); + let unpackedRoot: string | null = null; + for (const entry of distEntries) { + const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked"); + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + unpackedRoot = candidate; + break; + } + } + // Nothing to verify rather than silently passing: a packaging layout change + // should surface here instead of turning the check into a no-op. + if (unpackedRoot === null) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`, + }); + } + + const probeRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-bundle-selfcheck-", + }); + const probeApp = path.join(probeRoot, "app"); + yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp); + + // Guard the guard: if anything above the probe provides a node_modules, a + // missing dependency would resolve there and the check would pass while the + // packaged tree is broken. + for (const candidate of ancestorNodeModulesPaths(probeApp, path.sep)) { + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `Refusing to report success: ${candidate} is visible from the probe directory, so bare imports could resolve outside the packaged tree. Remove or rename it, or point TMPDIR somewhere without one.`, + }); + } + } + + const entryPoint = path.join(probeApp, "apps/server/dist/bin.mjs"); + if (!(yield* fs.exists(entryPoint).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new BundleNotSelfContainedError({ + exitCode: -1, + output: `Expected the server entry at ${entryPoint}.`, + }); + } + + // --version exercises the eagerly loaded module graph, which is where a + // missing dependency shows up, without starting a server or touching disk + // state. It does not cover lazily imported externals: node-pty is checked + // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node + // and the bun adapters are only covered by the unpack globs and the + // inlined-native check below. + yield* runCommand( + ChildProcess.make( + process.execPath, + // --no-global-search-paths because clearing NODE_PATH is not enough: + // CommonJS resolution still falls back to $HOME/.node_modules, + // $HOME/.node_libraries and the install prefix, so a globally installed + // copy of a missing dependency would quietly satisfy this check. + ["--no-global-search-paths", entryPoint, "--version"], + { + cwd: probeApp, + stdout: "pipe", + stderr: "pipe", + // NODE_PATH would let a createRequire call inside the bundle resolve + // a missing external from outside the packaged tree, which is the + // whole thing this is trying to rule out. + env: { ...process.env, NODE_PATH: "" }, + }, + ), + { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, + ).pipe( + // Printing a version should be immediate. A regression that blocks (on + // stdin, a port, a lock) would otherwise hang release CI until the job + // times out with nothing useful in the log. + Effect.timeout(BUNDLE_SELF_CHECK_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: -1, + output: `The packaged bundle did not print its version within ${Duration.toSeconds(BUNDLE_SELF_CHECK_TIMEOUT)}s; it is hanging rather than failing to resolve.`, + }), + ), + ), + Effect.catchTag("BuildCommandFailedError", (error) => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + ), + ); + }, +); + const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { readonly repoRoot: string; readonly stageResourcesDir: string; @@ -1817,6 +2151,68 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } } + // Assert against the emitted bundle, not the bundler config. `alwaysBundle` + // only forces packages IN, so a transitive dependency of an external package + // is bundled by default however the predicate is written — that silently + // inlined msgpackr-extract and its native loader while every list-based test + // still passed. An inlined native loader resolves its prebuilds relative to + // the bundle and quietly falls back to a slower pure-JS path, so this fails + // the build rather than shipping a silent regression. + { + const chunkNames = (yield* fs.readDirectory(distDirs.serverDist)).filter((entry) => + entry.endsWith(".mjs"), + ); + let totalRegions = 0; + const inlined = new Set(); + const inlinedPackages = new Set(); + for (const chunkName of chunkNames) { + const source = yield* fs.readFileString(path.join(distDirs.serverDist, chunkName)); + const scan = findInlinedExternalPackages(source); + totalRegions += scan.regionCount; + for (const name of scan.inlined) inlined.add(name); + for (const name of scan.inlinedPackages) inlinedPackages.add(name); + } + if (inlined.size > 0) { + return yield* new InlinedExternalPackageError({ + packages: [...inlined].sort(), + }); + } + // No regions at all means the scan went blind (marker format changed), not + // that the bundle is clean. + if (totalRegions === 0) { + return yield* new InlinedExternalPackageError({ + packages: [""], + }); + } + // The check above is one-directional: it only proves nothing external got + // inlined. A regression to externalizing everything would also pass it, + // since source-file regions still exist -- and that is the failure this + // whole change exists to prevent, because those packages are not in the + // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. + // `effect` is imported by every server module, so it is inlined in any + // correctly bundled build. + // The list-based check above only sees packages someone already thought to + // list. bufferutil and utf-8-validate were inlined for exactly that reason: + // native, but absent from the list, so nothing flagged them. Ask the store + // what each inlined package actually is instead. + const nativeInlined: string[] = []; + for (const name of [...inlinedPackages].sort()) { + const packageDir = yield* findStorePackageDirectory(repoRoot, name); + if (packageDir === null) continue; + if (yield* hasNativeLoaderMarkers(packageDir)) nativeInlined.push(name); + } + if (nativeInlined.length > 0) { + return yield* new InlinedNativePackageError({ packages: nativeInlined }); + } + + if (!inlinedPackages.has(BUNDLE_SELF_CONTAINED_SENTINEL)) { + return yield* new ExternalizedBundleError({ + sentinel: BUNDLE_SELF_CONTAINED_SENTINEL, + inlinedPackageCount: inlinedPackages.size, + }); + } + } + if (!(yield* fs.exists(bundledClientEntry))) { return yield* new MissingDesktopBuildInputError({ artifact: "bundled-server-client", @@ -2056,6 +2452,21 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } + // Prove the packaged bundle is self-contained by loading it the way the WSL + // backend does, rather than by reasoning about the emitted source. + // + // Static analysis kept getting this wrong here. Scanning for bare imports + // matched specifiers inside effect's JSDoc examples and inside ajv's runtime + // codegen template, and asserting that one sentinel package was inlined + // missed a build that inlined `effect` while leaving `yaml` external. Node's + // resolver has no such ambiguity: it either finds every import or it does not. + // + // Only Windows unpacks anything; macOS and Linux keep the whole tree inside + // the asar, where this check has nothing to look at. + if (options.platform === "win") { + yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose }); + } + const stageEntries = yield* fs.readDirectory(stageDistDir); yield* fs.makeDirectory(options.outputDir, { recursive: true }); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts new file mode 100644 index 000000000000..189634dfee61 --- /dev/null +++ b/scripts/lib/cli-external-packages.test.ts @@ -0,0 +1,275 @@ +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + CLI_EXTERNAL_PACKAGE_PREFIXES, + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + CLI_RUNTIME_EXTERNAL_PREFIXES, + findInlinedExternalPackages, + shouldBundleCliDependency, +} from "./cli-external-packages.ts"; + +// Only the field this test cares about; decoding ignores everything else. +// optionalDependencies matter as much as dependencies here: every native family +// in the list declares its actual platform bindings there (ffi-rs -> @yuuang/*, +// msgpackr-extract -> @msgpackr-extract/*, fff-node -> @ff-labs/fff-bin-*), so +// reading only `dependencies` would check nothing for exactly those packages. +const PackageManifest = Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); +type PackageManifest = typeof PackageManifest.Type; + +const decodeManifest = Schema.decodeUnknownSync(Schema.fromJsonString(PackageManifest)); + +describe("shouldBundleCliDependency", () => { + it("bundles ordinary runtime dependencies", () => { + for (const id of ["effect", "@effect/platform", "hono", "@t3tools/shared/hostProcess"]) { + assert.strictEqual(shouldBundleCliDependency(id), true, id); + } + }); + + it("never bundles node: builtins", () => { + assert.strictEqual(shouldBundleCliDependency("node:fs"), false); + }); + + it("leaves native addons and their dlopen wrappers external", () => { + for (const id of [ + "node-pty", + "ffi-rs", + "@yuuang/ffi-rs-win32-x64-msvc", + "@ff-labs/fff-node", + "@clerk/electron-passkeys", + "msgpackr-extract", + "@msgpackr-extract/msgpackr-extract-win32-x64", + ]) { + assert.strictEqual(shouldBundleCliDependency(id), false, id); + } + }); + + it("leaves bun-only entry points external", () => { + assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false); + assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false); + }); + + // The real package is `node-gyp-build-optional-packages`, reached by prefix. + // Matching it as external while failing to unpack it is invisible on the + // Windows primary (which reads app.asar) and breaks only under WSL. + it("treats prefix-matched siblings as external", () => { + assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); + }); +}); + +describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { + it("unpacks every external prefix from both the top level and the pnpm store", () => { + for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) { + assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix); + assert.include( + CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, + `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`, + prefix, + ); + } + }); + + // Without the trailing `*` the globs stop covering prefix-matched siblings, + // which is exactly how a package ends up external but not unpacked. + it("keeps the trailing wildcard that matches prefix siblings", () => { + assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); + }); +}); + +// The failure this guards is invisible on Windows and fatal under WSL. +// +// An external package is loaded from the real filesystem, so its own `require` +// also resolves from the real filesystem. If one of its dependencies was +// bundled away instead of left external, that dependency exists only inside +// app.asar — which the Windows primary reads transparently under +// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot. +// +// Found the hard way: node-gyp-build-optional-packages requires detect-libc, +// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. +it.layer(NodeServices.layer)("external package dependency closure", (it) => { + // Read manifests off disk from the pnpm store rather than resolving them. + // `require("/package.json")` cannot do this job: under pnpm isolation a + // transitive package (detect-libc, msgpackr-extract, ffi-rs) is not reachable + // by name from this file at all, and an `exports` map can refuse the + // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not + // installed", which would let this test skip everything and pass while + // checking nothing. The store is also what asarUnpack globs target, so this + // reads the same tree the build packages. + const readInstalledPackages = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const storeDir = path.resolve( + path.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../../node_modules/.pnpm", + ); + + // The store holds regular files too (lock.yaml), so a path built under one + // raises ENOTDIR rather than reporting absence. That throws on Linux while + // Windows quietly returns false, which is exactly the kind of difference + // this test exists to catch, so treat any failure as "not there". + const isPresent = (candidate: string) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + + const installed = new Map(); + if (!(yield* isPresent(storeDir))) return installed; + + for (const entry of yield* fileSystem.readDirectory(storeDir)) { + const modulesDir = path.join(storeDir, entry, "node_modules"); + if (!(yield* isPresent(modulesDir))) continue; + + for (const owner of yield* fileSystem.readDirectory(modulesDir)) { + const names = owner.startsWith("@") + ? (yield* fileSystem.readDirectory(path.join(modulesDir, owner))).map( + (scoped) => `${owner}/${scoped}`, + ) + : [owner]; + + for (const name of names) { + if (installed.has(name)) continue; + const manifestPath = path.join(modulesDir, name, "package.json"); + if (!(yield* isPresent(manifestPath))) continue; + installed.set(name, decodeManifest(yield* fileSystem.readFileString(manifestPath))); + } + } + } + return installed; + }).pipe(Effect.cached, Effect.runSync); + + // Runtime-external only. The build-only entries resolve `bun:*` and are never + // loaded by Node, so their closure genuinely does not need to be external. + const isRuntimeExternal = (name: string) => + CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => name.startsWith(prefix)); + + it.effect("finds the runtime-external packages on disk", () => + Effect.gen(function* () { + const installed = yield* readInstalledPackages; + const found = [...installed.keys()].filter(isRuntimeExternal); + + // Without this the closure check below can pass vacuously: if nothing is + // read, nothing is checked. These are the packages whose closure actually + // broke WSL, so require them by name. + for (const required of ["node-pty", "node-gyp-build-optional-packages", "detect-libc"]) { + assert.ok( + found.includes(required), + `expected ${required} in the pnpm store; the closure check is only meaningful if it can read these (found ${found.length})`, + ); + } + }), + ); + + it.effect("keeps every runtime dependency of an external package external too", () => + Effect.gen(function* () { + const installed = yield* readInstalledPackages; + const violations: string[] = []; + const seen = new Set(); + // Seeded from what is actually installed and matches a prefix, so scoped + // prefixes like "@yuuang/" and "@ff-labs/" are covered too. Seeding from + // the prefix strings themselves would skip every scoped entry, since a + // prefix is not a package name. + const queue = [...installed.keys()].filter(isRuntimeExternal); + + for (const name of queue) { + if (seen.has(name)) continue; + seen.add(name); + + const manifest = installed.get(name); + if (!manifest) continue; + + const declared = { + ...(manifest.dependencies ?? {}), + ...(manifest.optionalDependencies ?? {}), + ...(manifest.peerDependencies ?? {}), + }; + for (const dependency of Object.keys(declared)) { + if (!isRuntimeExternal(dependency)) { + violations.push(`${name} -> ${dependency}`); + } + if (!seen.has(dependency)) queue.push(dependency); + } + } + + assert.deepStrictEqual( + violations, + [], + `these dependencies of external packages would be bundled away and fail to resolve under WSL: ${violations.join(", ")}`, + ); + }), + ); +}); + +// Configuring the bundler is not the same as checking what it emitted. These +// exercise the scanner against the marker shape rolldown actually produces. +describe("findInlinedExternalPackages", () => { + const region = (path: string) => `//#region ${path} +var x = 1; +//#endregion +`; + + it("flags an external package that was inlined", () => { + const source = + region("../../node_modules/.pnpm/detect-libc@2.1.2/node_modules/detect-libc/lib/process.js") + + region( + "../../node_modules/.pnpm/msgpackr-extract@3.0.4/node_modules/msgpackr-extract/index.js", + ); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlined, ["detect-libc", "msgpackr-extract"]); + assert.strictEqual(result.regionCount, 2); + }); + + it("flags scoped external packages", () => { + const result = findInlinedExternalPackages( + region("../../node_modules/@ff-labs/fff-node/dist/src/index.js"), + ); + assert.deepStrictEqual(result.inlined, ["@ff-labs/fff-node"]); + }); + + it("ignores packages that are meant to be bundled", () => { + const source = + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") + + region("../../src/server/main.ts"); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlined, []); + assert.strictEqual(result.regionCount, 2); + }); + + // regionCount is what separates "clean" from "this scan went blind because the + // marker format changed". A caller that ignores it gets a vacuous pass. + // The scan has to answer both directions. Checking only that externals are + // absent still passes on a bundle that externalized everything, which is the + // failure this whole change prevents. + it("reports the packages that were inlined, not just the violations", () => { + const source = + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") + + region("../../node_modules/.pnpm/yaml@2.4.0/node_modules/yaml/dist/index.js") + + region("../../src/server/main.ts"); + const result = findInlinedExternalPackages(source); + + assert.deepStrictEqual(result.inlinedPackages, ["effect", "yaml"]); + assert.deepStrictEqual(result.inlined, []); + }); + + it("does not report the pnpm store directory as a package", () => { + const result = findInlinedExternalPackages( + region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js"), + ); + assert.deepStrictEqual(result.inlinedPackages, ["effect"]); + }); + + it("reports no regions when the marker format is absent", () => { + const result = findInlinedExternalPackages("var x = 1; // node_modules/detect-libc/lib.js"); + assert.strictEqual(result.regionCount, 0); + assert.deepStrictEqual(result.inlined, []); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts new file mode 100644 index 000000000000..f50718af4fed --- /dev/null +++ b/scripts/lib/cli-external-packages.ts @@ -0,0 +1,156 @@ +/** + * The single source of truth for packages the server CLI bundle must NOT inline. + * + * Two consumers derive from this list, and they must never disagree: + * + * - apps/server/vite.config.ts decides what stays external to the bundle. + * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar. + * + * A package that is external but not unpacked still resolves on the Windows + * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar + * transparently. It fails only under WSL, where the backend is launched as plain + * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the + * drift invisible on the platform you are most likely to test on, which is why + * both consumers derive from one list instead of maintaining their own. + * + * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover + * a package's platform-specific siblings — `node-gyp-build` covers + * `node-gyp-build-optional-packages`, `@yuuang/` covers every `ffi-rs-*` binding. + */ +/** + * External because Node actually loads them from disk at runtime. + * + * Native addons (.node), the JS wrappers that dlopen them by real path, and — + * critically — the ordinary JS packages those wrappers require. An external + * package is loaded from the real filesystem, so its own `require` also + * resolves from the real filesystem; a dependency that was bundled away exists + * only inside app.asar and is unreachable there. This closure is enforced by a + * test, not by inspection. + */ +export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ + "node-pty", + "ffi-rs", + "@yuuang/", + "@ff-labs/", + "@clerk/electron-passkeys", + "@msgpackr-extract/", + "msgpackr-extract", + "node-gyp-build", + "node-addon-api", + // Required by node-gyp-build-optional-packages. Not native, but in the + // closure: without it, WSL gets MODULE_NOT_FOUND while Windows is fine. + "detect-libc", + // ws's optional accelerators. Nothing in this repo declares them, so they are + // not in the staged production install and the packaged app does not ship + // them either way -- ws wraps the require in try/catch and falls back to its + // JS paths. They are listed because they were being inlined from the dev + // store: both carry binding.gyp and prebuilds and load through + // node-gyp-build, and a native loader inlined into a bundle chunk searches + // for prebuilds that cannot be beside it. Listing them keeps that from + // becoming real if either is ever declared as a dependency. + "bufferutil", + "utf-8-validate", +] as const; + +/** + * External only so the bundler never has to resolve them. + * + * These are reached through a runtime-conditional dynamic import that Node + * never takes, and they resolve `bun:*` specifiers that do not exist when + * bundling for Node. Because Node never loads them, their dependency closure + * does not need to be external — only the entry point must stay unbundled. + */ +export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [ + "@effect/platform-bun", + "@effect/sql-sqlite-bun", +] as const; + +export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ + ...CLI_RUNTIME_EXTERNAL_PREFIXES, + ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, +] as const; + +/** + * True when `id` must stay out of the bundle. + * + * This has to be wired to the bundler's `neverBundle`, not just to + * `alwaysBundle`. `alwaysBundle` only forces packages IN — returning false from + * it means "no opinion", and the default then applies: a declared dependency + * stays external, but a transitive one gets bundled. That is how + * msgpackr-extract, node-gyp-build-optional-packages and detect-libc ended up + * inlined while node-pty (a declared dependency) stayed external. + */ +export function isExternalCliDependency(id: string): boolean { + return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + +/** True when the CLI bundle should inline `id` rather than leave it external. */ +export function shouldBundleCliDependency(id: string): boolean { + if (id.startsWith("node:")) return false; + return !isExternalCliDependency(id); +} + +/** + * asar-unpack globs covering every external package. + * + * The trailing `*` is what keeps these aligned with the prefix matching above: + * without it, `node-gyp-build` would be left external by the bundler and then + * not unpacked, because the real package is `node-gyp-build-optional-packages`. + * + * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both + * paths are unpacked for the link target to exist on disk. + */ +export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap( + (prefix) => + [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, +); + +/** + * Scan an emitted bundle chunk for runtime-external packages that were inlined. + * + * Configuring the bundler is not the same as checking what it produced. The + * `alwaysBundle` predicate only forces packages IN; returning false from it + * means "no opinion", so a transitive dependency still gets bundled by default. + * msgpackr-extract, node-gyp-build-optional-packages and detect-libc were + * inlined that way while every list-based test passed, which is why this reads + * the artifact instead. + * + * `regionCount` is reported so the caller can tell "nothing was inlined" apart + * from "the marker format changed and this scan no longer sees anything". + * + * `inlinedPackages` is every package seen in a region, which lets the caller + * check the opposite direction too. Verifying only that externals are absent + * would still pass if the bundler reverted to leaving everything external: the + * scan would see source-file regions, report nothing inlined, and the packaged + * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages + * are not in the unpack globs either. + */ +export function findInlinedExternalPackages(source: string): { + readonly regionCount: number; + readonly inlined: ReadonlyArray; + readonly inlinedPackages: ReadonlyArray; +} { + // Rolldown marks each inlined module with a `//#region ` comment. + const regionPattern = /\/\/#region\s+(\S+)/g; + const packagePattern = /node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g; + + let regionCount = 0; + const inlined = new Set(); + const inlinedPackages = new Set(); + for (const region of source.matchAll(regionPattern)) { + regionCount += 1; + const regionPath = region[1] ?? ""; + for (const candidate of regionPath.matchAll(packagePattern)) { + const name = candidate[1]; + if (name === undefined || name === ".pnpm") continue; + inlinedPackages.add(name); + if (isExternalCliDependency(name)) inlined.add(name); + } + } + + return { + regionCount, + inlined: [...inlined].sort(), + inlinedPackages: [...inlinedPackages].sort(), + }; +} From baaeda305c60933e5e573b30807ecfc3a0871a80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 10:00:25 +0200 Subject: [PATCH 022/144] fix: avoid stale Live Activities when publishing is disabled (#6325) Co-authored-by: Claude Fable 5 --- .../remoteRegistration.test.ts | 76 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 24 +++++- .../features/threads/NewTaskDraftScreen.tsx | 1 + .../src/features/threads/ThreadComposer.tsx | 1 + apps/server/src/cli/connect.ts | 5 +- apps/server/src/cloud/config.ts | 40 ++++++++++ .../src/environment/ServerEnvironment.test.ts | 71 ++++++++++++++++- .../src/environment/ServerEnvironment.ts | 16 +++- apps/server/src/relay/AgentAwarenessRelay.ts | 3 +- packages/contracts/src/environment.ts | 6 ++ 10 files changed, 236 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b1a48a35a0ab..582c58fb27e6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -20,12 +20,15 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, + loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, + armAgentAwarenessLiveActivityForLocalWork, getAgentAwarenessRegistrationStatus, mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications"; const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), + start: vi.fn(() => ({})), +})); +const environmentConfigsMock = vi.hoisted(() => ({ + configs: new Map< + string, + { environment: { capabilities: { agentActivityPublishing?: boolean } } } + >(), })); const backgroundRuntime = vi.hoisted(() => ({ pending: [] as Array<{ @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({ vi.mock("../../widgets/AgentActivity", () => ({ default: { getInstances: widgetMocks.getInstances, + start: widgetMocks.start, }, })); +// The state modules pull the whole connection stack (and native expo modules) +// into the import graph; the arming gate only needs the configs map. +vi.mock("../../state/atom-registry", () => ({ + appAtomRegistry: { + get: () => environmentConfigsMock.configs, + }, +})); + +vi.mock("../../state/server", () => ({ + environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), +})); + vi.mock("expo-notifications", () => ({ addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); + widgetMocks.start.mockClear(); + environmentConfigsMock.configs.clear(); }); it("preserves disabled Live Activity preferences in relay registrations", () => { @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }, ); + + it("skips the Live Activity seed when the environment reports publishing disabled", async () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: false } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }); + + it("seeds the Live Activity for publishing and pre-capability environments", async () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + environmentConfigsMock.configs.set("env-publishing", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-publishing" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + + // An environment without the capability may run an older server that + // still publishes; only an explicit false skips the seed. + widgetMocks.start.mockClear(); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-pre-capability" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 449f90886cf3..b0f77d7704b5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -20,6 +20,8 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { Preferences } from "../../persistence/mobile-preferences"; import { clearAgentAwarenessRegistrationRecord, @@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: { }); } +// The environment descriptor advertises whether agent-activity publishes +// currently leave that server (`capabilities.agentActivityPublishing`). Only +// an explicit false skips the seed card: older servers omit the capability +// but may still publish. +function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .agentActivityPublishing !== false + ); +} + // Arms the lock-screen card the moment the user starts agent work from this // phone, while the app is still foregrounded and the fresh activity's token // can be registered immediately. The seeded row is a best-effort placeholder; // the relay's registration replay repaints it with the authoritative -// aggregate within seconds. No-ops when a card is already armed. +// aggregate within seconds. No-ops when a card is already armed, and skips +// environments that report publishing disabled — the seed would sit on +// "Connecting" forever with no update ever arriving to repaint or end it. export function armAgentAwarenessLiveActivityForLocalWork(input: { + readonly environmentId: EnvironmentId; readonly threadTitle: string; readonly projectTitle: string; }): void { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + if (!environmentPublishesAgentActivity(input.environmentId)) { + logRegistrationDebug("live activity arming skipped; environment does not publish", { + environmentId: input.environmentId, + }); + return; + } void loadPreferences() .catch(() => null) .then((preferences) => { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 87b12ad22f5f..baa28d7b4b8d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: { // -only Activity start. If creation fails, the token registration's replay // finds no work and ends the card within seconds. armAgentAwarenessLiveActivityForLocalWork({ + environmentId: selectedProject.environmentId, threadTitle: deriveThreadTitleFromPrompt(initialMessageText), projectTitle: selectedProject.title, }); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 3fba0a351c2b..60944ec1c792 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -541,6 +541,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ + environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index ef15e650a6f2..f330e62a5d20 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, + isAgentActivityPublishingEnabledValue, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_URL_SECRET, } from "../cloud/config.ts"; @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array { } export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return value === "true"; + return isAgentActivityPublishingEnabledValue(value); } interface CloudCliStatus { @@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* => + Effect.gen(function* () { + const readSecretString = (name: string) => + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); + const [enabled, url, environmentCredential] = yield* Effect.all([ + readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET), + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + // Empty strings are as unconfigured as missing files: the publisher's + // truthiness gate skips them, so the capability must too. + return ( + isAgentActivityPublishingEnabledValue(enabled) && + url !== null && + url !== "" && + environmentCredential !== null && + environmentCredential !== "" + ); + }).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 84269c381ceb..ee30d987591d 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -3,9 +3,16 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_URL_SECRET, +} from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; @@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is( ); const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + +const emptySecretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), +); const makeServerConfig = Effect.fn(function* (baseDir: string) { const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); @@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.agentActivityPublishing).toBe(false); + }), + ); + + it.effect("reports agent activity publishing from the current secret state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-publish-test-", + }); + const testLayer = Layer.mergeAll( + ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ServerSecretStore.layer, + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const encode = (value: string) => new TextEncoder().encode(value); + + const unlinked = yield* serverEnvironment.getDescriptor; + expect(unlinked.capabilities.agentActivityPublishing).toBe(false); + + // The opt-in alone is not enough: without relay link credentials no + // publish would leave this environment. + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true")); + const withoutLink = yield* serverEnvironment.getDescriptor; + expect(withoutLink.capabilities.agentActivityPublishing).toBe(false); + + // Empty credentials are as unconfigured as missing ones: the + // publisher's truthiness gate skips them, so the capability must not + // advertise publishing. + yield* secrets.set(RELAY_URL_SECRET, encode("")); + yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")); + const emptyUrl = yield* serverEnvironment.getDescriptor; + expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false); + + yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example")); + const linked = yield* serverEnvironment.getDescriptor; + expect(linked.capabilities.agentActivityPublishing).toBe(true); + + // The toggle changes at runtime, so the same service instance must + // reflect a flip without a restart. + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")); + const disabled = yield* serverEnvironment.getDescriptor; + expect(disabled.capabilities.agentActivityPublishing).toBe(false); + }).pipe(Effect.provide(testLayer)); }), ); @@ -113,6 +181,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe( Effect.provide( ServerEnvironment.layer.pipe( + Layer.provide(emptySecretStoreLayer), Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), ), ), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index e1e9020eb27d..45dc0ee9cfd5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; @@ -66,6 +68,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; @@ -156,13 +159,22 @@ export const make = Effect.gen(function* () { return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), + // The publish opt-in and relay link change at runtime (`t3 connect + // publish`, the client settings toggle), so the capability is read per + // descriptor request rather than baked in at startup. + getDescriptor: readAgentActivityPublishingActive(secrets).pipe( + Effect.map((agentActivityPublishing) => ({ + ...descriptor, + capabilities: { ...descriptor.capabilities, agentActivityPublishing }, + })), + ), }); }); /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must - * provide the external platform services and a ServerConfig. + * provide the external platform services, a ServerConfig, and the + * ServerSecretStore backing the descriptor's publishing capability. */ export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 2a4de7eda911..5127ecf7d359 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -35,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { + isAgentActivityPublishingEnabledValue, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, @@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n } export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return value === "true"; + return isAgentActivityPublishingEnabledValue(value); } export function resolveAgentActivityPublishingStartupState(input: { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 8173ad12b4cf..1777bcebc2f8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -74,6 +74,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), + /** Agent-activity publishes (push notifications and Live Activities) + currently leave this environment: the publish opt-in is enabled and the + relay link credentials exist. Clients skip seeding a Live Activity when + this is false — no update would ever repaint it. Absent on older + servers, which may still publish, so only an explicit false skips. */ + agentActivityPublishing: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; From 8f9ab0845d8e034bf49b28eb4d6abbb14fce597d Mon Sep 17 00:00:00 2001 From: Pavlo Trinko Date: Fri, 14 Aug 2026 10:25:05 +0200 Subject: [PATCH 023/144] fix(mobile): add breathing room between the git progress overlay and the app bar (#6587) Co-authored-by: Claude Fable 5 --- .../src/features/threads/GitActionProgressOverlay.tsx | 4 +++- apps/mobile/src/lib/layoutMetrics.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 2b257ec175cd..bc41570351c4 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -7,11 +7,13 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); +const OVERLAY_TOP_GAP = 8; const AnimatedLiquidGlassView = Animated.createAnimatedComponent(LiquidGlassView); export function GitActionProgressOverlay(props: { @@ -52,7 +54,7 @@ export function GitActionProgressOverlay(props: { entering={isLiquidGlassSupported ? undefined : FadeIn.duration(200)} exiting={FadeOut.duration(150)} className="absolute inset-x-3 z-[100]" - style={{ top: insets.top + 48 }} + style={{ top: insets.top + APP_BAR_HEIGHT + OVERLAY_TOP_GAP }} pointerEvents="box-none" > diff --git a/apps/mobile/src/lib/layoutMetrics.ts b/apps/mobile/src/lib/layoutMetrics.ts index 139fcbb65f32..73407601461b 100644 --- a/apps/mobile/src/lib/layoutMetrics.ts +++ b/apps/mobile/src/lib/layoutMetrics.ts @@ -3,3 +3,10 @@ export const HOME_HORIZONTAL_INSET = 20; /** Compensates for the tighter native sidebar title margin on iPad. */ export const IPAD_HOME_TITLE_OFFSET = 10; + +/** + * Height of the app's own header chrome below the safe-area inset, on every + * platform (matches the `min-h-12` AndroidScreenHeader). Distinct from the + * 44pt native iOS navigation bar. + */ +export const APP_BAR_HEIGHT = 48; From 4a2f8b04bbce916be166fec96f33a7e1f0df4a9f Mon Sep 17 00:00:00 2001 From: Michael Charles Aubrey Date: Fri, 14 Aug 2026 17:27:29 +0900 Subject: [PATCH 024/144] fix(web): keep thread rename open during IME composition (#6281) --- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/components/chat/ChatHeader.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6b44479b0cb5..f35dd1fdba67 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -935,6 +935,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") { event.preventDefault(); renameCommittedRef.current = true; diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index db13419962f0..643cf95ee88e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -203,6 +203,7 @@ export const ChatHeader = memo(function ChatHeader({ ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") { renameCommittedRef.current = true; commitRename(event.currentTarget.value); From 6ae44b418a24dc021cf042cbb1e60ebeb47e160f Mon Sep 17 00:00:00 2001 From: Pavlo Trinko Date: Fri, 14 Aug 2026 10:29:09 +0200 Subject: [PATCH 025/144] refactor(mobile): name the iOS nav bar height fallback (#6589) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge --- apps/mobile/src/features/files/FileTreeBrowser.tsx | 3 ++- apps/mobile/src/features/review/ReviewSheet.tsx | 7 +++++-- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 3 ++- apps/mobile/src/features/threads/ThreadFeed.tsx | 5 +++-- apps/mobile/src/lib/layoutMetrics.ts | 7 ++++++- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index dd7a12711949..f89bea133023 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,6 +8,7 @@ import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, @@ -123,7 +124,7 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 44 : 0; + const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 1ebb3aaf7b16..93ecc109d04f 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -39,6 +39,7 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { @@ -277,9 +278,11 @@ function ReviewFileNavigator({ // The nested native header is translucent; start the list below it so // the scroll-edge effect can sample the content (same treatment as // FileTreeBrowser in the Files pane). - paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8, + paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8, }} - scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined} + scrollIndicatorInsets={ + Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined + } renderItem={renderFile} /> ); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index fa6c1e95040c..978fc44640a1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -57,6 +57,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, @@ -249,7 +250,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } }, []); const windowHeight = useWindowDimensions().height; - const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 874c31249ce7..d138bb0c99dd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -47,6 +47,7 @@ import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -1396,7 +1397,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); - const topContentInset = props.contentTopInset ?? insets.top + 44; + const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; @@ -1409,7 +1410,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // header-providing screen) and fall back to the standard iOS bar height. const navigationHeaderHeight = useContext(HeaderHeightContext); const anchorTopInset = usesNativeAutomaticInsets - ? navigationHeaderHeight || insets.top + 44 + ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT : topContentInset; const iconSubtleColor = useThemeColor("--color-icon-subtle"); diff --git a/apps/mobile/src/lib/layoutMetrics.ts b/apps/mobile/src/lib/layoutMetrics.ts index 73407601461b..a661c70d4209 100644 --- a/apps/mobile/src/lib/layoutMetrics.ts +++ b/apps/mobile/src/lib/layoutMetrics.ts @@ -5,7 +5,12 @@ export const HOME_HORIZONTAL_INSET = 20; export const IPAD_HOME_TITLE_OFFSET = 10; /** - * Height of the app's own header chrome below the safe-area inset, on every + * Height of the native iOS navigation bar below the safe-area inset, used as + * a fallback when the measured HeaderHeightContext is unavailable. + */ +export const IOS_NAV_BAR_HEIGHT = 44; + +/* Height of the app's own header chrome below the safe-area inset, on every * platform (matches the `min-h-12` AndroidScreenHeader). Distinct from the * 44pt native iOS navigation bar. */ From b3b4b57794476c2e130d38f9243f3c5da1a0cbe7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 10:45:43 +0200 Subject: [PATCH 026/144] fix(mobile): preserve keyboard suggestions while typing (#6323) Co-authored-by: Claude Fable 5 --- .../t3composereditor/T3ComposerEditorView.kt | 7 ++ .../ios/T3ComposerEditorView.swift | 18 +++- .../src/native/T3ComposerEditor.ios.tsx | 60 ++++++++----- .../src/native/T3ComposerEditor.native.tsx | 61 ++++++++----- .../src/native/composerEditorRevision.test.ts | 89 ++++++++++++++++++- .../src/native/composerEditorRevision.ts | 60 +++++++++++-- 6 files changed, 242 insertions(+), 53 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index e13c0a521894..3010b5240997 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val textLength = editor.text?.length ?: 0 val safeStart = start.coerceIn(0, textLength) val safeEnd = end.coerceIn(0, textLength) + // Re-applying an unchanged selection resets the keyboard's suggestion + // state, so a no-op assignment must be skipped. + if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return editor.setSelection(safeStart, safeEnd) } @@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ) private fun emitSelectionChange(start: Int, end: Int) { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. + nativeEventCount += 1 onComposerSelectionChange( mapOf( "value" to editor.text.toString(), diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index ec5b54aa8f1a..2a8fb8c4ea26 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } restoreBaseTypingAttributes() + // UIKit moves the selection before textViewDidChange runs. Emitting here + // would pair the post-edit text with a pre-edit revision counter, so let + // the change event that follows carry both; only pure caret moves emit. + guard self.textView.serializedText() == value else { + return + } emitSelection() } @@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitSelection() { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. let currentValue = textView.serializedText() let selection = sourceSelection() + nativeEventCount += 1 onComposerSelectionChange([ "value": currentValue, "selection": ["start": selection.start, "end": selection.end], @@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro NSMaxRange(nextRange) <= textView.attributedText.length else { return } + self.requestedSelection = nil + // Programmatically assigning selectedRange resets the keyboard's + // autocorrect and predictive-text context even when the range is + // unchanged, so a no-op assignment must be skipped. + guard !NSEqualRanges(nextRange, textView.selectedRange) else { + return + } isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false - self.requestedSelection = nil } private func updatePlaceholderVisibility() { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 4e9d62ad2c24..32094109b1f3 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -102,11 +103,11 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); const textColor = useThemeColor("--color-foreground"); @@ -154,15 +155,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -170,9 +172,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -180,9 +180,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -190,6 +187,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -257,7 +266,7 @@ export function ComposerEditor({ onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -266,9 +275,16 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // A selection change that raced a text mutation can carry post-edit + // text. It must reach the parent alongside the acknowledged revision, + // or the next render stamps the stale draft at that revision and can + // re-apply it over the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index e78f90a7db91..ff177abf1642 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -103,11 +104,11 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); const textColor = useThemeColor("--color-foreground"); @@ -155,15 +156,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -171,9 +173,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -181,9 +181,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -191,6 +188,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -263,7 +272,7 @@ export function ComposerEditor({ onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -272,9 +281,17 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // Android emits the selection change mid-mutation, before the change + // event, so the payload can carry post-edit text. It must reach the + // parent alongside the acknowledged revision, or the next render + // stamps the stale draft at that revision and can re-apply it over + // the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index 9b255a5477ae..ccc2214e24c2 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -42,6 +43,19 @@ describe("isComposerNativeEcho", () => { it("matches value and revision when selection is uncontrolled", () => { expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); }); + + it("does not claim a controlled selection against an assumed state without one", () => { + // An echo payload serializes `selection: null`; classifying a controlled + // selection as an echo of an assumed state would drop a parent caret move. + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false); + expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false); + }); + + it("matches an assumed state when selection is uncontrolled", () => { + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true); + }); }); describe("resolveComposerControlledEventCount", () => { @@ -95,7 +109,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { selection: { start: eventCount, end: eventCount }, })); - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]); }); it("retains native events that arrive after the acknowledged render", () => { @@ -104,6 +118,77 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, ]; - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots); + }); + + it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => { + const snapshots = [ + { eventCount: 40, value: "a", selection: { start: 1, end: 1 } }, + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } }, + ]; + + const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42); + expect(pruned).toEqual([snapshots[2]]); + expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true); + }); + + it("keeps the newest of several snapshots sharing the acknowledged revision", () => { + const snapshots = [ + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } }, + ]; + + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]); + }); +}); + +describe("assumeComposerControlledState", () => { + it("replaces the acknowledged history with the applied controlled state", () => { + const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + ]); + }); + + it("keeps native events that raced past the controlled revision", () => { + const snapshots = [ + { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } }, + ]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + snapshots[1], + ]); + }); + + it("applies a parent caret move on the assumed value at the assumed revision", () => { + // Same value, new caret: not an echo (so the selection is serialized) but + // still stamped at the assumed revision so the editor accepts it. + const snapshots = assumeComposerControlledState([], 3, "typed"); + + expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe( + 3, + ); + }); + + it("re-applies a parent value that round-trips back to an acknowledged state", () => { + // Native acknowledged "typed", the parent then controlled the editor to "" + // (a send clearing the draft) and back to "typed" (the send failed and the + // draft was restored). The restore must be a fresh non-echo edit stamped at + // the current revision, not an echo the editor would drop. + const snapshots = assumeComposerControlledState( + [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }], + 3, + "", + ); + + expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe( + 3, + ); }); }); diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index ea18d153d53e..45d68ac1b652 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount( if (snapshot?.value !== value) continue; newestValueEventCount ??= snapshot.eventCount; - if ( - selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end) - ) { + if (selection === null || snapshotSelectionMatches(snapshot, selection)) { return snapshot.eventCount; } } @@ -49,6 +46,21 @@ export function resolveComposerControlledEventCount( return mostRecentEventCount; } +// A snapshot without a selection describes a state the editor applied itself +// (an assumed controlled document, where the native side may have bounded the +// caret). Revision stamping treats it as matching any controlled selection so +// a parent caret move on the assumed value stays at the assumed revision and +// passes the editor's staleness guard. Echo detection must not reuse this +// wildcard: an echo payload serializes `selection: null`, which would drop +// that caret move instead of applying it. +function snapshotSelectionMatches( + snapshot: ComposerNativeEventSnapshot, + selection: ComposerEditorSelection, +): boolean { + if (snapshot.selection === null) return true; + return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end; +} + export function isComposerNativeEcho( value: string, selection: ComposerEditorSelection | null, @@ -62,7 +74,9 @@ export function isComposerNativeEcho( snapshot.eventCount === eventCount && snapshot.value === value && (selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)) + (snapshot.selection !== null && + snapshot.selection.start === selection.start && + snapshot.selection.end === selection.end)) ) { return true; } @@ -70,9 +84,43 @@ export function isComposerNativeEcho( return false; } +/** + * Records that a parent-driven controlled document was handed to the native + * editor. From that point the acknowledged snapshot history describes a + * superseded native state, so it is replaced with the assumed applied state; + * a later parent update back to a previously acknowledged value must classify + * as a fresh edit, not as a native echo the editor would drop. Native events + * that raced past the controlled revision stay authoritative and are kept. + */ +export function assumeComposerControlledState( + snapshots: ReadonlyArray, + eventCount: number, + value: string, +): ComposerNativeEventSnapshot[] { + return [ + { eventCount, value, selection: null }, + ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount), + ]; +} + export function pruneAcknowledgedComposerNativeEvents( snapshots: ReadonlyArray, acknowledgedEventCount: number, ): ComposerNativeEventSnapshot[] { - return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount); + // The newest acknowledged snapshot must survive pruning: it is what lets a + // later, unrelated re-render classify the settled composer state as a native + // echo instead of a parent-driven edit that would re-control the caret (and + // reset the keyboard's autocorrect context on iOS). + let latestAcknowledgedIndex = -1; + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const snapshot = snapshots[index]; + if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) { + latestAcknowledgedIndex = index; + break; + } + } + return snapshots.filter( + (snapshot, index) => + index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount, + ); } From 21a3669cee0c6fb54f5afe065cf0eb0348bd232c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 10:46:33 +0200 Subject: [PATCH 027/144] fix(mobile): prevent OTA update restart crashes (#6324) Co-authored-by: Claude Fable 5 --- .../src/features/home/HomeRouteScreen.tsx | 3 +- .../features/settings/SettingsRouteScreen.tsx | 17 +- .../src/features/updates/app-updates.test.ts | 380 +++++++++++++++++- .../src/features/updates/app-updates.ts | 379 ++++++++++++++++- apps/mobile/src/lib/atomic-file.ts | 19 + apps/mobile/src/lib/composerImages.ts | 23 +- .../mobile/src/lib/foreground-handoff.test.ts | 32 ++ apps/mobile/src/lib/foreground-handoff.ts | 22 + .../mobile/src/state/thread-outbox-storage.ts | 30 +- apps/mobile/src/state/thread-outbox.ts | 13 +- .../src/state/use-composer-drafts.test.ts | 39 ++ apps/mobile/src/state/use-composer-drafts.ts | 28 +- docs/user/updating.md | 8 + 13 files changed, 959 insertions(+), 34 deletions(-) create mode 100644 apps/mobile/src/lib/atomic-file.ts create mode 100644 apps/mobile/src/lib/foreground-handoff.test.ts create mode 100644 apps/mobile/src/lib/foreground-handoff.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 331347867661..8061b1d1e85b 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; -import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; +import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -34,6 +34,7 @@ export function HomeRouteScreen() { useEffect(() => { void checkForAppUpdateOnLaunch(); + startAppUpdateForegroundRecheck(); }, []); const { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 84a5634e518b..bcf2ce386d9c 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -608,7 +608,10 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { + // The user asked for this restart by tapping the version row, so it may + // apply immediately instead of prompting. await runAppUpdateCheck({ + applyMode: "immediate", onFailure: (message) => Alert.alert("Update failed", message), onStateChange: setUpdateState, }); @@ -631,11 +634,15 @@ function AppSettingsSection() { ? "Checking…" : updateState === "downloading" ? "Downloading…" - : updateState === "restarting" - ? "Restarting…" - : updateState === "current" - ? "Up to date" - : null; + : // "ready" appears only when this check joined an in-flight background-mode + // check; that download installs at the next backgrounding. + updateState === "ready" + ? "Update ready" + : updateState === "restarting" + ? "Restarting…" + : updateState === "current" + ? "Up to date" + : null; const versionRow = ( diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 4926ae65ca3a..4ff344e63df6 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + createAppUpdateDeferral, createAppUpdateLaunchCheck, + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, registerHiddenUpdateTap, runAppUpdateCheck, + shouldRecheckAppUpdateOnForeground, type AppUpdateCheckState, type AppUpdateClient, + type AppUpdateEnvironment, } from "./app-updates"; vi.mock("expo-updates", () => ({ @@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial = {}): AppUpdateCl }; } +function makeUpdateEnvironment(overrides: Partial = {}): { + readonly backgroundCallbacks: Array<() => void>; + readonly environment: AppUpdateEnvironment; + readonly foregroundStayCallbacks: Array<() => void>; +} { + const backgroundCallbacks: Array<() => void> = []; + const foregroundStayCallbacks: Array<() => void> = []; + return { + backgroundCallbacks, + foregroundStayCallbacks, + environment: { + confirmInstallNow: vi.fn(async () => true), + flushPendingWrites: vi.fn(async () => {}), + isSafeToRestartInBackground: vi.fn(async () => true), + onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => { + backgroundCallbacks.push(apply); + }), + onForegroundStay: vi.fn((apply: () => void) => { + foregroundStayCallbacks.push(apply); + }), + ...overrides, + }, + }; +} + +function makeAvailableUpdateClient(overrides: Partial = {}): AppUpdateClient { + return makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: true, + isRollBackToEmbedded: false, + })), + ...overrides, + }); +} + describe("runAppUpdateCheck", () => { it("does nothing while running from the Metro development server", async () => { vi.stubGlobal("__DEV__", true); @@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => { expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); }); - it("downloads and restarts when a new update is available", async () => { - const client = makeUpdateClient({ - checkForUpdateAsync: vi.fn(async () => ({ - isAvailable: true, - isRollBackToEmbedded: false, - })), - }); + it("downloads silently and installs at the next backgrounding", async () => { + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); const states: AppUpdateCheckState[] = []; - await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + await runAppUpdateCheck({ + client, + deferral, + environment, + onStateChange: (state) => states.push(state), + }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(states).toEqual(["checking", "downloading", "ready"]); + expect(deferral.pendingInstall).toBe(true); + expect(backgroundCallbacks).toHaveLength(1); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + expect(environment.flushPendingWrites).toHaveBeenCalled(); + }); + + it("flushes pending writes before restarting", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + }); + + const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!; + const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!; + expect(flushOrder).toBeLessThan(reloadOrder); + }); + + it("prompts once the app has stayed foregrounded with the download waiting", async () => { + const client = makeAvailableUpdateClient(); + const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(foregroundStayCallbacks).toHaveLength(1); + + foregroundStayCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + expect(environment.confirmInstallNow).toHaveBeenCalledOnce(); + expect(environment.flushPendingWrites).toHaveBeenCalled(); + }); + + it("keeps the background install armed when the foreground prompt is declined", async () => { + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ + confirmInstallNow: vi.fn(async () => false), + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + foregroundStayCallbacks[0]!(); + await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce()); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("skips the foreground prompt once the install is no longer pending", async () => { + const client = makeAvailableUpdateClient(); + const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + // A failed deferred reload resets the deferral before the stay fires. + deferral.pendingInstall = false; + foregroundStayCallbacks[0]!(); + + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + }); + + it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => { + const client = makeAvailableUpdateClient(); + const safe = vi.fn(async () => false); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ + isSafeToRestartInBackground: safe, + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + expect(backgroundCallbacks).toHaveLength(1); + // Arming may fire for an already-backgrounded app… + expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + // …but a re-arm must wait for a fresh transition, or an unsafe attempt + // would retry in a tight loop within the same background session. + expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false); + + safe.mockResolvedValue(true); + backgroundCallbacks[1]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("resets the deferral when the deferred restart fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient({ + reloadAsync: vi.fn(async () => { + throw new Error("reload rejected"); + }), + }); + const { backgroundCallbacks, environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + backgroundCallbacks[0]!(); + + await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false)); + reportError.mockRestore(); + }); + + it("arms the deferred install once across repeated checks", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + await runAppUpdateCheck({ client, deferral, environment }); + + expect(environment.onNextBackground).toHaveBeenCalledOnce(); + expect(environment.onForegroundStay).toHaveBeenCalledOnce(); + }); + + it("restarts into an already-downloaded update when the user asks to install", async () => { + const client = makeUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + deferral.pendingInstall = true; + + await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment }); + + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("honors an immediate request that joined an in-flight background check", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + const backgroundCheck = runAppUpdateCheck({ client, deferral, environment }); + const manualCheck = runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral, + environment, + }); + + resolveCheck({ isAvailable: true, isRollBackToEmbedded: false }); + await Promise.all([backgroundCheck, manualCheck]); + + // The coalesced background check deferred the download, but the manual + // caller explicitly asked to install, so the restart happens anyway. + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("runs a single restart when the deferred install races the foreground prompt", async () => { + const client = makeAvailableUpdateClient(); + let releaseFlush!: () => void; + const blockedFlush = new Promise((resolve) => { + releaseFlush = resolve; + }); + const flushPendingWrites = vi.fn(async (): Promise => {}); + const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ + flushPendingWrites, + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + flushPendingWrites.mockReturnValue(blockedFlush); + + // The deferred install starts and blocks on its flush; the foreground + // prompt firing in that window must not begin a second restart. + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce()); + foregroundStayCallbacks[0]!(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + + releaseFlush(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ + flushPendingWrites: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + backgroundCallbacks[0]!(); + + await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + reportError.mockRestore(); + }); + + it("restarts without prompting when the caller asked for an immediate install", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + onStateChange: (state) => states.push(state), + }); + + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); expect(client.reloadAsync).toHaveBeenCalledOnce(); expect(states).toEqual(["checking", "downloading", "restarting"]); }); + it("holds an automatic rollback restart when the flush fails and re-arms it", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: true, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: false, + isRollBackToEmbedded: true, + })), + }); + const flushPendingWrites = vi.fn(async (): Promise => { + throw new Error("storage unavailable"); + }); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + // Nobody asked for this restart, so it must not discard the state it + // failed to land; the rollback waits armed for the next backgrounding. + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + expect(backgroundCallbacks).toHaveLength(1); + + flushPendingWrites.mockResolvedValue(undefined); + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + reportError.mockRestore(); + }); + + it("still restarts a user-requested install when the flush fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment({ + flushPendingWrites: vi.fn(async () => { + throw new Error("storage unavailable"); + }), + }); + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + }); + + expect(client.reloadAsync).toHaveBeenCalledOnce(); + reportError.mockRestore(); + }); + it("restarts into the embedded bundle for a rollback directive", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ @@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => { isRollBackToEmbedded: true, })), }); + const { environment } = makeUpdateEnvironment(); - await runAppUpdateCheck({ client }); + await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment }); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + // A rollback pulls a broken bundle, so it never waits on the prompt. + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); expect(client.reloadAsync).toHaveBeenCalledOnce(); }); @@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => { }); }); +describe("shouldRecheckAppUpdateOnForeground", () => { + it("requires a meaningful background gap", () => { + expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false); + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1, + false, + ), + ).toBe(false); + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, + false, + ), + ).toBe(true); + }); + + it("stays quiet while a downloaded update waits for its install", () => { + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, + true, + ), + ).toBe(false); + }); +}); + describe("registerHiddenUpdateTap", () => { it("unlocks the manual check on the fifth tap", () => { let count = 0; diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 66525d022925..5f8a110beaf6 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -8,7 +8,13 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; +export type AppUpdateCheckState = + | "idle" + | "checking" + | "downloading" + | "ready" + | "restarting" + | "current"; export interface AppUpdateClient { readonly isEnabled: boolean; @@ -23,8 +29,70 @@ export interface AppUpdateClient { readonly reloadAsync: () => Promise; } +/** + * The pieces of the app the update flow has to coordinate with before it may + * tear down the JavaScript runtime. Injectable so the flow stays unit-testable. + */ +export interface AppUpdateEnvironment { + /** Asks the user to install the waiting update now; `false` keeps it deferred. */ + readonly confirmInstallNow: () => Promise; + /** + * Lands persisted state (drafts, outbox) before the restart. Rejects when a + * write failed, so a silent restart can hold off instead of dropping the + * unsaved in-memory state. + */ + readonly flushPendingWrites: () => Promise; + /** + * Whether a deferred restart may fire right now: the app must still be + * backgrounded (flush latency or an iOS suspend can push the continuation + * into the next foreground session) and not merely paused behind an + * app-initiated handoff like the Android image picker. + */ + readonly isSafeToRestartInBackground: () => Promise; + /** + * Runs `apply` the next time the app enters the background. With + * `includeCurrent`, an app that is already backgrounded fires immediately + * (so a backgrounding that raced module load is not missed); without it, + * only a future transition fires, so an attempt that already failed in the + * current background session cannot retry in a tight loop. + */ + readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void; + /** + * Runs `apply` once the app has stayed foregrounded for the whole prompt + * window — the signal that a deferred install has had no backgrounding to + * ride on. + */ + readonly onForegroundStay: (apply: () => void) => void; +} + +/** Tracks a downloaded update waiting for a safe moment to install. */ +export interface AppUpdateDeferral { + pendingInstall: boolean; + /** + * Claimed by whichever restart sequence (deferred backgrounding, foreground + * prompt, manual install) starts first, so racing paths cannot tear down + * the runtime twice. + */ + installInProgress: boolean; +} + +export function createAppUpdateDeferral(): AppUpdateDeferral { + return { pendingInstall: false, installInProgress: false }; +} + +const appUpdateDeferral = createAppUpdateDeferral(); + interface AppUpdateCheckOptions { + /** + * "background" (default) installs silently at the next backgrounding, + * asking only if the app then stays foregrounded so long that the install + * never gets its chance. "immediate" restarts as soon as the download + * lands — reserved for flows where the user explicitly requested the update. + */ + readonly applyMode?: "background" | "immediate"; readonly client?: AppUpdateClient; + readonly deferral?: AppUpdateDeferral; + readonly environment?: AppUpdateEnvironment; readonly onFailure?: (message: string) => void; readonly onStateChange?: (state: AppUpdateCheckState) => void; } @@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr if (appUpdateCheckInFlight) { await observeAppUpdateCheck(appUpdateCheckInFlight, options); + // A background-mode check in flight may have deferred the download this + // caller explicitly asked to install; honor the explicit request now. + if (options.applyMode === "immediate") { + const deferral = options.deferral ?? appUpdateDeferral; + if (deferral.pendingInstall) { + const environment = options.environment ?? defaultAppUpdateEnvironment; + await installPendingAppUpdate(client, environment, deferral, options); + } + } return; } @@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr appUpdateCheckInFlight = inFlight; const execution = performAppUpdateCheck(client, { + applyMode: options.applyMode, + deferral: options.deferral, + environment: options.environment, onFailure: (message) => { progress.failure = message; notifyListeners(failureListeners, message); @@ -175,6 +255,15 @@ async function performAppUpdateCheck( options: AppUpdateCheckOptions, ): Promise { const setState = options.onStateChange ?? (() => {}); + const environment = options.environment ?? defaultAppUpdateEnvironment; + const deferral = options.deferral ?? appUpdateDeferral; + + // The user explicitly asked to install and a previous check has already + // downloaded the update; restart into it without another network round trip. + if (options.applyMode === "immediate" && deferral.pendingInstall) { + await installPendingAppUpdate(client, environment, deferral, options); + return; + } setState("checking"); const check = await settlePromise(() => client.checkForUpdateAsync()); @@ -203,14 +292,252 @@ async function performAppUpdateCheck( return; } + // A rollback directive exists to pull a broken bundle; never hold it + // behind a prompt or a deferred install. + if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) { + const outcome = await installAppUpdate( + client, + environment, + deferral, + options, + options.applyMode === "immediate", + ); + if (outcome === "flush-failed") { + // Only reachable for an automatic rollback: keep the state-bearing + // runtime alive and retry like a deferred install. The fetched rollback + // still applies at the next cold start regardless. + setState("ready"); + armDeferredAppUpdateInstall(client, environment, deferral); + } + return; + } + + setState("ready"); + armDeferredAppUpdateInstall(client, environment, deferral); +} + +type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed"; + +/** + * Restarting mid-session while native surfaces are mounted is the crashiest + * moment expo-updates has, so the restart flushes persistence first and, by + * default, waits for a backgrounding — where nothing is rendering and the + * teardown is invisible. Only a restart the user explicitly asked for may + * proceed over a failed flush; an automatic one aborts with "flush-failed" + * so unsaved state is never silently discarded. + */ +async function installAppUpdate( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + options: AppUpdateCheckOptions, + userRequested: boolean, +): Promise { + // A concurrent install sequence already owns the restart. + if (deferral.installInProgress) return "installed"; + deferral.installInProgress = true; + const setState = options.onStateChange ?? (() => {}); setState("restarting"); + const flushed = await settlePromise(() => environment.flushPendingWrites()); + if (flushed._tag === "Failure") { + reportUpdateFailure(flushed, "Could not save pending state.", undefined); + if (!userRequested) { + deferral.installInProgress = false; + return "flush-failed"; + } + } const reloaded = await settlePromise(() => client.reloadAsync()); if (reloaded._tag === "Failure") { reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); setState("idle"); + deferral.installInProgress = false; + return "restart-failed"; + } + return "installed"; +} + +/** Restarts into an already-downloaded update at the user's request. */ +async function installPendingAppUpdate( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + options: AppUpdateCheckOptions, +): Promise { + const outcome = await installAppUpdate(client, environment, deferral, options, true); + if (outcome === "restart-failed") { + // Let later checks re-arm the install; the downloaded update still + // applies at the next cold start regardless. + deferral.pendingInstall = false; + } +} + +function armDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): void { + if (deferral.pendingInstall) return; + deferral.pendingInstall = true; + scheduleDeferredAppUpdateInstall(client, environment, deferral, true); + environment.onForegroundStay(() => { + void promptDeferredAppUpdateInstall(client, environment, deferral); + }); +} + +/** + * A deferred install normally rides the next backgrounding, but a session that + * never leaves the foreground would sit on the download forever. Only then is + * the user asked, and declining simply leaves the background install armed. + */ +async function promptDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): Promise { + if (!deferral.pendingInstall || deferral.installInProgress) return; + const installNow = await settlePromise(() => environment.confirmInstallNow()); + if (installNow._tag !== "Success" || !installNow.value) return; + // A backgrounding while the alert was up may have started the deferred + // restart already; the stale accept must not start a second one. + if (!deferral.pendingInstall || deferral.installInProgress) return; + await installPendingAppUpdate(client, environment, deferral, {}); +} + +function scheduleDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + includeCurrent: boolean, +): void { + environment.onNextBackground(() => { + void applyDeferredAppUpdateInstall(client, environment, deferral); + }, includeCurrent); +} + +async function applyDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): Promise { + if (!deferral.pendingInstall || deferral.installInProgress) return; + deferral.installInProgress = true; + const flushed = await settlePromise(() => environment.flushPendingWrites()); + const safe = await settlePromise(() => environment.isSafeToRestartInBackground()); + if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) { + if (flushed._tag === "Failure") { + // Nothing is lost yet: keep the state-bearing runtime alive and retry + // the flush at the next backgrounding instead of restarting over it. + reportUpdateFailure(flushed, "Could not save pending state.", undefined); + } + deferral.installInProgress = false; + // This attempt already ran in the current background session; retrying + // before a fresh transition would just loop over the same failure. + scheduleDeferredAppUpdateInstall(client, environment, deferral, false); + return; + } + const reloaded = await settlePromise(() => client.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined); + deferral.installInProgress = false; + // Let later checks re-arm the install; the downloaded update still + // applies at the next cold start regardless. + deferral.pendingInstall = false; } } +async function defaultConfirmInstallNow(): Promise { + const { Alert } = await import("react-native"); + return new Promise((resolve) => { + Alert.alert( + "Update ready", + "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?", + [ + { onPress: () => resolve(false), style: "cancel", text: "Later" }, + { onPress: () => resolve(true), text: "Install Now" }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +async function defaultFlushPendingWrites(): Promise { + // Attempt every flush before surfacing the first failure, so one broken + // store cannot keep the others from landing. + const results = await Promise.allSettled([ + import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()), + import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()), + ]); + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) throw failed.reason; +} + +async function defaultIsSafeToRestartInBackground(): Promise { + const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); + if (isForegroundHandoffActive()) return false; + const { AppState } = await import("react-native"); + return AppState.currentState === "background"; +} + +function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { + void import("react-native").then(({ AppState }) => { + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "background") return; + subscription.remove(); + apply(); + }); + // The app may already have backgrounded while this module was loading; + // the listener alone would then wait a whole extra foreground cycle. + if (includeCurrent && AppState.currentState === "background") { + subscription.remove(); + apply(); + } + }); +} + +/** + * How long the app may stay foregrounded with a downloaded update before the + * install prompt appears. Long enough that most sessions background naturally + * and install silently instead. + */ +export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; + +/** + * The window resets on every backgrounding because that is exactly when the + * deferred install gets its chance. iOS "inactive" blips (app switcher, a + * pulled-down notification shade) leave the timer running. + */ +function defaultOnForegroundStay(apply: () => void): void { + void import("react-native").then(({ AppState }) => { + let timer: ReturnType | undefined; + const arm = () => { + timer ??= setTimeout(() => { + subscription.remove(); + apply(); + }, DEFERRED_INSTALL_PROMPT_AFTER_MS); + }; + const disarm = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") arm(); + else if (state === "background") disarm(); + }); + if (AppState.currentState === "active") arm(); + }); +} + +const defaultAppUpdateEnvironment: AppUpdateEnvironment = { + confirmInstallNow: defaultConfirmInstallNow, + flushPendingWrites: defaultFlushPendingWrites, + isSafeToRestartInBackground: defaultIsSafeToRestartInBackground, + onNextBackground: defaultOnNextBackground, + onForegroundStay: defaultOnForegroundStay, +}; + function reportUpdateFailure( result: AtomCommandResult, fallback: string, @@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck( } export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); + +/** + * The app can stay resident for days, so a launch-only check misses updates + * published while it was in memory. Anything shorter reads as noise: brief + * app switches should not trigger network checks or an install prompt. + */ +export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000; + +export function shouldRecheckAppUpdateOnForeground( + backgroundedAtMs: number | null, + activeAtMs: number, + pendingInstall: boolean, +): boolean { + if (pendingInstall) return false; + return ( + backgroundedAtMs !== null && + activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS + ); +} + +export function createAppUpdateForegroundRecheck( + client: AppUpdateClient = Updates, + deferral: AppUpdateDeferral = appUpdateDeferral, +): () => void { + let started = false; + + return () => { + if (started || !isAppUpdateCheckAvailable(client)) return; + started = true; + void import("react-native").then(({ AppState }) => { + let backgroundedAtMs: number | null = null; + AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); + }); + }); + }; +} + +export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck(); diff --git a/apps/mobile/src/lib/atomic-file.ts b/apps/mobile/src/lib/atomic-file.ts new file mode 100644 index 000000000000..77a695967ff0 --- /dev/null +++ b/apps/mobile/src/lib/atomic-file.ts @@ -0,0 +1,19 @@ +import type { File } from "expo-file-system"; + +let tempFileSequence = 0; + +/** + * Replaces a file's contents through a sibling temp file and an overwriting + * rename, so an interrupted write (app restart, process death) never leaves a + * truncated document at the final path. Each write stages through its own + * temp file so concurrent writers to the same destination cannot move or + * clobber each other's staging file mid-flight. + */ +export async function writeFileAtomically(file: File, contents: string): Promise { + const { File: FileConstructor } = await import("expo-file-system"); + tempFileSequence += 1; + const temp = new FileConstructor(file.parentDirectory, `${file.name}.${tempFileSequence}.tmp`); + temp.create({ intermediates: true, overwrite: true }); + temp.write(contents); + temp.moveSync(file, { overwrite: true }); +} diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index f559545c04ef..e92bb0c6e08b 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -4,6 +4,7 @@ import { type UploadChatImageAttachment, } from "@t3tools/contracts"; import { estimateBase64ByteSize } from "./base64"; +import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const result = await imagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], - allowsMultipleSelection: true, - selectionLimit: remainingSlots, - base64: true, - quality: 1, - }); + // The picker covers the Android activity, which reports the app as + // backgrounded; the guard keeps background-triggered restarts away mid-pick. + const endHandoff = beginForegroundHandoff(); + let result: Awaited>; + try { + result = await imagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }); + } finally { + endHandoff(); + } if (result.canceled) { return { diff --git a/apps/mobile/src/lib/foreground-handoff.test.ts b/apps/mobile/src/lib/foreground-handoff.test.ts new file mode 100644 index 000000000000..06608e692e90 --- /dev/null +++ b/apps/mobile/src/lib/foreground-handoff.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff"; + +describe("foreground handoff", () => { + it("is active only while a handoff is open", () => { + expect(isForegroundHandoffActive()).toBe(false); + const end = beginForegroundHandoff(); + expect(isForegroundHandoffActive()).toBe(true); + end(); + expect(isForegroundHandoffActive()).toBe(false); + }); + + it("stays active until every overlapping handoff ends", () => { + const endFirst = beginForegroundHandoff(); + const endSecond = beginForegroundHandoff(); + endFirst(); + expect(isForegroundHandoffActive()).toBe(true); + endSecond(); + expect(isForegroundHandoffActive()).toBe(false); + }); + + it("tolerates an end function called twice", () => { + const endFirst = beginForegroundHandoff(); + const endSecond = beginForegroundHandoff(); + endFirst(); + endFirst(); + expect(isForegroundHandoffActive()).toBe(true); + endSecond(); + expect(isForegroundHandoffActive()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/foreground-handoff.ts b/apps/mobile/src/lib/foreground-handoff.ts new file mode 100644 index 000000000000..7684913174d5 --- /dev/null +++ b/apps/mobile/src/lib/foreground-handoff.ts @@ -0,0 +1,22 @@ +/** + * Tracks app-initiated OS-surface handoffs (image picker, auth tab, share + * sheet). Android reports the app as backgrounded while one of these covers + * the activity, so background-triggered work — like a deferred app update + * restart — has to wait them out instead of tearing down the mid-flow runtime. + */ +let activeHandoffs = 0; + +/** Returns an idempotent end function; call it when the handoff resolves. */ +export function beginForegroundHandoff(): () => void { + activeHandoffs += 1; + let ended = false; + return () => { + if (ended) return; + ended = true; + activeHandoffs -= 1; + }; +} + +export function isForegroundHandoffActive(): boolean { + return activeHandoffs > 0; +} diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts index 2003c220badb..ab0853f7dee5 100644 --- a/apps/mobile/src/state/thread-outbox-storage.ts +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -1,6 +1,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { writeFileAtomically } from "../lib/atomic-file"; import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, @@ -9,6 +10,24 @@ import { const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; +const inFlightWrites = new Set>(); + +function trackInFlightWrite(operation: Promise): Promise { + inFlightWrites.add(operation); + void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation)); + return operation; +} + +/** + * Awaits queued-message writes so an app update restart cannot tear down the + * runtime while one is mid-file. + */ +export async function flushThreadOutboxWrites(): Promise { + while (inFlightWrites.size > 0) { + await Promise.allSettled(inFlightWrites); + } +} + export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()( "ThreadOutboxStorageError", { @@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { write: async (message) => { const fileName = messageFileName(message.messageId); try { - const file = await getMessageFile(message.messageId); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + await trackInFlightWrite( + (async () => { + const file = await getMessageFile(message.messageId); + await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message))); + })(), + ); } catch (cause) { throw new ThreadOutboxStorageError({ operation: "write", diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts index 59287b12e61e..1de1f8da655c 100644 --- a/apps/mobile/src/state/thread-outbox.ts +++ b/apps/mobile/src/state/thread-outbox.ts @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { appAtomRegistry } from "./atom-registry"; import { createThreadOutboxManager } from "./thread-outbox-manager"; import type { QueuedThreadMessage } from "./thread-outbox-model"; -import { expoThreadOutboxStorage } from "./thread-outbox-storage"; +import { expoThreadOutboxStorage, flushThreadOutboxWrites } from "./thread-outbox-storage"; export * from "./thread-outbox-model"; @@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({ storage: expoThreadOutboxStorage, }); +/** + * Lands queued outbox mutations before the JS runtime is torn down (app update + * restart). An enqueued message is published to the atom immediately but its + * durable write waits behind the mutation queue, so draining only the writes + * already mid-file would miss it. + */ +export async function flushThreadOutbox(): Promise { + await threadOutboxManager.serialize(async () => {}); + await flushThreadOutboxWrites(); +} + export function ensureThreadOutboxLoaded(): void { void threadOutboxManager.load(); } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index ae03141a8863..8dbddfe1fece 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -4,6 +4,7 @@ import { vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = ""; + let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); @@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => { releaseRead?.(); releaseRead = null; }, + getDocument() { + return document; + }, setDocument(value: unknown) { document = JSON.stringify(value); }, + setWriteError(error: Error | null) { + writeError = error; + }, Directory: class { create() {} }, File: class { exists = true; + parentDirectory = null; create() {} + moveSync() {} + async text() { await readBarrier; return document; } write(value: string) { + if (writeError) { + throw writeError; + } document = value; } }, @@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({ import { appAtomRegistry } from "./atom-registry"; import { clearComposerDraftContentState, + ComposerDraftPersistenceError, composerDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerDrafts, type ComposerDraft, + flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, removeComposerDraftsForEnvironment, restoreComposerDraftSnapshotState, + setComposerDraftText, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -393,4 +409,27 @@ describe("mobile composer drafts", () => { [unrelatedKey]: unrelated, }); }); + + it("lands a still-debounced draft write when flushed", async () => { + const draftKey = "environment-1:thread-1"; + setComposerDraftText(draftKey, "typed right before the restart"); + + await flushComposerDrafts(); + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({ + drafts: { [draftKey]: { text: "typed right before the restart" } }, + }); + }); + + it("propagates a flush write failure instead of resolving as saved", async () => { + const draftKey = "environment-1:thread-1"; + setComposerDraftText(draftKey, "unsaved"); + composerDraftFileMocks.setWriteError(new Error("storage unavailable")); + + try { + await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError); + } finally { + composerDraftFileMocks.setWriteError(null); + } + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index e9f8cde3cec2..7dbea23596c7 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { writeFileAtomically } from "../lib/atomic-file"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; @@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record } } +/** + * Lands any debounced or in-flight draft write before the JS runtime is torn + * down (app update restart), so the freshest draft state survives it. A write + * failure propagates so the caller can decide whether the restart may proceed. + */ +export async function flushComposerDrafts(): Promise { + // An edit during an awaited write schedules another debounced write, so + // keep landing snapshots until no debounce is pending after a queue drain. + do { + while (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + await persistenceQueue.run(() => + writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), + ); + } + await persistenceQueue.run(() => Promise.resolve()); + } while (persistTimer !== null); +} + function schedulePersistComposerDrafts(drafts: Record): void { if (persistTimer !== null) { clearTimeout(persistTimer); @@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); - await writePersistedComposerDrafts(next); + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); } export function useComposerDraft(draftKey: string | null): ComposerDraft { diff --git a/docs/user/updating.md b/docs/user/updating.md index d6c6dfd1c850..564d05fd431f 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -70,4 +70,12 @@ If a step fails: 3. For a command-line server, relaunch it with `npx t3@`, replacing `` with the client version shown in the warning. +## The Mobile App + +The mobile app keeps itself current on its own. When it finds a new version, it downloads it in the +background and installs it automatically the next time you leave the app. Unsent drafts and queued +messages are saved before the restart. Only if the app stays open long enough that the update never +gets that chance does it ask whether to install right away; choosing **Later** is safe and keeps the +automatic install armed. + For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). From 038560e58036d51b2576b3c2cd9170a194cefe9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 11:22:05 +0200 Subject: [PATCH 028/144] fix(web): align every titlebar control cluster on one shared inset (#6592) Co-authored-by: Claude Fable 5 --- apps/web/src/components/AppSidebarLayout.tsx | 5 ++++- apps/web/src/components/ChatView.tsx | 6 +++++- apps/web/src/components/RightPanelTabs.tsx | 4 +++- .../components/chat/PanelLayoutControls.tsx | 8 ++++---- apps/web/src/routes/_chat.pull-requests.tsx | 18 +++++++++++++++--- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5138e84d2f15..a3ba76679689 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -96,8 +96,11 @@ function SidebarControl() { }, [keybindings, toggleSidebar]); return ( + // The right-side layout controls carry mr-px (border compensation inside + // the panel), so the trigger mirrors it: both clusters sit one extra pixel + // off their edge and the titlebar reads symmetric.
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e459d6d09c7b..3da816618a18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6583,7 +6583,11 @@ function ChatViewContent(props: ChatViewProps) { {panelToggleControls}
} surfaces={rightPanelState.surfaces} activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 21abd873cb3a..df65aa60d520 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -604,7 +604,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
- + } /> @@ -75,7 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ size="sm" disabled={!rightPanelAvailable} > - + {liveAgentCount > 0 ? ( {maximized ? ( - + ) : ( - + )} } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f3ccf54be3a1..aee41fb696d0 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1303,7 +1303,13 @@ function PullRequestsRouteView() { /> ); const openPanelControls = ( -
+
{panelToggleControls}
); @@ -1484,7 +1490,13 @@ function PullRequestsRouteView() { searchInput, filtersMenu, rightPanelControl: - !pullRequestsSupported || rightPanelState.isOpen ? null : panelToggleControls, + // Footprint reserve while the panel is closed: the toggle itself stays + // mounted at the fixed titlebar inset in both states so it cannot move + // on toggle, and this spacer keeps refresh from sliding underneath it + // (sized per header padding so refresh ends a normal gap short of it). + !pullRequestsSupported || rightPanelState.isOpen ? null : ( + + ), rightPanelOpen: rightPanelState.isOpen, listBody, }; @@ -1526,7 +1538,7 @@ function PullRequestsRouteView() { return (
- {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null} + {pullRequestsSupported ? openPanelControls : null} {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? ( From 184d8ef33b8f42869fb84f66a33984185b81dc47 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 14 Aug 2026 12:35:10 +0100 Subject: [PATCH 029/144] fix(mobile): steer active turns by default (#6543) --- .../src/features/threads/ThreadComposer.tsx | 5 +---- .../features/threads/ThreadDetailScreen.tsx | 2 -- .../features/threads/ThreadRouteScreen.tsx | 1 - apps/mobile/src/state/thread-outbox-model.ts | 2 +- apps/mobile/src/state/thread-outbox.test.ts | 21 +++++++++++++++++++ .../src/state/use-thread-composer-state.ts | 5 ----- .../src/state/use-thread-outbox-drain.ts | 18 ++++------------ 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 60944ec1c792..55a4eed568d6 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -105,7 +105,6 @@ export interface ThreadComposerProps { readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; - readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; @@ -327,9 +326,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "starting"; const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 - ? "Queue" - : "Send"; + props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const connectionStatus = composerConnectionStatus({ diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 978fc44640a1..fb859090ad5e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -101,7 +101,6 @@ export interface ThreadDetailScreenProps { readonly threadSyncStatus?: EnvironmentThreadStatus; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; - readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; @@ -727,7 +726,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} - activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} bottomInset={composerBottomInset} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d7754b7d78f7..cad1cab8e602 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -785,7 +785,6 @@ function ThreadRouteContent( connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} loadEarlier={loadEarlierTurns} - activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be38720..eede506976a7 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -169,7 +169,7 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + return input.environmentConnected ? "send" : "wait"; } /** diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 89f8b26798be..b12ad2dc5843 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -487,6 +487,27 @@ describe("thread outbox", () => { ).toBe("send"); }); + it("sends existing-thread messages whenever connected so queued messages can steer", () => { + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: true, + threadBusy: true, + }), + ).toBe("send"); + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: false, + threadBusy: true, + }), + ).toBe("wait"); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e6b7..721c82a0e38e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -129,10 +129,6 @@ export function useThreadComposerState() { ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); - const activeThreadBusy = - !!selectedThread && - (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); - const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { return null; @@ -308,7 +304,6 @@ export function useThreadComposerState() { modelSelection, runtimeMode, interactionMode, - activeThreadBusy, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index d06a4098aab2..68c973ff97e3 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -37,7 +37,7 @@ import { type QueuedThreadMessage, type ThreadOutboxCommandStage, } from "./thread-outbox-model"; -import { environmentThreadShells, threadEnvironment } from "./threads"; +import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; import { editingQueuedMessageIdsAtom, @@ -362,22 +362,12 @@ export function useThreadOutboxDrain(): void { return true; } // The guards evaluated before the confirmation await are stale by now: - // the thread may have gone busy, or the user may have opened this - // message in the editor. Re-read both and defer to the next drain pass - // (returning true skips the failure/backoff path) rather than sending - // a payload the user is editing or racing an active turn. + // the user may have opened this message in the editor. Re-read that + // guard and defer to the next drain pass (returning true skips the + // failure/backoff path) rather than sending a payload being edited. if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { return true; } - const freshThread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, - ); - const freshThreadBusy = - freshThread?.session?.status === "running" || freshThread?.session?.status === "starting"; - if (deliveryAction === "send" && creation === undefined && freshThreadBusy) { - return true; - } return deliveryAction === "remove" ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined From 1a6599437b6ad77330923819613cc28be3b33945 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:58:49 +0200 Subject: [PATCH 030/144] fix(web): clarify desktop update status (#6504) --- .../sidebar/DesktopUpdateStatusIcon.tsx | 126 ++++++++++++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 113 +++++++++++++--- 2 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx new file mode 100644 index 000000000000..9346a742a379 --- /dev/null +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -0,0 +1,126 @@ +import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import type { AnimationEventHandler } from "react"; + +import { cn } from "../../lib/utils"; + +const DOWNLOAD_PROGRESS_RADIUS = 14; +const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; + +export type DesktopUpdateStatusIconState = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded"; + +function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { + if (percent === null || !Number.isFinite(percent)) return 0; + return Math.min(100, Math.max(0, percent)); +} + +export function shouldShowDesktopUpdateCheckIcon({ + isAnimationLatched, + isChecking, + prefersReducedMotion, +}: { + readonly isAnimationLatched: boolean; + readonly isChecking: boolean; + readonly prefersReducedMotion: boolean; +}): boolean { + return isChecking || (isAnimationLatched && !prefersReducedMotion); +} + +export function shouldContinueDesktopUpdateCheckAnimation({ + isChecking, + prefersReducedMotion, +}: { + readonly isChecking: boolean; + readonly prefersReducedMotion: boolean; +}): boolean { + return isChecking && !prefersReducedMotion; +} + +function DesktopUpdateAvailableIcon() { + return ( + + + + ); +} + +function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) { + const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent); + const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100); + + return ( + + + + + ); +} + +function DesktopUpdateDownloadedIcon() { + return ( + + + + + + + ); +} + +export function DesktopUpdateStatusIcon({ + downloadPercent, + isCheckAnimating, + onCheckAnimationIteration, + status, +}: { + readonly downloadPercent?: number | null; + readonly isCheckAnimating?: boolean; + readonly onCheckAnimationIteration?: AnimationEventHandler; + readonly status: DesktopUpdateStatusIconState; +}) { + if (status === "available") return ; + if (status === "downloading") { + return ; + } + if (status === "downloaded") return ; + + return ( + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 191f30438dd6..c5cffd8110b5 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,6 +1,7 @@ -import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { isElectron } from "../../env"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { SidebarMenuItem } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + DesktopUpdateStatusIcon, + shouldContinueDesktopUpdateCheckAnimation, + shouldShowDesktopUpdateCheckIcon, +} from "./DesktopUpdateStatusIcon"; + +function resolveSidebarUpdatePresentation({ + action, + isDownloading, + showCheckIcon, +}: { + readonly action: ReturnType; + readonly isDownloading: boolean; + readonly showCheckIcon: boolean; +}) { + const showUpdateDetails = action !== "none" || isDownloading; + const iconStatus = showCheckIcon + ? "checking" + : action === "install" + ? "downloaded" + : isDownloading + ? "downloading" + : action === "download" + ? "available" + : "idle"; + + return { + iconStatus, + showUpdateDetails, + showUpdateIconState: showUpdateDetails && !showCheckIcon, + } as const; +} function keyReleaseNoteItems(items: ReadonlyArray) { const occurrences = new Map(); @@ -110,18 +143,42 @@ export function SidebarUpdatePill() { function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); + const [checkAnimationKey, setCheckAnimationKey] = useState(0); + const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + + useEffect(() => { + if (prefersReducedMotion) { + setIsCheckAnimationLatched(false); + } else if (state?.status === "checking") { + setIsCheckAnimationLatched(true); + } + }, [prefersReducedMotion, state?.status]); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; - const isUpdateState = action !== "none" || isDownloading; - const tooltip = isUpdateState + const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ + isAnimationLatched: isCheckAnimationLatched, + isChecking: state?.status === "checking", + prefersReducedMotion, + }); + const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ + action, + isDownloading, + showCheckIcon, + }); + const tooltip = showUpdateDetails ? state ? getDesktopUpdateButtonTooltip(state) : "Update available" - : state?.status === "checking" + : showCheckIcon ? "Checking for updates…" : "Check for updates"; - const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); + const disabled = showCheckIcon + ? true + : showUpdateDetails + ? isDesktopUpdateButtonDisabled(state) + : !canCheckForUpdate(state); const handleAction = useCallback(async () => { const bridge = window.desktopBridge; @@ -209,6 +266,10 @@ function SidebarUpdateControl() { return; } + if (!prefersReducedMotion) { + setIsCheckAnimationLatched(true); + setCheckAnimationKey((key) => key + 1); + } void bridge .checkForUpdate() .then((result) => { @@ -232,7 +293,16 @@ function SidebarUpdateControl() { ); }) .finally(() => setIsActionPending(false)); - }, [action, disabled, isActionPending, state]); + }, [action, disabled, isActionPending, prefersReducedMotion, state]); + + const handleCheckAnimationIteration = useCallback(() => { + setIsCheckAnimationLatched( + shouldContinueDesktopUpdateCheckAnimation({ + isChecking: state?.status === "checking", + prefersReducedMotion, + }), + ); + }, [prefersReducedMotion, state?.status]); return ( @@ -245,29 +315,28 @@ function SidebarUpdateControl() { aria-disabled={disabled || isActionPending || undefined} disabled={disabled || isActionPending} className={cn( - "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60", - isUpdateState + "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed", + showUpdateIconState ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", + disabled && !showUpdateIconState && "opacity-60", )} onClick={handleAction} > - {action === "install" ? ( - - ) : isUpdateState ? ( - - ) : ( - - )} + } /> 0 + showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0 ? // pointer-events-auto overrides the positioner's pointer-events-none so the // release notes stay open (and scrollable) when the cursor moves into them. "pointer-events-auto max-w-none text-balance" @@ -275,7 +344,7 @@ function SidebarUpdateControl() { } side="top" style={ - isUpdateState + showUpdateDetails ? { background: "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", @@ -283,9 +352,9 @@ function SidebarUpdateControl() { } : undefined } - variant={isUpdateState ? "glass" : "default"} + variant={showUpdateDetails ? "glass" : "default"} > - {isUpdateState && state ? ( + {showUpdateDetails && state ? ( ) : ( tooltip From 80991402dcdc488838fb4d8b21171bd38d9ab0aa Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:32:25 -0700 Subject: [PATCH 031/144] fix(server): terminal subprocess polling no longer floods the PID space (#6377) --- apps/server/src/terminal/Manager.test.ts | 120 +++++++ apps/server/src/terminal/Manager.ts | 396 ++++++++++------------- 2 files changed, 299 insertions(+), 217 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed25a0880b47..47d91e4516ec 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -24,6 +24,7 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -953,6 +954,125 @@ it.layer( }), ); + it.effect("derives subprocess activity for every terminal from one shared process snapshot", () => + Effect.gen(function* () { + const runCalls: Array<{ command: string; args: ReadonlyArray }> = []; + // FakePtyAdapter assigns pids starting at 9000, so the two terminals + // opened below run as pids 9000 and 9001. + const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join( + "\n", + ); + const processRunner: ProcessRunner.ProcessRunner["Service"] = { + run: (input) => + Effect.sync(() => { + runCalls.push({ command: input.command, args: input.args }); + return { + stdout: psStdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }; + + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provide(withHostPlatform("linux")), + ); + + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* waitFor( + Effect.map( + getEvents, + (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ) && + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "python3", + ), + ), + "1200 millis", + ); + yield* waitFor( + Effect.sync(() => runCalls.length >= 3), + "1200 millis", + ); + + // Every spawn is the shared table snapshot — no per-terminal `pgrep` + // or per-child `ps -p` invocations. + expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true); + }), + ); + + it.effect("keeps last known subprocess state when the process snapshot fails", () => + Effect.gen(function* () { + let failSnapshots = false; + let failedCalls = 0; + const processRunner: ProcessRunner.ProcessRunner["Service"] = { + run: () => + Effect.sync(() => { + if (failSnapshots) failedCalls += 1; + return { + stdout: failSnapshots ? "" : " 100 9000 vim", + stderr: "", + code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }; + + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provide(withHostPlatform("linux")), + ); + + yield* manager.open(openInput()); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ), + ), + "1200 millis", + ); + + failSnapshots = true; + yield* waitFor( + Effect.sync(() => failedCalls >= 3), + "1200 millis", + ); + + // A failed snapshot is not authoritative: no terminal flips to idle. + const activityEvents = (yield* getEvents).filter((event) => event.type === "activity"); + expect(activityEvents.length).toBeGreaterThan(0); + expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true); + }), + ); + it.effect("caps persisted history to configured line limit", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(3); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b63..64c2dbb913fb 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -89,12 +89,21 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass detail !== null) + .join(", "); + return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`; } } @@ -610,125 +619,102 @@ function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { ); } -function parseFirstChildPidFromPgrep(stdout: string): number | null { +interface TerminalProcessTableSnapshot { + readonly childrenByParent: ReadonlyMap>; + readonly commandById: ReadonlyMap; +} + +function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { + const childrenByParent = new Map(); + const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { - const n = Number.parseInt(line.trim(), 10); - if (Number.isInteger(n) && n > 0) { - return n; - } + // `comm=` is the final column and may itself contain spaces, so only the + // first two tokens are structural. + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + const ppid = Number(match[2]); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + commandById.set(pid, (match[3] ?? "").trim()); + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); } - return null; + return { childrenByParent, commandById }; } -function windowsInspectSubprocess( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.Effect< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processNameById = new Map(); - const childrenByParent = new Map(); - for (const line of result.stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - processNameById.set(pid, nameRaw?.trim() ?? ""); - const children = childrenByParent.get(parentPid) ?? []; - children.push(pid); - childrenByParent.set(parentPid, children); - } - const directChildren = childrenByParent.get(terminalPid) ?? []; - const childPid = directChildren[0]; - if (childPid === undefined) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processIds = new Set([terminalPid]); - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const pid of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(pid)) continue; - processIds.add(pid); - pending.push(pid); - } - } - const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - } as const; - }), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - cause, - terminalPid, - command: "powershell", - }), - ), - ); +function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot { + const childrenByParent = new Map(); + const commandById = new Map(); + for (const line of stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + commandById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); + } + return { childrenByParent, commandById }; } -const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( +function deriveSubprocessInspectResult( + snapshot: TerminalProcessTableSnapshot, terminalPid: number, platform: NodeJS.Platform, +): TerminalSubprocessInspectResult { + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + }; +} + +const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; + +// Resolve `ps` to an absolute path once at startup. Spawning by bare name +// walks every PATH entry per spawn (one failed posix_spawn per directory +// until the hit), which is measurable at a 1s poll cadence on long PATHs. +const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) return candidate; + } + return "ps"; +}); + +const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* ( + psCommand: string, ): Effect.fn.Return< - TerminalSubprocessInspectResult, + TerminalProcessTableSnapshot, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner + const result = yield* processRunner .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], + command: psCommand, + args: ["-eo", "pid=,ppid=,comm="], timeout: "1 second", - maxOutputBytes: 262_144, + maxOutputBytes: 524_288, outputMode: "truncate", timeoutBehavior: "timedOutResult", }) @@ -737,120 +723,66 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func (cause) => new TerminalSubprocessCheckError({ cause, - terminalPid, command: "ps", }), ), ); - - let childPid: number | null = null; - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); - } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - childPid = pid; - break; - } - } - } - - if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - - const runComm = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "comm="], - timeout: "1 second", - maxOutputBytes: 8_192, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - - const commResult = yield* Effect.exit(runComm); - let rawComm: string | null = null; - if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { - rawComm = commResult.value.stdout.trim(); - } - - if (!rawComm || rawComm.length === 0) { - const runArgs = processRunner.run({ + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + // Not authoritative: an empty or partial table would mark every terminal + // idle and clear its registered process ids. Failing skips the tick. + return yield* new TerminalSubprocessCheckError({ command: "ps", - args: ["-p", String(childPid), "-o", "args="], - timeout: "1 second", - maxOutputBytes: 16_384, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, }); - const argsResult = yield* Effect.exit(runArgs); - if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { - const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; - rawComm = first.length > 0 ? first : null; - } } - - const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } - } - } else { - processIds.add(childPid); - } - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - }; + return parsePosixProcessTable(result.stdout); }); -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - if (platform === "win32") { - return yield* windowsInspectSubprocess(terminalPid, platform); +const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")( + function* (): Effect.fn.Return< + TerminalProcessTableSnapshot, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner + > { + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: "1500 millis", + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + command: "powershell", + }), + ), + ); + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + // Not authoritative: an empty or partial table would mark every terminal + // idle and clear its registered process ids. Failing skips the tick. + return yield* new TerminalSubprocessCheckError({ + command: "powershell", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, + }); } - return yield* posixInspectSubprocess(terminalPid, platform); - }); -} + return parseWindowsProcessTable(result.stdout); + }, +); function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; @@ -1227,12 +1159,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessInspector = - options.subprocessInspector ?? - ((terminalPid) => - defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); + // One process-table snapshot per poll tick, shared across every terminal. + // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and + // can exhaust the PID space on hosts with many sessions (#6332). + const fetchProcessTableSnapshot = ( + platform === "win32" + ? windowsProcessTableSnapshot() + : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) + ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); + const customSubprocessInspector = options.subprocessInspector; + const acquireSubprocessInspector: Effect.Effect< + TerminalSubprocessInspector, + TerminalSubprocessCheckError + > = + customSubprocessInspector !== undefined + ? Effect.succeed(customSubprocessInspector) + : Effect.map( + fetchProcessTableSnapshot, + (snapshot): TerminalSubprocessInspector => + (terminalPid) => + Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), + ); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; @@ -2064,6 +2011,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return; } + const inspectorOption = yield* acquireSubprocessInspector.pipe( + Effect.map(Option.some), + Effect.catch((reason) => + Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { + reason, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(inspectorOption)) { + return; + } + + const subprocessInspector = inspectorOption.value; + const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( session: TerminalSessionState & { pid: number }, ) { From 1add47b322ab1dfb5010bb363613650176b88088 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:22:57 +0530 Subject: [PATCH 032/144] fix(web): add copying terminal selection with ctrl+c in the web app (#5638) --- .../src/components/ThreadTerminalDrawer.tsx | 18 ++--- .../settings/SettingsFontPreviews.tsx | 1 - apps/web/src/contextMenuFallback.test.ts | 36 +++++++++- apps/web/src/contextMenuFallback.ts | 27 +++++++ apps/web/src/localApi.test.ts | 10 +++ apps/web/src/localApi.ts | 10 ++- apps/web/src/terminal/ghostty/surface.test.ts | 5 +- apps/web/src/terminal/ghostty/surface.ts | 72 +++++++++++++++++-- packages/contracts/src/ipc.ts | 1 + 9 files changed, 159 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c59f682c415e..87f0ed4ae706 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -440,7 +440,6 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), - onCopy: (text) => handleCopy(text), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), }; @@ -668,17 +667,6 @@ export function TerminalViewport({ })(); } - function handleCopy(text: string): void { - void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - }); - } - function handleData(data: string): void { void (async () => { const result = await writeTerminal(data); @@ -696,6 +684,12 @@ export function TerminalViewport({ return; } clearSelectionAction(); + // A copy shortcut that clears the selection (Ctrl+C) must also close + // the context menu that appears with the selection, but a clear that + // never opened a menu must not dismiss an unrelated one. + if (selectionActionMenuOpenRef.current) { + void localApi?.contextMenu.close(); + } } const handleMouseUp = (event: MouseEvent) => { diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 05ea2c9f04e3..a678c2ad5540 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu onData: echo, onResize: noop, onSelectionChange: noop, - onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), // Tab keeps walking the settings page instead of feeding the echo loop. beforeKey: (event) => event.key !== "Tab", onLinkActivate: noop, diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 29596e72a9ff..d36f1a1d11b6 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { showContextMenuFallback } from "./contextMenuFallback"; +import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; @@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename:project-b"); }); }); + +describe("dismissContextMenu", () => { + it("resolves an open menu with null", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete" }, + ]); + expect(findButton("Rename")).toBeTruthy(); + + dismissContextMenu(); + + await expect(selectionPromise).resolves.toBeNull(); + expect(findButton("Rename")).toBeUndefined(); + }); + + it("is a no-op when no menu is open", async () => { + dismissContextMenu(); + expect(findButton("Rename")).toBeUndefined(); + }); + + it("dismisses the prior menu when a new one opens", async () => { + const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]); + expect(findButton("First")).toBeTruthy(); + + const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]); + + await expect(firstPromise).resolves.toBeNull(); + expect(findButton("First")).toBeUndefined(); + expect(findButton("Second")).toBeTruthy(); + + dismissContextMenu(); + await expect(secondPromise).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 50f4340e22dc..769826e3999c 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H return false; } +// Only one fallback menu exists at a time in the renderer; the active one is +// tracked so a state change (for example a terminal selection clearing) can +// dismiss it with the same result as an outside click or Escape. +let activeContextMenuDismiss: (() => void) | null = null; + +/** + * Closes the currently open fallback context menu, resolving its show() with + * null (the same result as dismissing by outside click or Escape). No-op when + * no fallback menu is open. + */ +export function dismissContextMenu(): void { + activeContextMenuDismiss?.(); + activeContextMenuDismiss = null; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -114,11 +129,16 @@ export function showContextMenuFallback( let isDisposed = false; let canDismissFromPointer = false; + const dismiss = () => cleanup(null); + const cleanup = (result: T | null) => { if (isDisposed) { return; } isDisposed = true; + if (activeContextMenuDismiss === dismiss) { + activeContextMenuDismiss = null; + } document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); @@ -299,6 +319,13 @@ export function showContextMenuFallback( document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); + // Only one fallback menu can be open at a time: a new show must dismiss + // any prior one, or its DOM and listeners leak and close() can only ever + // reach the newest menu. + if (activeContextMenuDismiss) { + activeContextMenuDismiss(); + } + activeContextMenuDismiss = dismiss; requestAnimationFrame(() => { canDismissFromPointer = true; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 064b927031d6..9220252cb20e 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -13,12 +13,14 @@ const showContextMenuFallbackMock = position?: { x: number; y: number }, ) => Promise >(); +const dismissContextMenuMock = vi.fn<() => void>(); const requestConfirmDialogMock = vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise | undefined>(); vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, + dismissContextMenu: dismissContextMenuMock, })); vi.mock("./confirmDialog", () => ({ @@ -85,6 +87,14 @@ describe("LocalApi", () => { expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); + it("dismisses an open browser context menu without a desktop bridge", async () => { + const { createLocalApi } = await import("./localApi"); + + await createLocalApi().contextMenu.close(); + + expect(dismissContextMenuMock).toHaveBeenCalledOnce(); + }); + it("uses the themed confirmation host when it is available", async () => { requestConfirmDialogMock.mockResolvedValue(true); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 5c8f4ec9da8c..863388106a3e 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,7 +1,7 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; import { requestConfirmDialog } from "./confirmDialog"; -import { showContextMenuFallback } from "./contextMenuFallback"; +import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; @@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi { } return showContextMenuFallback(items, position); }, + // A native desktop menu blocks keyboard input and closes on outside + // interaction, so nothing to do there; the DOM fallback needs an explicit + // dismiss when the state behind it goes away. + close: async () => { + if (!window.desktopBridge) { + dismissContextMenu(); + } + }, }, persistence: { getClientSettings: async () => { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 31bc47bdff79..18cf95901209 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => { expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); }); - it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { - expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + it("copies with Ctrl+C and Ctrl+Shift+C elsewhere", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(true); expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( true, ); + expect(isTerminalCopyShortcut(event({}), "Linux x86_64")).toBe(false); }); it("uses the produced character instead of the physical key position", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index fc7a89c6d31e..8a9c796b948b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -333,7 +333,7 @@ export function isTerminalCopyShortcut( platform = navigator.platform, ) { if (event.key.toLowerCase() !== "c") return false; - return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; } export function isTerminalPasteShortcut( @@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; - readonly onCopy: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; } @@ -531,6 +530,8 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + private copyShortcutToken = 0; + private clearSelectionAfterCopy = false; private wheelRemainder = 0; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the @@ -901,9 +902,58 @@ export class GhosttyTerminalSurface { return; } if (isTerminalCopyShortcut(event) && this.hasSelection()) { - event.preventDefault(); + // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in + // onCopyEvent; not preventing the default keeps that path alive. WebKit + // omits the keyboard copy event without a DOM selection, so race the + // clipboard write against it the same way paste races its read. The + // Shift variant has no native event (Chrome binds Ctrl+Shift+C to + // inspect), so synthesize one with execCommand("copy"). + if (event.shiftKey) { + event.preventDefault(); + document.execCommand("copy"); + } else { + // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once + // it copies so the next Ctrl+C reaches the shell. The Shift chord and + // Cmd+C are copy-only, so they keep the selection; resetting the flag + // up front also drops any clear owed by an earlier gesture that never + // completed. + this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); + const clipboard = navigator.clipboard; + if (typeof clipboard?.writeText === "function") { + // Defer the write past the default action: the native copy event + // (dispatched synchronously with the default action) claims the + // token first when it fires, and the write covers browsers whose + // shortcut produces no copy event. Skipping a write the native + // event already handled stops a stale resolution from clobbering a + // clipboard the user filled after this copy. + const token = ++this.copyShortcutToken; + const selection = this.getSelection(); + void Promise.resolve().then(() => { + if (this.disposed || this.copyShortcutToken !== token) return; + void clipboard.writeText(selection).then( + () => { + // The write may have been superseded while in flight; only + // touch the selection if this gesture still owns the token. + if (this.disposed || this.copyShortcutToken !== token) return; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }, + () => { + // The write failed and the native event has already had its + // chance, so nothing copied and no clear is owed by this + // gesture; a newer one may have just set the flag, so only + // drop it if this gesture still owns the token. + if (this.copyShortcutToken === token) { + this.clearSelectionAfterCopy = false; + } + }, + ); + }); + } + } this.suppressedKeyCodes.add(event.code); - this.options.onCopy(this.getSelection()); return; } if (isTerminalPasteShortcut(event)) { @@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface { this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); } + private readonly onCopyEvent = (event: ClipboardEvent) => { + if (!this.hasSelection()) return; + event.preventDefault(); + event.clipboardData?.setData("text/plain", this.getSelection()); + // The native event beat any deferred write; drop the in-flight fallback. + this.copyShortcutToken += 1; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }; + private readonly onPaste = (event: ClipboardEvent) => { // Always suppress the browser's default insertion: content the textarea // would receive (for example an html-only clipboard converted to text) @@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface { this.input.addEventListener("blur", this.onBlur); this.input.addEventListener("input", this.onInput); this.input.addEventListener("paste", this.onPaste); + this.input.addEventListener("copy", this.onCopyEvent); this.input.addEventListener("compositionstart", this.onCompositionStart); this.input.addEventListener("compositionend", this.onCompositionEnd); this.canvas.addEventListener("pointerdown", this.onPointerDown); @@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface { this.input.removeEventListener("blur", this.onBlur); this.input.removeEventListener("input", this.onInput); this.input.removeEventListener("paste", this.onPaste); + this.input.removeEventListener("copy", this.onCopyEvent); this.input.removeEventListener("compositionstart", this.onCompositionStart); this.input.removeEventListener("compositionend", this.onCompositionEnd); this.canvas.removeEventListener("pointerdown", this.onPointerDown); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4e4d4baa13d0..f99d4d34b4d2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1189,6 +1189,7 @@ export interface LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; + close: () => Promise; }; persistence: { getClientSettings: () => Promise; From c9063f03ea1c16e0239e1996a9b6ef611679995d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:12:39 +0200 Subject: [PATCH 033/144] perf(desktop): speed up Windows update installation (#6169) --- .../src/app/DesktopEnvironment.test.ts | 19 + apps/desktop/src/app/DesktopEnvironment.ts | 14 +- .../DesktopBackendConfiguration.test.ts | 104 ++- .../backend/DesktopBackendConfiguration.ts | 52 +- apps/desktop/src/main.ts | 2 + .../src/wsl/DesktopWslServerTree.test.ts | 323 ++++++++ apps/desktop/src/wsl/DesktopWslServerTree.ts | 226 ++++++ apps/server/package.json | 1 + docs/operations/release.md | 31 + patches/@ff-labs__fff-node@0.9.4.patch | 10 +- pnpm-lock.yaml | 14 +- scripts/build-desktop-artifact.test.ts | 407 +++++++++- scripts/build-desktop-artifact.ts | 707 +++++++++++++++--- scripts/lib/cli-external-packages.test.ts | 53 +- scripts/lib/cli-external-packages.ts | 46 +- scripts/package.json | 1 + 16 files changed, 1822 insertions(+), 188 deletions(-) create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.ts diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e1522..218e2c3e4ba2 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); @@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("uses the packaged Windows server sidecar as the backend root", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({ + platform: "win32", + isPackaged: true, + appPath: "/install/resources/app.asar", + resourcesPath: "/install/resources", + }); + + assert.equal(environment.appRoot, "/install/resources/app.asar"); + assert.equal(environment.serverRoot, "/install/resources/server.asar"); + assert.equal( + environment.backendEntryPath, + "/install/resources/server.asar/apps/server/dist/bin.mjs", + ); + }), + ); + it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 1806289a08d1..eaf390187124 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; + // Root of the tree containing apps/server/dist and node_modules for the + // backend. Equals appRoot everywhere except packaged Windows, where the + // server tree ships as the resources/server.asar sidecar (see + // scripts/build-desktop-artifact.ts) that the asar-aware + // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend + // extracts on demand (see DesktopWslServerTree). + readonly serverRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; readonly preloadPath: string; @@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; + const serverRoot = + input.isPackaged && input.platform === "win32" + ? path.join(input.resourcesPath, "server.asar") + : appRoot; const branding = resolveDesktopAppBranding({ isDevelopment, appVersion: input.appVersion, @@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, - backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + serverRoot, + backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a8..2bbde73abaa2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ @@ -115,6 +116,7 @@ const withHarness = ( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const resourcesPath = `${baseDir}/resources`; + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolvePrimary; + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + Layer.succeed( + DesktopWslServerTree.DesktopWslServerTree, + DesktopWslServerTree.DesktopWslServerTree.of({ + ensure: Effect.die("Windows primary must not extract the WSL server tree"), + }), + ), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: `${resourcesPath}/app.asar`, + platform: "win32", + resourcesPath, + }), + ), + ), + ), + ); + + assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`); + assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl reuses the primary's bootstrap token", () => withHarness( Effect.gen(function* () { @@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), Layer.provideMerge(failingFileSystemLayer), @@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => { wslOnly: true, }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Removed-Distro", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "could not be extracted"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslServerTree.layerTest({ + result: { + ok: false, + reason: "WSL server files could not be extracted", + fatal: false, + }, + }), + ), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), // isAvailable on win32 only touches the filesystem, never the spawner, // so a die-stub is enough to satisfy the layer's deps. diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e55..bcce731a5953 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl never, | DesktopEnvironment.DesktopEnvironment | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslServerTree.DesktopWslServerTree | FileSystem.FileSystem > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; // Bind to 0.0.0.0 inside WSL so the backend is reachable both via // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 @@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...buildObservabilityFragment(input.observabilitySettings), }; - // In packaged builds environment.appRoot is .../resources/app.asar — an - // archive FILE. The Windows primary reads its entry through - // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain - // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks - // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) - // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is - // already a real directory, so this is a no-op. - const wslAppRoot = environment.isPackaged - ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") - : environment.appRoot; + // In packaged builds the server tree ships inside resources/server.asar — + // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE + // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which + // can't read an asar, so materialize (or reuse) the extracted copy of the + // sidecar before preflighting. In dev the server tree is the real checkout + // directory and ensure returns it unchanged. + const serverTree = yield* wslServerTree.ensure; + const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot; const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); - const preflight = yield* runWslPreflight({ - distro: input.distro, - windowsEntryPath: wslEntryPath, - windowsRepoRoot: wslAppRoot, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. - allowBuild: !environment.isPackaged, - }); + const preflight = serverTree.ok + ? yield* runWslPreflight({ + distro: input.distro, + windowsEntryPath: wslEntryPath, + windowsRepoRoot: wslAppRoot, + // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and + // attached to the Windows artifact — see build-desktop-artifact.ts), so the + // WSL backend never needs a compiler, node-gyp, or network on first launch. + // Compiling from source is a dev-only convenience: a checkout has no shipped + // prebuilt, and developers have the toolchain. In packaged builds we instead + // surface a clear diagnostic if the prebuilt can't load (unsupported + // arch/distro), rather than silently dropping into a fragile runtime build. + allowBuild: !environment.isPackaged, + }) + : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const); // Every operation after preflight uses the same concrete distro. In // default-tracking mode this closes the race where the system default @@ -610,6 +613,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. @@ -665,6 +669,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }); @@ -727,6 +732,7 @@ export const make = Effect.gen(function* () { return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }).pipe( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74d..14caeed8a9a1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts new file mode 100644 index 000000000000..8c1a5b020b1e --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -0,0 +1,323 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopWslServerTree from "./DesktopWslServerTree.ts"; + +// The service reads packaged Windows roots through the (asar-aware, in +// Electron) fs, so a plain directory named server.asar exercises the full +// extraction path under plain Node. + +const environmentLayer = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: input.baseDir, + platform: "win32", + processArch: "x64", + appVersion: input.appVersion ?? "1.2.3", + appPath: "/repo", + isPackaged: input.isPackaged ?? true, + resourcesPath: input.resourcesPath, + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: input.baseDir, + T3CODE_MODE: "desktop", + }), + ), + ), + ); + +const withTempDir = ( + run: (tempDir: string) => Effect.Effect, +): Effect.Effect< + A, + E | PlatformError.PlatformError, + FileSystem.FileSystem | Exclude +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-wsl-server-tree-test-", + }); + return yield* run(tempDir); + }).pipe(Effect.scoped); + +const ensureWith = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* tree.ensure; + }).pipe( + Effect.provide(DesktopWslServerTree.layer.pipe(Layer.provideMerge(environmentLayer(input)))), + ); + +describe("DesktopWslServerTree", () => { + it.effect("bounds entry work across an eight-way nested tree", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const visited = yield* Ref.make(0); + + yield* DesktopWslServerTree.forEachBoundedTree([{ depth: 0, id: "root" }], (node) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const current = yield* Ref.updateAndGet(active, (count) => count + 1); + yield* Ref.update(maxActive, (maximum) => Math.max(maximum, current)); + yield* Ref.update(visited, (count) => count + 1); + }), + () => + Effect.gen(function* () { + // Give every task in the current batch a chance to overlap. + yield* Effect.yieldNow; + if (node.depth === 4) return []; + return Array.from({ length: 8 }, (_, index) => ({ + depth: node.depth + 1, + id: `${node.id}.${String(index)}`, + })); + }), + () => Ref.update(active, (count) => count - 1), + ), + ); + + assert.equal(yield* Ref.get(active), 0); + assert.equal(yield* Ref.get(maxActive), 8); + assert.equal(yield* Ref.get(visited), 4_681); + }), + ); + + it.effect("returns the server root unchanged when it is a plain directory (dev)", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: tempDir, + isPackaged: false, + }); + assert.isTrue(result.ok); + assert.isFalse(result.ok && result.root.endsWith(".asar")); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("extracts an archive root into a version-keyed state directory", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + yield* fileSystem.makeDirectory(path.join(serverRoot, "node_modules/effect"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "node_modules/effect/package.json"), + "{}", + ); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + + assert.isTrue(result.ok); + const root = result.ok ? result.root : ""; + assert.include(root, path.join("wsl-server-tree", "1.2.3")); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "server-entry"); + const dep = yield* fileSystem.exists(path.join(root, "node_modules/effect/package.json")); + assert.isTrue(dep); + const marker = yield* fileSystem.readFileString( + path.join(root, "t3code-wsl-server-tree.json"), + ); + assert.include(marker, '"version":"1.2.3"'); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("serializes concurrent extraction callers and publishes one complete tree", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const serverRoot = path.join(resourcesPath, "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + + const results = yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* Effect.all([tree.ensure, tree.ensure], { concurrency: "unbounded" }); + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + ), + ), + ); + + assert.isTrue(results.every((result) => result.ok)); + const roots = results.flatMap((result) => (result.ok ? [result.root] : [])); + assert.lengthOf(new Set(roots), 1); + assert.equal( + yield* fileSystem.readFileString(path.join(roots[0] ?? "", "apps/server/dist/bin.mjs")), + "server-entry", + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reuses a completed extraction instead of copying again", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "v1"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(first.ok); + + // Mutate the source; a reused tree must keep the first copy. + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "v2-should-not-appear", + ); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "v1"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sweeps stale version directories and leftover partials after extraction", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "x"); + + // T3CODE_HOME is set to tempDir, so the desktop state dir resolves to + // /userdata (no .t3 segment). + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.0.0"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3.partial"), { recursive: true }); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(result.ok); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.0.0"))); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3.partial"))); + assert.isTrue(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-extracts when the app version changes", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "old"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.3", + }); + assert.isTrue(first.ok); + + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "new"); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.4", + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + assert.include(root, "1.2.4"); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "new"); + // The previous version's tree is gone. + const treeRoot = path.dirname(root); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a retryable failure when the archive cannot be read", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* ensureWith({ + baseDir: tempDir, + // resources dir exists but server.asar does not + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isFalse(result.ok); + if (!result.ok) { + assert.include(result.reason, "could not be extracted"); + assert.isFalse(result.fatal); + } + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + const leftovers = yield* fileSystem + .readDirectory(treeRoot) + .pipe(Effect.orElseSucceed(() => [])); + assert.deepStrictEqual(leftovers, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts new file mode 100644 index 000000000000..0b87f7bf1fe0 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -0,0 +1,226 @@ +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 PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +// Packaged Windows builds ship the server tree inside resources/server.asar +// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in +// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL +// backend launches plain `wsl.exe -- node`, which cannot read an asar +// archive. This service materializes the archive into a real, version-keyed +// directory the first time the WSL backend starts, and reuses it afterwards — +// so only users who enable WSL ever pay for a loose copy of the server tree. +// +// Reading through Electron's patched fs also transparently returns the +// contents of files that electron-builder/asar left in the server.asar.unpacked +// sibling (native binaries), so a single walk of the archive yields the +// complete tree. + +export type WslServerTreeResult = + | { readonly ok: true; readonly root: string } + | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; + +const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; +const COPY_CONCURRENCY = 8; + +const Marker = Schema.Struct({ version: Schema.String }); +const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); +const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); + +export class DesktopWslServerTreeExtractError extends Schema.TaggedErrorClass()( + "DesktopWslServerTreeExtractError", + { + targetDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to extract the WSL server tree to ${this.targetDir}.`; + } +} + +export class DesktopWslServerTree extends Context.Service< + DesktopWslServerTree, + { + // Resolves the directory the WSL backend should treat as the app root + // (the directory containing apps/server/dist and node_modules). In dev + // the checkout already is that directory; packaged Windows builds extract + // server.asar on first use. + readonly ensure: Effect.Effect; + } +>()("@t3tools/desktop/wsl/DesktopWslServerTree") {} + +// Child scheduling stays here instead of inside `visit`, so nested directories +// cannot create independent concurrency pools. The LIFO work list also keeps +// traversal memory proportional to the remaining frontier rather than the +// number of active fibers. +export const forEachBoundedTree = ( + roots: ReadonlyArray, + visit: (node: Node) => Effect.Effect, E, R>, +): Effect.Effect => + Effect.gen(function* () { + const pending = [...roots]; + while (pending.length > 0) { + const batch = pending.splice(-COPY_CONCURRENCY); + const children = yield* Effect.forEach(batch, visit, { + concurrency: COPY_CONCURRENCY, + }); + for (const entries of children) { + pending.push(...entries); + } + } + }); + +interface CopyTreeEntry { + readonly sourcePath: string; + readonly targetPath: string; +} + +// Copy using only operations supported by Electron's asar-patched fs. Symlinks +// are not expected because the sidecar is installed with a hoisted, physical +// layout; anything that is neither a file nor a directory is skipped. +const copyTree = ( + fs: FileSystem.FileSystem, + join: (first: string, ...rest: string[]) => string, + from: string, + to: string, +): Effect.Effect => + forEachBoundedTree( + [{ sourcePath: from, targetPath: to }], + ({ sourcePath, targetPath }) => + Effect.gen(function* () { + const info = yield* fs.stat(sourcePath); + if (info.type === "Directory") { + yield* fs.makeDirectory(targetPath, { recursive: true }); + const entries = yield* fs.readDirectory(sourcePath); + return entries.map((entry) => ({ + sourcePath: join(sourcePath, entry), + targetPath: join(targetPath, entry), + })); + } + if (info.type === "File") { + // Read and write stay in the same bounded task, so at most eight file + // buffers can be retained while their writes complete. + const bytes = yield* fs.readFile(sourcePath); + yield* fs.writeFile(targetPath, bytes); + } + return []; + }), + ); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const join = environment.path.join; + + const serverRoot = environment.serverRoot; + const needsExtraction = environment.isPackaged && environment.platform === "win32"; + const treeRoot = join(environment.stateDir, "wsl-server-tree"); + const version = environment.appVersion; + const versionDir = join(treeRoot, version); + + // Remove sibling trees left behind by previous app versions (and aborted + // extractions). Best-effort: a locked file must not block the backend. + const sweepStale = Effect.gen(function* () { + const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry !== version), + (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const markerMatches = Effect.gen(function* () { + const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME)); + const marker = yield* decodeMarker(raw); + return marker.version === version; + }).pipe(Effect.orElseSucceed(() => false)); + + const extract = Effect.gen(function* () { + yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`); + yield* fs.makeDirectory(treeRoot, { recursive: true }); + // Keep the temporary tree beside the target so rename is atomic. Cleanup + // is owned explicitly because a scoped temp-directory finalizer treats the + // successful rename (and therefore missing original path) as an error. + const partialDir = yield* fs.makeTempDirectory({ + directory: treeRoot, + prefix: `.${version}.extract-`, + }); + yield* Effect.gen(function* () { + yield* copyTree(fs, join, serverRoot, partialDir); + const markerJson = yield* encodeMarker({ version }); + yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`); + // The marker is written before the rename, so a directory named after + // the version is complete by construction. + yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(partialDir, versionDir); + }).pipe( + Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`); + }).pipe( + Effect.mapError( + (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }), + ), + ); + + // Serialize concurrent ensure calls (backend restarts can overlap): the + // first caller extracts, later callers see the marker and reuse the tree. + const gate = yield* Semaphore.make(1); + + const ensure: Effect.Effect = gate + .withPermits(1)( + Effect.gen(function* () { + if (!needsExtraction) { + return { ok: true, root: serverRoot } as const; + } + if (yield* markerMatches) { + yield* sweepStale; + return { ok: true, root: versionDir } as const; + } + const result = yield* extract.pipe( + Effect.map(() => ({ ok: true, root: versionDir }) as const), + // Retryable: transient antivirus locks and slow disks are the common + // causes, and the backend manager already bounds preflight retries. + Effect.catch((error) => + Effect.succeed({ + ok: false, + reason: `WSL server files could not be extracted to ${versionDir}: ${ + error.cause instanceof Error ? error.cause.message : String(error.cause) + }`, + fatal: false, + } as const), + ), + ); + if (result.ok) { + yield* sweepStale; + } + return result; + }), + ) + .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); + + return DesktopWslServerTree.of({ ensure }); +}); + +export const layer = Layer.effect(DesktopWslServerTree, make); + +export interface DesktopWslServerTreeTestStub { + readonly result?: WslServerTreeResult; +} + +export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => + Layer.effect( + DesktopWslServerTree, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return DesktopWslServerTree.of({ + ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), + }); + }), + ); diff --git a/apps/server/package.json b/apps/server/package.json index 7a508a38effb..eb4dc7dd35ec 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", + "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", "yaml": "catalog:" }, diff --git a/docs/operations/release.md b/docs/operations/release.md index 1d8768f59d0a..1cec84054cb0 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available. - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. +### Windows payload topology and update validation + +Windows packages the bundled server and only its runtime-external/native +dependency closure in `resources/server.asar`. Native modules and helper +executables declared as unpacked by that archive must be present at the matching +paths below `resources/server.asar.unpacked`. The Windows-native backend reads +the archive in place through Electron. WSL cannot read ASAR files, so enabling +the WSL backend extracts the server tree once into the desktop state directory +under `wsl-server-tree/` and reuses the completed version until the app +is updated. + +The artifact builder rejects a Windows package when any of these invariants +break: + +- `resources/server.asar` is absent or does not contain the server entry. +- Any file marked unpacked in the ASAR header is absent from + `resources/server.asar.unpacked`. +- On same-architecture Windows builds, the packaged primary cannot load the fff + native library from inside `server.asar` through its `.unpacked` sibling. +- The isolated, extracted sidecar cannot load the server entry with plain Node. +- The external Windows resource monitor is absent. +- The unpacked Windows application contains more than 80 files. + +Cross-architecture Windows builds retain every structural and extracted-sidecar +check, but skip executing the target Electron binary. A same-architecture build +for each release target must exercise the primary native-load probe. + +NSIS differential packaging remains enabled. A sidecar layout transition can +produce a larger one-time download; subsequent small releases retain their +blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. + ## 0) npm OIDC trusted publishing setup (CLI) The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch index 2d0c16133eb8..74c132926d90 100644 --- a/patches/@ff-labs__fff-node@0.9.4.patch +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1 import { fileURLToPath } from "node:url"; import { getLibFilename, getNpmPackageName } from "./platform.js"; /** -@@ -46,6 +46,14 @@ function getPackageDir() { +@@ -46,6 +46,16 @@ function getPackageDir() { // Fallback: assume we're one level deep in src/ return dirname(currentDir); } +function resolveUnpackedAsarPath(binaryPath) { -+ const asarSegment = `${sep}app.asar${sep}`; -+ if (!binaryPath.includes(asarSegment)) { ++ const pathSegments = binaryPath.split(sep); ++ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar")); ++ if (asarIndex === -1) { + return binaryPath; + } -+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); ++ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`; ++ const unpackedPath = pathSegments.join(sep); + return existsSync(unpackedPath) ? unpackedPath : binaryPath; +} /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eab1715c13e..2c79aea36a0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 - '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 + '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 @@ -463,7 +463,7 @@ importers: version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 - version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -473,6 +473,9 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + msgpackr-extract: + specifier: 3.0.4 + version: 3.0.4 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -913,6 +916,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@electron/asar': + specifier: ^3.4.1 + version: 3.4.1 '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -12789,7 +12795,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': + '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -19143,7 +19149,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true msgpackr@2.0.4: optionalDependencies: @@ -19237,7 +19242,6 @@ snapshots: node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 - optional: true node-gyp-build@4.8.4: optional: true diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 6b04d6587086..2b9fd3e02585 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + BundleNotSelfContainedError, BuildCommandFailedError, createStageWorkspaceConfig, createStagePatchedDependencies, @@ -26,6 +27,7 @@ import { LinuxIconResizeError, MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, + packWindowsServerAsar, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -44,9 +46,17 @@ import { resolvePackageManagerUserAgent, stageLinuxIconSize, STAGE_INSTALL_ARGS, - WINDOWS_ASAR_UNPACK, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, + validateWindowsPackagedPayload, + WindowsPrimaryNativeProbeError, + WindowsPackagedPayloadValidationError, + WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT, + WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + WINDOWS_SERVER_EXTRA_RESOURCES, + WINDOWS_SERVER_ASAR_RESOURCE, + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + WINDOWS_SERVER_RESOURCE_SOURCE_DIR, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -88,6 +98,54 @@ function iconResizeSpawnerLayer( ); } +const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { + readonly copyUnpackedNatives: boolean; + readonly serverEntrySource?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-windows-payload-test-", + }); + const sourceDir = path.join(tempDir, "server-source"); + const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs"); + const nativePath = path.join(sourceDir, "node_modules/native/addon.node"); + yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n"); + yield* fs.writeFileString(nativePath, "native-binary"); + + const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE); + yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath }); + + const stageDistDir = path.join(tempDir, "dist"); + const packagedAppDir = path.join(stageDistDir, "win-unpacked"); + const resourcesDir = path.join(packagedAppDir, "resources"); + yield* fs.makeDirectory(path.join(resourcesDir, "resource-monitor"), { recursive: true }); + yield* fs.copyFile(generatedAsarPath, path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE)); + if (input.copyUnpackedNatives) { + yield* fs.copy( + `${generatedAsarPath}.unpacked`, + path.join(resourcesDir, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`), + ); + } + yield* fs.writeFileString( + path.join(resourcesDir, "resource-monitor/t3-resource-monitor.exe"), + "monitor", + ); + const appExecutableName = "t3code.exe"; + yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron"); + yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); + + return { + stageDistDir, + packagedAppDir, + sourceDir, + generatedAsarPath, + appExecutableName, + } as const; +}); + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { libc: ["glibc"], }, }); - // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the - // staged install must fetch its native optional deps (e.g. ffi-rs) too. + // The Windows app stage only serves the desktop main process; the server + // sidecar stage is the one that needs Linux natives (below). assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { supportedArchitectures: { - os: ["win32", "linux"], + os: ["win32"], cpu: ["x64"], - libc: ["glibc"], }, }); - assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { - supportedArchitectures: { - os: ["win32", "linux"], - cpu: ["arm64"], - libc: ["glibc"], + // The server sidecar stage bundles the same-architecture WSL (Linux, + // glibc) backend, so its install must fetch Linux native optional deps + // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar + // packing and runtime extraction without symlinks. + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["x64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", }, - }); + ); + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["arm64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", + }, + ); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { supportedArchitectures: { os: ["darwin"], @@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", + ]); + assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server"); + assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [ + { + from: "apps/desktop/prod-resources/windows-server", + to: ".", + filter: ["server.asar", "server.asar.unpacked/**/*"], + }, ]); }); @@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, ); + // All platforms keep app.asar fully packed; Windows ships the server + // tree as the hand-packed server.asar sidecar in extraResources instead + // of unpacking thousands of loose files at install time. assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); - assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + assert.notProperty(win, "asarUnpack"); + assert.deepStrictEqual(win.extraResources, [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, + ...WINDOWS_SERVER_EXTRA_RESOURCES, + ]); + assert.deepStrictEqual(win.nsis, { differentialPackage: true }); + // Native binaries and helper executables cannot load from inside an + // asar; everything else stays packed. The Claude SDK platform packages + // and .bin shims never ship. + assert.equal( + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}", + ); + assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", + ]); // Linux must register the renderer schemes so the generated .desktop // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. assert.deepStrictEqual((linux.linux as Record).protocols, [ @@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); + yield* packWindowsServerAsar({ + sourceDir: fixture.sourceDir, + asarPath: secondAsarPath, + }); + const [firstAsar, secondAsar] = yield* Effect.all([ + fs.readFile(fixture.generatedAsarPath), + fs.readFile(secondAsarPath), + ]); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + assert.deepStrictEqual(result.unpackedFiles, ["node_modules/native/addon.node"]); + assert.isBelow(result.fileCount, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT); + assert.deepStrictEqual(secondAsar, firstAsar); + }), + ), + ); + + it.effect("probes fff through the packaged Windows primary instead of helper executables", () => { + const commands: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly cwd?: string; + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const primaryProbe = commands.find( + (command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1", + ); + if (primaryProbe === undefined) return assert.fail("Windows primary probe was not spawned"); + + assert.equal( + primaryProbe.command, + path.join(fixture.packagedAppDir, fixture.appExecutableName), + ); + assert.deepStrictEqual(primaryProbe.args.slice(0, 3), [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + ]); + assert.include(primaryProbe.args[3], "FileFinder.create"); + assert.equal( + primaryProbe.args[4], + path.join( + fixture.packagedAppDir, + "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js", + ), + ); + assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir); + assert.equal(primaryProbe.options.env?.NODE_PATH, ""); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { + const commands: Array<{ + readonly command: string; + readonly options: { + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }); + + assert.isFalse( + commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"), + ); + assert.isTrue( + commands.some( + (command) => + command.command === process.execPath && command.options.env?.NODE_PATH === "", + ), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("rejects a cross-architecture Windows payload without its primary executable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const executablePath = path.join(fixture.packagedAppDir, fixture.appExecutableName); + yield* fs.remove(executablePath); + + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPrimaryNativeProbeError); + assert.equal(error.executablePath, executablePath); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ), + ); + + it.effect("rejects a packaged sidecar whose ASAR-unpacked native is missing", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "unpacked-native-missing"); + assert.deepStrictEqual(error.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + }), + ), + ); + + it.effect("rejects directories in place of packaged executable files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const nativePath = path.join( + fixture.packagedAppDir, + "resources/server.asar.unpacked/node_modules/native/addon.node", + ); + yield* fs.remove(nativePath); + yield* fs.makeDirectory(nativePath); + + const nativeError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); + assert.equal(nativeError.reason, "unpacked-native-missing"); + assert.deepStrictEqual(nativeError.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + + yield* fs.remove(nativePath, { recursive: true }); + yield* fs.writeFileString(nativePath, "native-binary"); + const resourceMonitorPath = path.join( + fixture.packagedAppDir, + "resources/resource-monitor/t3-resource-monitor.exe", + ); + yield* fs.remove(resourceMonitorPath); + yield* fs.makeDirectory(resourceMonitorPath); + + const resourceMonitorError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); + assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); + assert.deepStrictEqual(resourceMonitorError.missingFiles, [ + "resource-monitor/t3-resource-monitor.exe", + ]); + }), + ), + ); + + it.effect("rejects a Windows payload that regresses above the file-count budget", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + fileLimit: 2, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "file-limit-exceeded"); + assert.isAbove(error.fileCount ?? 0, 2); + }), + ), + ); + + it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + serverEntrySource: 'import "t3code-deliberately-missing-package";\n', + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, BundleNotSelfContainedError); + assert.include(error.output, "t3code-deliberately-missing-package"); + }), + ), + ); + it.effect("preserves both Linux icon resize failures with structural context", () => { const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); // The self-containment check runs the packaged tree in a scratch directory. Its -// own node_modules holds the unpacked externals and must be ignored, but any +// own node_modules holds the sidecar externals and must be ignored, but any // node_modules *above* it would let Node's parent walk satisfy an import that is // missing from the package, so the probe refuses to run in that case. it("lists ancestor node_modules, nearest first, excluding the start directory", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c86f0c38cb52..0cda9766f372 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,8 +4,16 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; +import { + createPackageWithOptions, + extractAll, + getRawHeader, + statFile, + type DirectoryRecord, +} from "@electron/asar"; + import { fromYaml } from "@t3tools/shared/schemaYaml"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; @@ -20,8 +28,8 @@ import { } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({ allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), }); type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; @@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = { /** * Imported by every server module, so it is inlined in any correctly bundled * build. Its absence means the bundle went back to externalizing its - * dependencies, which the unpack globs do not cover. + * dependencies, which the sidecar's selected runtime closure does not cover. */ const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); +const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30); + +const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = ` +const { join } = await import("node:path"); +const { pathToFileURL } = await import("node:url"); +const { FileFinder } = await import(pathToFileURL(process.argv[1]).href); +const probeRoot = process.argv[2]; +const result = FileFinder.create({ + basePath: probeRoot, + frecencyDbPath: join(probeRoot, "frecency.mdb"), + historyDbPath: join(probeRoot, "history.mdb"), + disableWatch: true, + disableMmapCache: true, + disableContentIndexing: true, +}); +if (!result.ok) throw new Error(result.error); +result.value.destroy(); +`; export class ExternalizedBundleError extends Schema.TaggedErrorClass()( "ExternalizedBundleError", { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, ) { override get message(): string { - return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; + return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; } } @@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass()( + "WindowsServerSidecarPackError", + { + asarPath: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to pack the Windows server sidecar at ${this.asarPath}.`; + } +} + +export class WindowsPrimaryNativeProbeError extends Schema.TaggedErrorClass()( + "WindowsPrimaryNativeProbeError", + { + executablePath: Schema.String, + exitCode: Schema.Number, + output: Schema.String, + }, +) { + override get message(): string { + return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`; + } +} + +const WindowsPackagedPayloadValidationReason = Schema.Literals([ + "packaged-app-missing", + "sidecar-missing", + "sidecar-invalid", + "unpacked-native-missing", + "resource-monitor-missing", + "file-limit-exceeded", +]); + +export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorClass()( + "WindowsPackagedPayloadValidationError", + { + reason: WindowsPackagedPayloadValidationReason, + packagedAppDir: Schema.String, + missingFiles: Schema.optionalKey(Schema.Array(Schema.String)), + fileCount: Schema.optionalKey(Schema.Int), + fileLimit: Schema.optionalKey(Schema.Int), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + if (this.reason === "file-limit-exceeded") { + return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`; + } + if (this.reason === "unpacked-native-missing") { + return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`; + } + if (this.reason === "resource-monitor-missing") { + return "Windows packaged payload is missing the resource monitor executable."; + } + if (this.reason === "sidecar-invalid") { + return "Windows packaged payload contains an invalid server.asar sidecar."; + } + if (this.reason === "sidecar-missing") { + return "Windows packaged payload is missing resources/server.asar."; + } + return `Windows packaged application directory was not found at ${this.packagedAppDir}.`; + } +} + export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass()( "WslNodePtyManifestReadError", { @@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + // Windows stages the server sidecar below prod-resources so electron-builder + // can copy it using project-relative extraResources matchers. Keep those + // staging inputs out of app.asar; they are emitted once at resources/. + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", ] as const; -// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot -// read inside an asar archive, so everything it loads must be on the real -// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the -// server bundle externalized its runtime deps and the Linux Node would fail with -// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached -// node-pty. -// -// The CLI bundle now inlines its JS dependencies, so the only things that still -// have to be loose are the server bundle itself and the packages the bundle -// leaves external — derived from the same list the bundler uses, so the two -// cannot drift apart. -export const WINDOWS_ASAR_UNPACK = [ - "apps/server/dist/**", - ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, +// Windows ships the server tree (bundle + node_modules) as a separate +// resources/server.asar sidecar instead of loose files: the NSIS installer +// then extracts a handful of large archives instead of thousands of small +// files, which dominates install (and update) time. The Windows primary runs +// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE +// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily +// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree). +export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; +// dlopen/spawn need real files, so native modules, shared libraries, and +// helper executables live in the server.asar.unpacked sibling (the standard +// asar redirect convention). Everything else stays packed. +export const WINDOWS_SERVER_ASAR_UNPACK_GLOB = + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; +// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK +// platform packages are dead weight (see above), and node_modules/.bin shims +// are never spawned at runtime (and are symlinks on POSIX build hosts, which +// the asar extraction path deliberately does not support). +export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", +] as const; +export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; +export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; +export const WINDOWS_SERVER_EXTRA_RESOURCES = [ + { + // Copy the archive and its .unpacked sibling from one parent directory. + // Mapping the .unpacked directory as an independent FileSet silently + // omitted it from Windows packages even though electron-builder copied + // the adjacent archive. + from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR, + to: ".", + filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`], + }, ] as const; export const DESKTOP_EXTRA_RESOURCES = [ { @@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: { readonly allowBuilds?: Record; readonly patchedDependencies?: Record; readonly overrides?: Record; + // The Windows server sidecar stage runs both the Windows primary and the + // WSL Linux backend from one dependency tree, so it needs win32 + linux + // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, + // symlink-free) node_modules: the tree gets packed into server.asar and + // later extracted for WSL, and neither step can rely on pnpm's + // symlink/junction layout surviving the trip. + readonly linuxServerBackend?: boolean; }): StageWorkspaceConfig { - const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; + const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; - // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node - // process that loads Linux-native optional deps at runtime (e.g. - // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those - // optional packages in the staged production install. + // Linux AppImages execute a Linux/glibc Node process that loads + // Linux-native optional deps at runtime. Keep libc explicit so pnpm + // includes those optional packages in the staged production install. const supportedArchitectures = platform === "linux" ? { @@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: { cpu: hostCpu, libc: ["glibc"], } - : platform === "win" + : linuxServerBackend ? { os: Array.from(new Set([hostOs, "linux"])), cpu: hostCpu, @@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: { ? { patchedDependencies } : {}), ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), + ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), }; } @@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin ); const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( - function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) { + function* (input: { readonly asarPath: string; readonly verbose: boolean }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // electron-builder names this win-unpacked, win-arm64-unpacked, and so on. - const distEntries = yield* fs - .readDirectory(input.stageDistDir) - .pipe(Effect.orElseSucceed(() => [] as Array)); - let unpackedRoot: string | null = null; - for (const entry of distEntries) { - const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked"); - if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - unpackedRoot = candidate; - break; - } - } - // Nothing to verify rather than silently passing: a packaging layout change - // should surface here instead of turning the check into a no-op. - if (unpackedRoot === null) { - return yield* new BundleNotSelfContainedError({ - exitCode: -1, - output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`, - }); - } - const probeRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-bundle-selfcheck-", }); + const extractedApp = path.join(probeRoot, "extracted"); const probeApp = path.join(probeRoot, "app"); - yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp); + yield* Effect.try({ + try: () => extractAll(input.asarPath, extractedApp), + catch: (cause) => + new BundleNotSelfContainedError({ + exitCode: -1, + output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`, + }), + }); + // Keep the existing symlink isolation guard even though the sidecar stage + // is hoisted and should be physical. A future package-manager layout change + // must not let the probe resolve through the build tree. + yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp); // Guard the guard: if anything above the probe provides a node_modules, a // missing dependency would resolve there and the check would pass while the @@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // missing dependency shows up, without starting a server or touching disk // state. It does not cover lazily imported externals: node-pty is checked // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node - // and the bun adapters are only covered by the unpack globs and the - // inlined-native check below. + // and the bun adapters are covered by the shared runtime-external closure + // and emitted-bundle checks. yield* runCommand( ChildProcess.make( process.execPath, @@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel env: { ...process.env, NODE_PATH: "" }, }, ), - { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, + { + label: "server sidecar self-containment check (node bin.mjs --version)", + verbose: input.verbose, + }, ).pipe( // Printing a version should be immediate. A regression that blocks (on // stdin, a port, a lock) would otherwise hang release CI until the job @@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( directories: { buildResources: "apps/desktop/resources", }, - // Only the Windows WSL backend needs files outside the asar (see - // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack - // extracts native libraries, which fff-node finds in app.asar.unpacked. - ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), - extraResources: DESKTOP_EXTRA_RESOURCES, + // All platforms keep app.asar fully packed; electron-builder's default + // smart unpack extracts native libraries, which loaders find in + // app.asar.unpacked. Windows additionally ships the server tree as the + // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE). + extraResources: [ + ...DESKTOP_EXTRA_RESOURCES, + ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []), + ], }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "win") { buildConfig.npmRebuild = false; + // Keep blockmap-based differential downloads enabled while changing the + // installed file topology. The optimization is in the payload shape, not + // in trading update bandwidth for install speed. + buildConfig.nsis = { differentialPackage: true }; const winConfig: Record = { target: [target], icon: "icon.ico", @@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( ); }); +// Stage and pack the Windows server sidecar: the bundled server plus a hoisted +// install of only its runtime-external/native dependency closure for win32 and +// WSL Linux. The Windows primary runs from the archive through the asar-aware +// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. +// Shipping one packed archive instead of thousands of loose files is what +// makes the NSIS install/update fast. +export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { + readonly sourceDir: string; + readonly asarPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + yield* Effect.tryPromise({ + try: () => + createPackageWithOptions(input.sourceDir, input.asarPath, { + dot: true, + unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB, + globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] }, + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); + const unpackedDirPath = `${input.asarPath}.unpacked`; + if (!(yield* fs.exists(unpackedDirPath))) { + return yield* new WindowsServerSidecarPackError({ + asarPath: input.asarPath, + cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`), + }); + } +}); + +export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: { + readonly stageRoot: string; + readonly repoRoot: string; + readonly serverDistDir: string; + readonly arch: typeof BuildArch.Type; + readonly appVersion: string; + readonly runtimeExternalDependencies: Record; + readonly fffNodeVersion: string; + readonly allowBuilds: Record; + readonly patchedDependencies: Record; + readonly overrides: Record; + readonly wslPrebuildPath: string | undefined; + readonly asarPath: string; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const serverStageDir = path.join(input.stageRoot, "server"); + yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true }); + yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist")); + + const sidecarDependencies = { + ...input.runtimeExternalDependencies, + // The sidecar serves two processes: the Windows primary loads win32 + // natives, and the WSL backend loads the matching Linux natives (fff via + // ffi-rs) from the extracted copy of this same tree. + ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), + ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), + }; + const sidecarPatchedDependencies = createStagePatchedDependencies( + input.patchedDependencies, + sidecarDependencies, + ); + const sidecarPackageJson = { + name: "t3code-server", + version: input.appVersion, + private: true, + packageManager: rootPackageJson.packageManager, + dependencies: sidecarDependencies, + }; + const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson); + yield* fs.writeFileString( + path.join(serverStageDir, "package.json"), + `${sidecarPackageJsonString}\n`, + ); + const sidecarWorkspaceConfig = createStageWorkspaceConfig({ + platform: "win", + arch: input.arch, + allowBuilds: input.allowBuilds, + patchedDependencies: sidecarPatchedDependencies, + overrides: input.overrides, + linuxServerBackend: true, + }); + const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); + yield* fs.writeFileString( + path.join(serverStageDir, "pnpm-workspace.yaml"), + sidecarWorkspaceConfigString, + ); + if (Object.keys(sidecarPatchedDependencies).length > 0) { + yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches")); + } + + yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals..."); + const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); + yield* runCommand( + ChildProcess.make(installCommand.command, installCommand.args, { + cwd: serverStageDir, + shell: installCommand.shell, + }), + { label: "vp install --prod (server sidecar)", verbose: input.verbose }, + ); + + yield* stageWslNodePtyPrebuild({ + stageAppDir: serverStageDir, + arch: input.arch, + prebuildPath: input.wslPrebuildPath, + }); + + yield* Effect.log("[desktop-artifact] Packing server.asar..."); + yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); + yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath }); + const packedStat = yield* fs.stat(input.asarPath); + yield* Effect.log( + `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`, + ); +}); + +function collectUnpackedAsarFiles( + directory: DirectoryRecord, + parentPath = "", + output: string[] = [], +): readonly string[] { + for (const [name, entry] of Object.entries(directory.files)) { + const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`; + if ("files" in entry) { + collectUnpackedAsarFiles(entry, entryPath, output); + } else if (entry.unpacked) { + output.push(entryPath); + } + } + return output; +} + +const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pendingDirectories = [root]; + let count = 0; + + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.pop(); + if (directory === undefined) break; + const entries = yield* fs.readDirectory(directory); + for (const entry of entries) { + const entryPath = path.join(directory, entry); + const stat = yield* fs.stat(entryPath); + if (stat.type === "Directory") { + pendingDirectories.push(entryPath); + } else if (stat.type === "File") { + count += 1; + } + } + } + + return count; +}); + +export const verifyWindowsPrimaryFffNativeLoad = Effect.fn( + "desktopArtifact.verifyWindowsPrimaryFffNativeLoad", +)(function* (input: { + readonly packagedAppDir: string; + readonly asarPath: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = path.join(input.packagedAppDir, input.appExecutableName); + const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null)); + if (executableStat?.type !== "File") { + return yield* new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: "The unpacked application does not contain its expected primary executable.", + }); + } + if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return; + + const probeRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-windows-primary-native-probe-", + }); + const fffEntryPath = path.join( + input.asarPath, + "node_modules/@ff-labs/fff-node/dist/src/index.js", + ); + const probeEnv = { ...process.env }; + delete probeEnv.ELECTRON_NO_ASAR; + delete probeEnv.NODE_OPTIONS; + + yield* runCommand( + ChildProcess.make( + executablePath, + [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + WINDOWS_PRIMARY_FFF_PROBE_SOURCE, + fffEntryPath, + probeRoot, + ], + { + cwd: input.packagedAppDir, + stdout: "pipe", + stderr: "pipe", + env: { + ...probeEnv, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "", + }, + }, + ), + { + label: "Windows primary fff native-load probe", + verbose: input.verbose, + }, + ).pipe( + Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`, + }), + ), + BuildCommandFailedError: (error) => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + }), + ); +}); + +export const validateWindowsPackagedPayload = Effect.fn( + "desktopArtifact.validateWindowsPackagedPayload", +)(function* (input: { + readonly stageDistDir: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly fileLimit?: number; + readonly verbose?: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT; + const isFile = (filePath: string) => + fs.stat(filePath).pipe( + Effect.map((stat) => stat.type === "File"), + Effect.orElseSucceed(() => false), + ); + const stageEntries = yield* fs.readDirectory(input.stageDistDir); + let packagedAppDir: string | undefined; + + for (const entry of stageEntries) { + if (!entry.endsWith("-unpacked")) continue; + const candidate = path.join(input.stageDistDir, entry); + const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + if (stat?.type === "Directory") { + packagedAppDir = candidate; + break; + } + } + + if (packagedAppDir === undefined) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "packaged-app-missing", + packagedAppDir: path.join(input.stageDistDir, "win-unpacked"), + }); + } + + const resourcesDir = path.join(packagedAppDir, "resources"); + const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE); + if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-missing", + packagedAppDir, + missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE], + }); + } + + const unpackedFiles = yield* Effect.try({ + try: () => { + // The entry lookup proves the archive contains the server executable, + // while the single header walk identifies every file ASAR redirects to + // the unpacked sibling at runtime. + // @electron/asar resolves entry names using the host path separator. + // POSIX separators work on Linux/macOS but fail on Windows even when the + // entry is present in the archive. + statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs")); + return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort(); + }, + catch: (cause) => + new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause, + }), + }); + if (unpackedFiles.length === 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause: new Error("server.asar does not declare any unpacked native files"), + }); + } + + const missingFiles: string[] = []; + for (const unpackedFile of unpackedFiles) { + const unpackedPath = path.join( + resourcesDir, + `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`, + ...unpackedFile.split("/"), + ); + if (!(yield* isFile(unpackedPath))) { + missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`); + } + } + if (missingFiles.length > 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "unpacked-native-missing", + packagedAppDir, + missingFiles, + }); + } + + const resourceMonitorPath = path.join( + resourcesDir, + "resource-monitor", + resourceMonitorExecutableName("win"), + ); + if (!(yield* isFile(resourceMonitorPath))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "resource-monitor-missing", + packagedAppDir, + missingFiles: ["resource-monitor/t3-resource-monitor.exe"], + }); + } + + const fileCount = yield* countPayloadFiles(packagedAppDir); + if (fileCount > fileLimit) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "file-limit-exceeded", + packagedAppDir, + fileCount, + fileLimit, + }); + } + + yield* verifyWindowsPrimaryFffNativeLoad({ + packagedAppDir, + asarPath, + appExecutableName: input.appExecutableName, + targetArch: input.targetArch, + verbose: input.verbose ?? false, + }); + + yield* verifyPackagedBundleIsSelfContained({ + asarPath, + verbose: input.verbose ?? false, + }); + + yield* Effect.log( + `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`, + ); + return { packagedAppDir, fileCount, unpackedFiles } as const; +}); + const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options: ResolvedBuildOptions, ) { @@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cause, }), }); + const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies( + resolvedServerDependencies, + ); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => @@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // inlined. A regression to externalizing everything would also pass it, // since source-file regions still exist -- and that is the failure this // whole change exists to prevent, because those packages are not in the - // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. + // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND. // `effect` is imported by every server module, so it is inlined in any // correctly bundled build. // The list-based check above only sees packages someone already thought to @@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); - yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + if (options.platform !== "win") { + yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + } yield* Effect.log("[desktop-artifact] Staging release app..."); yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); - yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + // On Windows the server tree ships in the server.asar sidecar instead of + // app.asar (see stageWindowsServerSidecar), so the app stage omits it. + if (options.platform !== "win") { + yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + } yield* stageResourceMonitor({ repoRoot, stageResourcesDir, @@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); // electron-builder is filtering out stageResourcesDir directory in the AppImage for production - yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources"); + yield* fs.copy(stageResourcesDir, stageProdResourcesDir); const configuredMacPasskeySigning = options.platform === "mac" && options.signed @@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); } - const stageDependencies = { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the - // fff native binary through ffi-rs. The platform fff binary above is the - // host's (win32), so promote the matching Linux fff binaries too; without - // them file-finding in WSL fails to load its Linux native package. - ...(options.platform === "win" - ? resolveFffNativeDependencies( - "linux", - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ) - : {}), - }; + // Windows splits dependencies per process: app.asar carries only the + // desktop main-process runtime deps, while the server bundle's deps live in + // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux + // keep the single merged tree — their primary resolves everything from + // app.asar and there is no second consumer. + const stageDependencies = + options.platform === "win" + ? { ...resolvedDesktopRuntimeDependencies } + : { + ...resolvedServerDependencies, + ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), + }; const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, ); + const windowsServerAsarPath = + options.platform === "win" + ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE) + : undefined; const stagePackageJson: StagePackageJson = { name: "t3code", version: appVersion, @@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); - // WSL is Windows-only, so only the Windows artifact carries the Linux backend - // binary; other platforms ignore the prebuild input. - if (options.platform === "win") { - yield* stageWslNodePtyPrebuild({ - stageAppDir, + // WSL is Windows-only, so only the Windows artifact carries the server + // sidecar (which embeds the Linux node-pty prebuild); other platforms + // ignore the prebuild input. + if (options.platform === "win" && windowsServerAsarPath) { + yield* stageWindowsServerSidecar({ + stageRoot, + repoRoot, + serverDistDir: distDirs.serverDist, arch: options.arch, - prebuildPath: options.wslPrebuild, + appVersion, + runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + allowBuilds: workspaceAllowBuilds, + patchedDependencies: workspacePatchedDependencies, + overrides: resolvedOverrides, + wslPrebuildPath: options.wslPrebuild, + asarPath: windowsServerAsarPath, + verbose: options.verbose, }); } @@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // resolver has no such ambiguity: it either finds every import or it does not. // // Only Windows unpacks anything; macOS and Linux keep the whole tree inside - // the asar, where this check has nothing to look at. + // the app asar. Windows validates and executes the separately packed server + // sidecar after electron-builder copies it into the final payload. if (options.platform === "win") { - yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose }); + yield* validateWindowsPackagedPayload({ + stageDistDir, + appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, + targetArch: options.arch, + verbose: options.verbose, + }); } const stageEntries = yield* fs.readDirectory(stageDistDir); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 189634dfee61..754cd646f17d 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; + import { - CLI_EXTERNAL_PACKAGE_PREFIXES, - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, CLI_RUNTIME_EXTERNAL_PREFIXES, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, shouldBundleCliDependency, } from "./cli-external-packages.ts"; @@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => { }); // The real package is `node-gyp-build-optional-packages`, reached by prefix. - // Matching it as external while failing to unpack it is invisible on the - // Windows primary (which reads app.asar) and breaks only under WSL. + // It is transitive to a selected dependency root, so the runtime closure test + // below ensures it follows that root into the sidecar. it("treats prefix-matched siblings as external", () => { assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); }); }); -describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { - it("unpacks every external prefix from both the top level and the pnpm store", () => { - for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix); - assert.include( - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, - `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`, - prefix, - ); - } +describe("selectCliRuntimeExternalDependencies", () => { + it("keeps only runtime-external dependency roots for the Windows sidecar", () => { + assert.deepStrictEqual( + selectCliRuntimeExternalDependencies({ + "@effect/platform-bun": "1.0.0", + "@ff-labs/fff-node": "2.0.0", + effect: "3.0.0", + "node-pty": "4.0.0", + }), + { + "@ff-labs/fff-node": "2.0.0", + "node-pty": "4.0.0", + }, + ); }); - // Without the trailing `*` the globs stop covering prefix-matched siblings, - // which is exactly how a package ends up external but not unpacked. - it("keeps the trailing wildcard that matches prefix siblings", () => { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); + it("selects every external root declared by the server", () => { + assert.deepStrictEqual( + Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), + ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"], + ); }); }); -// The failure this guards is invisible on Windows and fatal under WSL. -// // An external package is loaded from the real filesystem, so its own `require` // also resolves from the real filesystem. If one of its dependencies was -// bundled away instead of left external, that dependency exists only inside -// app.asar — which the Windows primary reads transparently under -// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot. +// bundled away instead of left external, that dependency does not follow the +// selected root into the sidecar. // // Found the hard way: node-gyp-build-optional-packages requires detect-libc, // which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. @@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { // by name from this file at all, and an `exports` map can refuse the // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not // installed", which would let this test skip everything and pass while - // checking nothing. The store is also what asarUnpack globs target, so this - // reads the same tree the build packages. + // checking nothing. The store contains the dependency graph the sidecar's + // minimal production install resolves. const readInstalledPackages = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index f50718af4fed..d7a89bc408a4 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -4,14 +4,12 @@ * Two consumers derive from this list, and they must never disagree: * * - apps/server/vite.config.ts decides what stays external to the bundle. - * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar. + * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for + * the Windows server sidecar. * - * A package that is external but not unpacked still resolves on the Windows - * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar - * transparently. It fails only under WSL, where the backend is launched as plain - * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the - * drift invisible on the platform you are most likely to test on, which is why - * both consumers derive from one list instead of maintaining their own. + * A runtime package that is external but absent from the sidecar fails as soon + * as Node resolves it from the emitted bundle. Keeping both consumers on one + * list prevents packaging from drifting away from the bundle boundary. * * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover * a package's platform-specific siblings — `node-gyp-build` covers @@ -24,8 +22,8 @@ * critically — the ordinary JS packages those wrappers require. An external * package is loaded from the real filesystem, so its own `require` also * resolves from the real filesystem; a dependency that was bundled away exists - * only inside app.asar and is unreachable there. This closure is enforced by a - * test, not by inspection. + * only inside the emitted bundle and is unreachable there. This closure is + * enforced by a test, not by inspection. */ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "node-pty", @@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, ] as const; +export function isRuntimeExternalCliDependency(id: string): boolean { + return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + /** * True when `id` must stay out of the bundle. * @@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean { return !isExternalCliDependency(id); } -/** - * asar-unpack globs covering every external package. - * - * The trailing `*` is what keeps these aligned with the prefix matching above: - * without it, `node-gyp-build` would be left external by the bundler and then - * not unpacked, because the real package is `node-gyp-build-optional-packages`. - * - * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both - * paths are unpacked for the link target to exist on disk. - */ -export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap( - (prefix) => - [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, -); +/** Select direct dependency roots whose runtime closure belongs in the sidecar. */ +export function selectCliRuntimeExternalDependencies( + dependencies: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)), + ); +} /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. @@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f * check the opposite direction too. Verifying only that externals are absent * would still pass if the bundler reverted to leaving everything external: the * scan would see source-file regions, report nothing inlined, and the packaged - * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages - * are not in the unpack globs either. + * backends would then fail with ERR_MODULE_NOT_FOUND because those packages + * are not in the selected sidecar closure either. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; diff --git a/scripts/package.json b/scripts/package.json index 457a8f0d3a3f..14c4ea98e9b2 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@electron/asar": "^3.4.1", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", From 196c8ea0d642acd1db66bd57f5d98abe81d8da6e Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:43:33 +0000 Subject: [PATCH 034/144] fix(web): style sidebar action tooltips (#6371) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 82 ++++++++++++++++++----------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f35dd1fdba67..dc8be07dcad1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -366,19 +366,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - > - - + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + {presets.map((preset) => ( + + + } + > + + + Unpin thread + ) : ( ) : null} {props.settlementSupported ? ( - + + + } + > + + Settle + + Settle thread + ) : null} ) : null} From 9885a845c97325b1099b095011da8385485616f5 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:13 +0200 Subject: [PATCH 035/144] refactor(web): simplify global styling (#6381) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../check-run-agents/ui-consistency.md | 82 ++ apps/web/src/components/AgentsPanel.tsx | 18 +- .../BranchToolbarBranchSelector.tsx | 9 +- apps/web/src/components/ChatMarkdown.tsx | 23 +- apps/web/src/components/ChatView.tsx | 18 +- .../src/components/ComposerPromptEditor.tsx | 12 +- apps/web/src/components/DiffPanel.tsx | 9 +- apps/web/src/components/DiffPanelShell.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 7 +- .../src/components/NoActiveThreadState.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 13 +- .../src/components/ThreadTerminalDrawer.tsx | 9 +- .../components/chat/ComposerCommandMenu.tsx | 2 +- .../ComposerPreviewAnnotationCards.test.tsx | 14 + .../chat/ComposerPreviewAnnotationCards.tsx | 10 +- .../components/chat/ComposerStashBadge.tsx | 2 +- .../src/components/chat/ComposerStashMenu.tsx | 2 +- .../components/chat/ContextWindowMeter.tsx | 15 +- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 11 +- .../components/chat/ProviderStatusBanner.tsx | 10 +- apps/web/src/components/composerInlineChip.ts | 3 + .../diffs/StyledDiffCodeView.test.tsx | 4 +- .../components/diffs/StyledDiffCodeView.tsx | 4 +- .../src/components/files/FileBrowserPanel.tsx | 7 +- .../src/components/files/FilePreviewPanel.tsx | 5 +- .../components/preview/PreviewChromeRow.tsx | 8 +- .../pullRequest/PullRequestCodeTab.tsx | 24 +- .../pullRequest/PullRequestDetailPanel.tsx | 10 +- .../pullRequest/PullRequestListFilters.tsx | 26 +- .../pullRequest/PullRequestReviewerPicker.tsx | 5 +- .../search/ProjectContentSearchDialog.tsx | 15 +- .../settings/DiagnosticsSettings.tsx | 26 +- .../settings/KeybindingsSettings.tsx | 98 +- .../settings/ProjectSettingsPanel.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 9 +- .../settings/ProviderModelsSection.tsx | 33 +- .../settings/ProviderSettingsPanel.tsx | 10 +- .../settings/ResourceTelemetryDiagnostics.tsx | 14 +- .../settings/SettingsSidebarNav.tsx | 4 +- .../settings/SourceControlSettings.tsx | 45 +- .../components/settings/ThemeImportDialog.tsx | 8 +- .../settings/ThemeSearchSection.tsx | 30 +- .../components/settings/settingsLayout.tsx | 18 +- .../src/components/sidebar/SidebarChrome.tsx | 2 +- .../sidebar/SidebarProviderUpdatePill.tsx | 12 +- .../src/components/threadSidebarWidth.test.ts | 19 +- apps/web/src/components/ui/button.test.tsx | 15 + apps/web/src/components/ui/button.tsx | 8 + apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/input-group.tsx | 4 +- apps/web/src/components/ui/input.tsx | 6 +- apps/web/src/components/ui/menu.tsx | 10 +- apps/web/src/components/ui/popover.tsx | 2 + apps/web/src/components/ui/scroll-area.tsx | 19 +- apps/web/src/components/ui/select.tsx | 4 +- apps/web/src/components/ui/sidebar.tsx | 2 + apps/web/src/components/ui/skeleton.tsx | 2 +- apps/web/src/components/ui/toast.tsx | 27 +- apps/web/src/components/ui/toggle.tsx | 2 + apps/web/src/components/usage/UsagePage.tsx | 11 +- apps/web/src/index.css | 1101 ++++++----------- .../web/src/routes/-chatIndexTitlebar.test.ts | 7 +- apps/web/src/routes/_chat.index.tsx | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 7 +- apps/web/src/routes/settings.tsx | 2 +- apps/web/src/terminal/ghostty/surface.ts | 9 +- 69 files changed, 851 insertions(+), 1123 deletions(-) create mode 100644 .macroscope/check-run-agents/ui-consistency.md diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md new file mode 100644 index 000000000000..8ec720742759 --- /dev/null +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -0,0 +1,82 @@ +--- +title: UI Consistency +model: claude-opus-5 +effort: high +input: full_diff +tools: + - browse_code + - git_tools + - github_api_read_only + - modify_pr +include: + - "apps/web/src/**/*.ts" + - "apps/web/src/**/*.tsx" + - "apps/web/src/**/*.css" +conclusion: failure +showToolCalls: true +--- + +# UI consistency review + +Review changed web UI code and directly affected call sites for consistency with the shared component system, Tailwind ownership, and the behavioral constraints below. Apply these rules when a pull request creates, moves, or modifies controls or styling. Do not demand unrelated repository-wide cleanup. + +The goal is not to minimize CSS or class counts at any cost. The goal is to put each behavior in the smallest correct owner while preserving interaction, theming, accessibility, layout, and browser behavior. + +## Shared controls and variants + +- Prefer the core UI primitives in `apps/web/src/components/ui` over native controls or locally reconstructed primitives. In ordinary product UI, a raw ` +
{result._tag === "Success" ? ( @@ -417,14 +419,14 @@ function ExpandedWorkflowSection({ {settled}/{members.length} settled - +
{scriptOpen && canShowScript ? ( diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 05ed533acbc2..b3c1c08eb730 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -51,6 +51,7 @@ import { } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; +import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { Combobox, ComboboxEmpty, @@ -814,9 +815,11 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }} className={cn( - "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", - showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1", + getVirtualizedScrollFadeClassName({ + top: showTopBranchScrollFade, + bottom: showBottomBranchScrollFade, + }), )} style={{ maxHeight: "14rem" }} /> diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e9390ed0a8aa..53b043f3a8ff 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -449,7 +449,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { {children} -
+
-
- +
+ (); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( - + {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( @@ -1044,7 +1047,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> - + {plainText.slice(0, leadingLength)} @@ -1060,7 +1063,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> - + {firstChild.slice(0, leadingLength)} @@ -1072,7 +1075,7 @@ function MarkdownExternalLinkContent({ return ( <> - + {firstChild} @@ -1289,7 +1292,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ side="top" className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight" > -
+
{displayPath}
@@ -1699,7 +1702,7 @@ function ChatMarkdown({ return (
{rightPanelOpen && !shouldUseRightPanelSheet ? ( @@ -6273,16 +6274,17 @@ function ChatViewContent(props: ChatViewProps) { className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" style={{ bottom: composerOverlayHeight + 4 }} > - +
)}
@@ -6299,7 +6301,7 @@ function ChatViewContent(props: ChatViewProps) { >
{isDraftHeroState ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0489e8c79cdf..f6dfef2489b4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -71,6 +71,7 @@ import { import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, @@ -188,7 +189,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -326,7 +327,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -397,7 +398,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -1747,13 +1748,12 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance - // can drive it; keep everything else here. + // The wrapper owns the appearance preference; keep everything else here. "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 385d67b6b701..b929d05a719b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -825,7 +825,7 @@ export default function DiffPanel({
) : ( <> -
+
{isSelectedPatchTruncated && (

This diff was truncated because it exceeded the preview limit. The changes shown are @@ -907,10 +907,11 @@ export default function DiffPanel({ } diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 68a5855c1a28..82dddd8f41e0 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -11,7 +11,9 @@ export function NoActiveThreadState() {

diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index df65aa60d520..b91e81bc7a0b 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -26,6 +26,7 @@ import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; @@ -603,7 +604,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
0 ? ( + } > diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc8be07dcad1..2f0c5a221405 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3298,9 +3298,9 @@ export default function Sidebar() { {isSearchingThreads ? ( + ); })} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 87f0ed4ae706..1266e5ed7e94 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -31,6 +31,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -1273,13 +1274,9 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

- +
); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 3ed2a9432e48..4f32211c10cc 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -141,7 +141,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { >
{props.items.length > 0 ? ( diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx index 5bb28054e7d7..46af299073c5 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx @@ -43,4 +43,18 @@ describe("ComposerPreviewAnnotationCards", () => { expect(markup).not.toContain("localhost:3000"); expect(markup).not.toContain("Preview annotation"); }); + + it("uses the shared button contract for removal", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Remove preview annotation"'); + expect(markup).toContain('data-slot="button"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 5e9e43dcf21e..19f6a5ca308e 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; interface ComposerPreviewAnnotationCardsProps { annotations: ReadonlyArray; @@ -128,14 +129,15 @@ export function ComposerPreviewAnnotationCards({
- + ); })} diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index 79ed301a5d56..a2599ebc9f9f 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { className={cn( "rounded-full px-1.5 text-[10px] font-medium tabular-nums", props.pulsing - ? "prompt-stash-count-enter bg-primary text-primary-foreground" + ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] bg-primary text-primary-foreground motion-reduce:animate-none" : "bg-muted text-muted-foreground", )} > diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9e9238515332..fc8be327da18 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -91,7 +91,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { return ( -
+
diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 752ec4013768..f377c893ae2c 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,4 +1,4 @@ -import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -36,13 +36,10 @@ export function ContextWindowMeter(props: { delay={150} closeDelay={0} render={ - + } /> { ); expect(compactMarkup).toContain('class="h-3 sm:h-4"'); - expect(compactMarkup).not.toContain("chat-timeline-scroll-fade"); + expect(compactMarkup).not.toContain("topbar-scroll-fade"); expect(fadedMarkup).toContain('class="h-10 sm:h-12"'); - expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); + expect(fadedMarkup).toContain("topbar-scroll-fade"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9cd1c78aa4d5..e190f47569b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -591,7 +591,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "chat-timeline-scroll-fade", + topFadeEnabled && "topbar-scroll-fade", )} ListHeaderComponent={ loadEarlier !== null ? ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7c86ec630141..7ffb2bf077da 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -33,6 +33,7 @@ import { } from "../../keybindings"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; import { isProviderInstancePickerReady, @@ -598,7 +599,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -781,9 +782,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "model-picker-list scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "model-picker-list-scroll-fade-top", - showBottomScrollFade && "model-picker-list-scroll-fade-bottom", + "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2", + getVirtualizedScrollFadeClassName({ + top: showTopScrollFade, + bottom: showBottomScrollFade, + }), )} /> diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index f82c17b13ddd..1c7571b962f9 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -2,6 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -66,14 +67,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
- +
); diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index c17b3ddab3c0..3f0e8ca1ac00 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -8,6 +8,9 @@ export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; +export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = + "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index dbdd10d194f6..f0cd49abc41d 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -35,7 +35,9 @@ describe("StyledDiffCodeView", () => { />, ); - expect(testState.codeViewClassName).toBe("diff-render-surface outline-none min-h-0"); + expect(testState.codeViewClassName).toBe( + "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", + ); expect(testState.codeViewOptions).toMatchObject({ theme: "pierre-dark", stickyHeaders: true, diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 7dbd5358a0ff..14939de09820 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -292,8 +292,8 @@ export function StyledDiffCodeView({ // outside the panel clipping boundary; actual controls inside retain their own indicators. className={ className - ? `diff-render-surface outline-none ${className}` - : "diff-render-surface outline-none" + ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` + : "diff-render-surface [--code-background:var(--background)] outline-none" } options={{ ...options, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a70c..e3280c99caa3 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -78,7 +78,7 @@ function FileSearchField(props: { value: string; }) { return ( - + -
+
{relativePath ? ( -
+
-
+
- + { event.stopPropagation(); toggleFile(item.id); @@ -685,7 +684,7 @@ export function PullRequestCodeTab({ ) : ( )} - + ); }, [toggleFile], @@ -899,7 +898,7 @@ export function PullRequestCodeTab({ review.verdicts.length === 0 ? null : (
{reviewOpen ? ( -
+
+ )}
); @@ -959,7 +959,7 @@ export function PullRequestCodeTab({ * diff API offers it. */ const toolbar = ( -
+
{/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 7237b4357481..2f4e84dc3fd2 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1137,8 +1137,14 @@ export function PullRequestDetailPanel({ <> + } > diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index dd4a9d161cdc..3066eafc38a1 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -24,6 +24,7 @@ import type { ElementType } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Menu, @@ -79,29 +80,18 @@ export function PullRequestSearchInput({ onChange: (value: string) => void; }) { return ( -
- {busy ? ( - - ) : ( - - )} - + + {busy ? : } + + onChange(event.currentTarget.value)} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" - // Tracks the shared input's height at both widths, so it stays level with the icon - // button beside it rather than towering over it on wide screens. - className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:h-8" /> -
+ ); } diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index 25c663794e97..8330c87a9294 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -19,6 +19,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -131,13 +132,13 @@ export function PullRequestReviewerPicker({ />
- setQuery(event.currentTarget.value)} placeholder="Search people with access" aria-label="Search people with access" - className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring" + size="compact" />
diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 1890aab7fa79..d877d6537bda 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -11,6 +11,7 @@ import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; +import { Toggle } from "../ui/toggle"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { @@ -58,19 +59,17 @@ function SearchOptionButton(props: { readonly children: ReactNode; }) { return ( - + ); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 5bd4fdc08c4a..a472c6a8d3d7 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -273,14 +273,14 @@ function TraceIdCell({ traceId }: { traceId: string }) { copyToClipboard(traceId)} > - + } /> {copied ? "Copied" : "Copy full trace ID"} @@ -322,14 +322,14 @@ function ProcessNameCell({ style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }} > {hasChildren ? ( - + ) : (
- - @@ -702,17 +681,11 @@ function WhenExpressionBuilder({ ) : (
- - @@ -864,8 +837,7 @@ function KeybindingTableRow({ )} {isDirty ? (
+ ) : ( )} @@ -975,9 +975,8 @@ export function ResourceTelemetryDiagnostics() { - } - /> - - {children} - - - ); -} - function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -316,9 +301,8 @@ function DiscoveryItemRow({
{hasDetails ? ( + } /> @@ -216,11 +212,10 @@ export function SettingResetButton({ { event.stopPropagation(); onClick(); @@ -251,7 +246,10 @@ export function SettingsPageContainer({ return ( -
+
{children}
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 175a1c3d0620..f4a98dec86c7 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -83,7 +83,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { startExit(displayedView.key, null, displayedView.key)} > - + } /> Dismiss until provider status changes diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 3beb2a8f5130..e38d5c3749bc 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares shipped CSS with the sidebar width contract. +// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the sidebar component with its width contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -36,20 +36,13 @@ describe("thread sidebar width", () => { }); it("shows the desktop wordmark across the sidebar's full legal width range", () => { - const sidebarStyles = NodeFS.readFileSync(new URL("../index.css", import.meta.url), "utf8"); - const desktopHeaderStyles = sidebarStyles.slice( - sidebarStyles.indexOf("@media (min-width: 48rem)"), - sidebarStyles.indexOf("/* Stage-channel sidebar art"), + const sidebarSource = NodeFS.readFileSync( + new URL("./sidebar/SidebarChrome.tsx", import.meta.url), + "utf8", ); - const stageLabelThreshold = desktopHeaderStyles.match( - /@container sidebar-header \(min-width: ([\d.]+)rem\) \{\s*\.sidebar-brand-stage \{\s*display: inline-flex;/, - )?.[1]; - expect(sidebarStyles).toMatch(/\.sidebar-brand \{\s*display: none;/); - expect(desktopHeaderStyles).toMatch( - /@media \(min-width: 48rem\) \{\s*\.sidebar-brand \{\s*display: flex;/, - ); + expect(sidebarSource).toContain("hidden h-7 w-fit min-w-0 shrink-0 items-center gap-1"); + expect(sidebarSource).toContain("md:flex"); expect(THREAD_SIDEBAR_MIN_WIDTH).toBe(13 * 16); - expect(Number(stageLabelThreshold) * 16).toBeGreaterThan(THREAD_SIDEBAR_MIN_WIDTH); }); }); diff --git a/apps/web/src/components/ui/button.test.tsx b/apps/web/src/components/ui/button.test.tsx index 341d85b42bbc..e1bd89d94cdb 100644 --- a/apps/web/src/components/ui/button.test.tsx +++ b/apps/web/src/components/ui/button.test.tsx @@ -28,4 +28,19 @@ describe("button geometry tokens", () => { expect(html).toContain("size-7"); expect(html).toContain("sm:size-6"); }); + + it("owns shared compact and micro control geometry", () => { + const compact = renderToStaticMarkup(); + const micro = renderToStaticMarkup( + , + ); + + expect(compact).toContain("h-7"); + expect(compact).toContain("rounded-md"); + expect(micro).toContain("size-5"); + expect(micro).toContain("rounded-sm"); + expect(micro).toContain("text-muted-foreground"); + }); }); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 778574ba0107..9f0b4d049eda 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -16,9 +16,13 @@ const buttonVariants = cva( }, variants: { size: { + compact: + "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", icon: "size-9 sm:size-8", "icon-lg": "size-10 sm:size-9", + "icon-micro": + "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", @@ -38,6 +42,10 @@ const buttonVariants = cva( "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", ghost: "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", + "ghost-muted": + "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", + glass: + "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 324b67e64d90..cf3a46142ad2 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -170,7 +170,7 @@ function ComboboxPopup({ > diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index 2ac9ee1ed41a..04e34e5611ee 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -8,7 +8,7 @@ import { Input, type InputProps } from "~/components/ui/input"; import { Textarea, type TextareaProps } from "~/components/ui/textarea"; const inputGroupVariants = cva( - "relative inline-flex w-full min-w-0 items-center rounded-lg border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]", + "relative inline-flex w-full min-w-0 items-center rounded-[var(--control-radius)] border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--control-radius)-1px)]", { defaultVariants: { variant: "default", @@ -16,7 +16,7 @@ const inputGroupVariants = cva( variants: { variant: { default: - "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", + "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", ghost: "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background", }, diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 6edc8d4a62fc..cae3dfe62852 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; type InputProps = Omit, "size"> & { - size?: "sm" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | number; unstyled?: boolean; nativeInput?: boolean; }; @@ -20,6 +20,7 @@ function Input({ }: InputProps) { const inputClassName = cn( "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-placeholder sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", + size === "compact" && "h-7 px-[calc(--spacing(2.5)-1px)] text-xs leading-7 sm:h-7 sm:leading-7", size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", props.type === "search" && @@ -59,6 +60,9 @@ function Input({ cn( !unstyled && "relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]", + !unstyled && + size === "compact" && + "rounded-md before:rounded-[calc(var(--radius-md)-1px)]", className, ) || undefined } diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 9f7cfc8c0670..803d6c1987c1 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -36,6 +36,13 @@ function MenuPopup({ side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; }) { + const hasExplicitWidthClass = + typeof className === "string" && + className.split(/\s+/).some((classToken) => { + const utility = classToken.split(":").at(-1) ?? classToken; + return /^(?:min-|max-)?w-/.test(utility); + }); + return (
) { return (
copyToClipboard(text)} - type="button" /> } > @@ -381,24 +382,22 @@ function ToastBodyContent({ > {copyErrorText !== null ? : null} {additionalActions.map(({ id, props: { className, ...props } }) => ( - ))}
- +
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6169e1a643ca..4e636eb4ff0f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant light (&:not(.dark, .dark *)); /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ @custom-variant wco (&:is(.wco, .wco *)); @@ -102,20 +103,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; -} - -.dark { - --app-scrollbar-thumb: rgb(255 255 255 / 8%); - --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); - --glass-blur: 16px; - --glass-saturation: 1.08; -} -[data-slot="sidebar-wrapper"] { - --workspace-titlebar-content-left: calc( - var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + - var(--workspace-titlebar-control-gap) - ); + @variant dark { + --app-scrollbar-thumb: rgb(255 255 255 / 8%); + --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); + --glass-blur: 16px; + --glass-saturation: 1.08; + } } .wco { @@ -264,6 +258,179 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility surface-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility alert-glass { + --alert-glass-tint: transparent; + background: + linear-gradient( + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) + ), + color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + &[data-variant="error"] { + --alert-glass-tint: var(--destructive); + } + + &[data-variant="info"] { + --alert-glass-tint: var(--info); + } + + &[data-variant="success"] { + --alert-glass-tint: var(--success); + } + + &[data-variant="warning"] { + --alert-glass-tint: var(--warning); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility dialog-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border-color: color-mix(in srgb, var(--foreground) 10%, transparent); + box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); + + @variant dark { + border-color: color-mix(in srgb, var(--color-white) 8%, transparent); + box-shadow: + inset 0 1px rgb(255 255 255 / 4%), + 0 24px 72px -20px rgb(0 0 0 / 90%); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility dialog-backdrop { + background: color-mix(in srgb, var(--background) 60%, transparent); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + + @variant dark { + background: color-mix(in srgb, var(--background) 64%, transparent); + } +} + +@utility dropdown-glass { + background: color-mix( + in srgb, + var(--popover) 18%, + color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) + ); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility topbar-scroll-fade { + --topbar-scroll-fade-height: 2.5rem; + -webkit-mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + -webkit-mask-position: top, bottom, right; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + mask-position: top, bottom, right; + mask-repeat: no-repeat; + mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + + @variant sm { + --topbar-scroll-fade-height: 3rem; + } +} + +/* Virtualizers own their native scroll element, so they cannot use ScrollArea's + viewport fade. Keep the scrollbar lane opaque while sharing the same fade + contract across those lists. */ +@utility virtualized-scroll-fade { + -webkit-mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + -webkit-mask-position: left, right; + mask-position: left, right; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; + mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; +} + +/* Stage-channel art needs a mask and pseudo-element gradient, so keep the + behavior composable without tying it to the global components layer. */ +@utility sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); + mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + transparent 0%, + transparent 28%, + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% + ); + } +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -292,12 +459,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-glow-highlight: oklch(0.553749 0.176543 271.958); --stage-night-glow-secondary: oklch(0.345571 0.117466 273.568); --stage-night-sparkle: oklch(0.880867 0.057747 269.011); - } - .dark { - --stage-art-top: oklch(0.581473 0.149124 256.9); - --stage-art-mid: oklch(0.456509 0.159377 261.945); - --stage-art-bottom: oklch(0.291327 0.136578 267.649); + @variant dark { + --stage-art-top: oklch(0.581473 0.149124 256.9); + --stage-art-mid: oklch(0.456509 0.159377 261.945); + --stage-art-bottom: oklch(0.291327 0.136578 267.649); + } } * { @@ -311,63 +478,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ):focus-visible { @apply outline-none ring-0; } - html { + html, + body { background-color: var(--app-chrome-background); } body { @apply text-foreground relative; - background-color: var(--app-chrome-background); } } @layer components { - .sidebar-brand { - display: none; - } - - .sidebar-brand-stage { - display: none; - } - - @media (min-width: 48rem) { - .sidebar-brand { - display: flex; - } - - @container sidebar-header (min-width: 15.75rem) { - .sidebar-brand-stage { - display: inline-flex; - } - } - } - - /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. Panels whose - background differs from the app chrome (e.g. sidebar v2) override - --sidebar-stage-fade so the art fades into their own surface color. */ - .sidebar-stage-backdrop { - --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); - mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - } - - .sidebar-stage-backdrop::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0%, - transparent 28%, - color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, - color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, - color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, - color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, - color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, - var(--stage-fade) 93% - ); - } - /* Each maintainer palette gives the same line art its own material: rose vellum, forest drafting paper, marine cyanotype, copper, and violet ink. These colors stay deliberately deep at the top edge so the white stage @@ -380,16 +500,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.763402 0.163836 352.525); --stage-art-tertiary: oklch(0.70819 0.180285 311.949); --stage-art-line: oklch(0.952158 0.034194 336.179); - } - html.dark[data-theme-id="t3-chat"] { - --stage-art-top: oklch(0.540689 0.143665 347.587); - --stage-art-mid: oklch(0.396586 0.126592 347.6); - --stage-art-bottom: oklch(0.249959 0.079694 340.523); - --stage-art-highlight: oklch(0.921297 0.051708 343.229); - --stage-art-secondary: oklch(0.667398 0.165674 352.549); - --stage-art-tertiary: oklch(0.609315 0.163722 306.315); - --stage-art-line: oklch(0.945349 0.036045 341.433); + @variant dark { + --stage-art-top: oklch(0.540689 0.143665 347.587); + --stage-art-mid: oklch(0.396586 0.126592 347.6); + --stage-art-bottom: oklch(0.249959 0.079694 340.523); + --stage-art-highlight: oklch(0.921297 0.051708 343.229); + --stage-art-secondary: oklch(0.667398 0.165674 352.549); + --stage-art-tertiary: oklch(0.609315 0.163722 306.315); + --stage-art-line: oklch(0.945349 0.036045 341.433); + } } html[data-theme-id="grove"] { @@ -407,23 +527,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.665652 0.109731 156.599); --stage-night-tertiary: oklch(0.698651 0.103024 89.828); --stage-night-line: oklch(0.945336 0.041923 157.222); - } - html.dark[data-theme-id="grove"] { - --stage-art-top: oklch(0.58719 0.09869 157.426); - --stage-art-mid: oklch(0.454979 0.079031 159.756); - --stage-art-bottom: oklch(0.297856 0.050355 161.167); - --stage-art-highlight: oklch(0.952407 0.053872 158.44); - --stage-art-secondary: oklch(0.732591 0.120606 155.853); - --stage-art-tertiary: oklch(0.716282 0.116547 80.563); - --stage-art-line: oklch(0.961577 0.035285 157.03); - --stage-night-top: oklch(0.398632 0.065534 158.601); - --stage-night-mid: oklch(0.290561 0.049694 160.456); - --stage-night-bottom: oklch(0.210147 0.03173 169.818); - --stage-night-highlight: oklch(0.866303 0.057526 156.796); - --stage-night-secondary: oklch(0.586553 0.093722 157.365); - --stage-night-tertiary: oklch(0.6364 0.101769 82.985); - --stage-night-line: oklch(0.913292 0.035718 156.976); + @variant dark { + --stage-art-top: oklch(0.58719 0.09869 157.426); + --stage-art-mid: oklch(0.454979 0.079031 159.756); + --stage-art-bottom: oklch(0.297856 0.050355 161.167); + --stage-art-highlight: oklch(0.952407 0.053872 158.44); + --stage-art-secondary: oklch(0.732591 0.120606 155.853); + --stage-art-tertiary: oklch(0.716282 0.116547 80.563); + --stage-art-line: oklch(0.961577 0.035285 157.03); + --stage-night-top: oklch(0.398632 0.065534 158.601); + --stage-night-mid: oklch(0.290561 0.049694 160.456); + --stage-night-bottom: oklch(0.210147 0.03173 169.818); + --stage-night-highlight: oklch(0.866303 0.057526 156.796); + --stage-night-secondary: oklch(0.586553 0.093722 157.365); + --stage-night-tertiary: oklch(0.6364 0.101769 82.985); + --stage-night-line: oklch(0.913292 0.035718 156.976); + } } html[data-theme-id="ocean"] { @@ -434,16 +554,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.788391 0.090856 215.684); --stage-art-tertiary: oklch(0.76441 0.099607 187.893); --stage-art-line: oklch(0.976025 0.019647 212.543); - } - html.dark[data-theme-id="ocean"] { - --stage-art-top: oklch(0.59663 0.089167 233.427); - --stage-art-mid: oklch(0.461094 0.084904 243.478); - --stage-art-bottom: oklch(0.294818 0.05947 250.526); - --stage-art-highlight: oklch(0.952907 0.032224 221.27); - --stage-art-secondary: oklch(0.732079 0.09296 224.414); - --stage-art-tertiary: oklch(0.720885 0.095495 190.903); - --stage-art-line: oklch(0.961039 0.027355 219.756); + @variant dark { + --stage-art-top: oklch(0.59663 0.089167 233.427); + --stage-art-mid: oklch(0.461094 0.084904 243.478); + --stage-art-bottom: oklch(0.294818 0.05947 250.526); + --stage-art-highlight: oklch(0.952907 0.032224 221.27); + --stage-art-secondary: oklch(0.732079 0.09296 224.414); + --stage-art-tertiary: oklch(0.720885 0.095495 190.903); + --stage-art-line: oklch(0.961039 0.027355 219.756); + } } html[data-theme-id="ember"] { @@ -461,23 +581,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.641705 0.126508 44.376); --stage-night-tertiary: oklch(0.538694 0.129931 25.865); --stage-night-line: oklch(0.926348 0.046029 58.73); - } - html.dark[data-theme-id="ember"] { - --stage-art-top: oklch(0.597533 0.120694 43.455); - --stage-art-mid: oklch(0.437763 0.101287 34.86); - --stage-art-bottom: oklch(0.264269 0.055858 26.548); - --stage-art-highlight: oklch(0.929214 0.042638 55.801); - --stage-art-secondary: oklch(0.705592 0.137369 43.176); - --stage-art-tertiary: oklch(0.629583 0.158322 24.088); - --stage-art-line: oklch(0.945058 0.033906 58.824); - --stage-night-top: oklch(0.392352 0.081287 36.444); - --stage-night-mid: oklch(0.271305 0.056352 31.135); - --stage-night-bottom: oklch(0.182126 0.028154 27.774); - --stage-night-highlight: oklch(0.851007 0.061294 53.805); - --stage-night-secondary: oklch(0.560789 0.10645 42.953); - --stage-night-tertiary: oklch(0.476228 0.106656 24.165); - --stage-night-line: oklch(0.884931 0.046607 56.556); + @variant dark { + --stage-art-top: oklch(0.597533 0.120694 43.455); + --stage-art-mid: oklch(0.437763 0.101287 34.86); + --stage-art-bottom: oklch(0.264269 0.055858 26.548); + --stage-art-highlight: oklch(0.929214 0.042638 55.801); + --stage-art-secondary: oklch(0.705592 0.137369 43.176); + --stage-art-tertiary: oklch(0.629583 0.158322 24.088); + --stage-art-line: oklch(0.945058 0.033906 58.824); + --stage-night-top: oklch(0.392352 0.081287 36.444); + --stage-night-mid: oklch(0.271305 0.056352 31.135); + --stage-night-bottom: oklch(0.182126 0.028154 27.774); + --stage-night-highlight: oklch(0.851007 0.061294 53.805); + --stage-night-secondary: oklch(0.560789 0.10645 42.953); + --stage-night-tertiary: oklch(0.476228 0.106656 24.165); + --stage-night-line: oklch(0.884931 0.046607 56.556); + } } html[data-theme-id="iris"] { @@ -488,16 +608,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.745085 0.125892 298.647); --stage-art-tertiary: oklch(0.73066 0.167815 340.964); --stage-art-line: oklch(0.960278 0.024064 306.969); - } - html.dark[data-theme-id="iris"] { - --stage-art-top: oklch(0.57297 0.145973 295.185); - --stage-art-mid: oklch(0.419499 0.13752 292.131); - --stage-art-bottom: oklch(0.274235 0.095798 286.608); - --stage-art-highlight: oklch(0.916698 0.047206 300.224); - --stage-art-secondary: oklch(0.670994 0.13095 296.689); - --stage-art-tertiary: oklch(0.679357 0.165376 340.439); - --stage-art-line: oklch(0.940582 0.032921 299.076); + @variant dark { + --stage-art-top: oklch(0.57297 0.145973 295.185); + --stage-art-mid: oklch(0.419499 0.13752 292.131); + --stage-art-bottom: oklch(0.274235 0.095798 286.608); + --stage-art-highlight: oklch(0.916698 0.047206 300.224); + --stage-art-secondary: oklch(0.670994 0.13095 296.689); + --stage-art-tertiary: oklch(0.679357 0.165376 340.439); + --stage-art-line: oklch(0.940582 0.032921 299.076); + } } :is(html[data-theme-id="t3-chat"], html[data-theme-id="ocean"], html[data-theme-id="iris"]) { @@ -538,65 +658,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-sparkle: var(--stage-night-line); } - .workspace-topbar { - display: flex; - height: var(--workspace-topbar-height); - min-height: var(--workspace-topbar-height); - flex-shrink: 0; - align-items: center; - } - - /* Fade rows themselves as they pass beneath the top chrome. A mask remains - visible even when the header and timeline share the same background. */ - .chat-timeline-scroll-fade, - .settings-page-scroll-fade, - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 2.5rem; - -webkit-mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - -webkit-mask-position: top, bottom, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - mask-position: top, bottom, right; - mask-repeat: no-repeat; - mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - } - - /* The pull request list sits directly under its topbar, so the tall band the chat and - settings pages fade under would read as empty padding here. A shorter band keeps the - fade while letting the controls start near the chrome. */ - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 1.5rem; - } - @keyframes settings-search-target-pulse { 0%, 100% { @@ -607,56 +668,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - .settings-page-scroll-fade div.settings-search-target-pulse, - .settings-page-scroll-fade section.settings-search-target-pulse > div:first-child { + [data-settings-page-scroll] div.settings-search-target-pulse, + [data-settings-page-scroll] section.settings-search-target-pulse > div:first-child { animation: settings-search-target-pulse 650ms ease-in-out 2; border-radius: 0.75rem; } /* The pulse is the destination indicator; without it (reduced motion), the focus outline takes over, so exactly one indicator shows at a time. */ - .settings-page-scroll-fade .settings-search-target-pulse:focus { + [data-settings-page-scroll] .settings-search-target-pulse:focus { outline: none; } - .workspace-titlebar-controls { - position: absolute; - top: var(--workspace-controls-top); - right: var(--workspace-controls-right); - display: flex; - height: var(--workspace-topbar-height); - align-items: center; - -webkit-app-region: no-drag; - } - - .surface-subheader { - @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; - } - - [data-preview-panel-mode="inline"] [data-right-panel-surface-content] [data-surface-subheader] { - height: calc(var(--spacing) * 7); - min-height: calc(var(--spacing) * 7); - margin-bottom: calc(var(--spacing) * 3); - border-bottom-color: transparent; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem); - padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); - } - - .chat-composer-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - .chat-composer-glass-shell { --chat-composer-glass-surface: var(--card); --chat-composer-outline: rgb(0 0 0 / 8%); - position: relative; isolation: isolate; + + @variant dark { + --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); + --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); + --chat-composer-highlight: rgb(255 255 255 / 3%); + } } .chat-composer-glass-shell::before { @@ -716,8 +750,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .chat-composer-glass-host { - position: relative; box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); + + @variant dark { + box-shadow: none; + + &::after { + box-shadow: inset 0 1px var(--chat-composer-highlight); + } + } } .chat-composer-glass-host::after { @@ -747,6 +788,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-context-strip { position: relative; isolation: isolate; + + @variant dark { + &::before { + border-color: rgb(255 255 255 / 7%); + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + rgb(255 255 255 / 2%); + box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); + } + } } .chat-composer-context-strip::before { @@ -762,33 +818,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil content: ""; } - .dark .chat-composer-glass-shell { - --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); - --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); - --chat-composer-highlight: rgb(255 255 255 / 3%); - } - - .dark .chat-composer-glass-host { - box-shadow: none; - } - - .dark .chat-composer-glass-host::after { - box-shadow: inset 0 1px var(--chat-composer-highlight); - } - - .dark .chat-composer-context-strip::before { - border-color: rgb(255 255 255 / 7%); - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - rgb(255 255 255 / 2%); - box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); - } - @supports not (clip-path: shape(from 0 0, line to 1px 1px)) { .chat-composer-glass-shell-with-context::before { inset-block-end: var(--chat-composer-context-extension); @@ -806,106 +835,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); } - .dark .chat-composer-context-strip::before { - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), - color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + .chat-composer-context-strip { + @variant dark { + &::before { + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), + color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + } + } } } - .alert-glass { - --alert-glass-tint: transparent; - - background: - linear-gradient( - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) - ), - color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .alert-glass[data-variant="error"] { - --alert-glass-tint: var(--destructive); - } - - .alert-glass[data-variant="info"] { - --alert-glass-tint: var(--info); - } - - .alert-glass[data-variant="success"] { - --alert-glass-tint: var(--success); - } - - .alert-glass[data-variant="warning"] { - --alert-glass-tint: var(--warning); - } - - .dialog-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .dialog-backdrop { - background: color-mix(in srgb, var(--background) 60%, transparent); - -webkit-backdrop-filter: blur(4px); - backdrop-filter: blur(4px); - } - - .dropdown-glass { - /* - * Elevated glass needs a denser tint than broad ambient surfaces. Nesting - * the user-controlled mix inside an 18% popover tint preserves the full - * opacity setting range (40% -> 51%, 80% -> 84%, 100% -> 100%) while - * keeping high-contrast page content from blooming through menus. - */ - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - -webkit-backdrop-filter: blur(var(--glass-blur)); - backdrop-filter: blur(var(--glass-blur)); - border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 16px 40px -18px rgb(0 0 0 / 55%); - } - - .dialog-glass { - border-color: color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); - } - - .dark .dropdown-glass { - box-shadow: 0 18px 44px -18px rgb(0 0 0 / 80%); - } - - .dark .model-picker-surface.model-picker-surface { - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - } - - .dark .dialog-glass { - border-color: color-mix(in srgb, var(--color-white) 8%, transparent); - box-shadow: - inset 0 1px rgb(255 255 255 / 4%), - 0 24px 72px -20px rgb(0 0 0 / 90%); - } - - .dark .dialog-backdrop { - background: color-mix(in srgb, var(--background) 64%, transparent); - } - .settings-slider { --settings-slider-progress: 0%; --settings-slider-fill-offset: 0.5rem; @@ -1021,32 +967,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - @media (min-width: 40rem) { - .chat-timeline-scroll-fade, - .settings-page-scroll-fade { - --topbar-scroll-fade-height: 3rem; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 1.25rem); - padding-inline-end: calc(env(safe-area-inset-right) + 1.25rem); - } - } - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .alert-glass { - background: var(--background) !important; - } - .chat-composer-glass-shell::before { background: var(--chat-composer-glass-surface); } - - .dialog-glass, - .dropdown-glass { - background: var(--popover) !important; - } } } @@ -1152,15 +1076,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --terminal-foreground: var(--foreground); --terminal-cursor: rgb(38 56 78); --terminal-selection-background: rgb(37 63 99 / 20%); - --terminal-scrollbar: rgb(0 0 0 / 15%); - --terminal-scrollbar-hover: rgb(0 0 0 / 25%); @variant dark { color-scheme: dark; /* Keep the workspace in the same neutral-black family as sidebar v2. Surfaces lift from this base instead of starting from a milky gray. */ --background: var(--color-neutral-950); - --app-chrome-background: var(--background); --surface-raised: var(--secondary); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); @@ -1168,54 +1089,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.571 0.21 264); - --primary-foreground: var(--color-white); --secondary: --alpha(var(--color-white) / 4%); --secondary-foreground: var(--color-neutral-100); --muted: --alpha(var(--color-white) / 4%); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --placeholder: var(--muted-foreground); - --secondary-label: var(--muted-foreground); - --icon-muted: var(--muted-foreground); - --message-surface: var(--accent); - --message-foreground: var(--foreground); - --message-action: var(--primary); - --message-action-foreground: var(--primary-foreground); - --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); --error: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); --error-foreground: var(--color-red-400); --error-surface: color-mix(in srgb, var(--error) 16%, transparent); - --destructive: var(--error); --border: --alpha(var(--color-white) / 6%); --input: --alpha(var(--color-white) / 8%); - --ring: var(--primary); - --destructive-foreground: var(--error-foreground); - --info: var(--color-blue-500); --info-foreground: var(--color-blue-400); - --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-400); - --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); - --update: var(--primary); --update-foreground: var(--color-blue-400); --update-surface: color-mix(in srgb, var(--update) 18%, transparent); --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); --sidebar-row-hover: var(--accent); --sidebar-row-active: var(--accent); --sidebar-row-selected: var(--muted); - --sidebar-border: var(--border); --sidebar-stage-fade: var(--card); - --terminal-background: var(--background); - --terminal-foreground: var(--foreground); --terminal-cursor: rgb(180 203 255); --terminal-selection-background: rgb(180 203 255 / 25%); - --terminal-scrollbar: rgb(255 255 255 / 10%); - --terminal-scrollbar-hover: rgb(255 255 255 / 18%); } } @@ -1242,32 +1140,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-selected: var(--color-white); --sidebar-border: var(--color-zinc-200); --sidebar-stage-fade: var(--sidebar); - background-color: var(--sidebar); -} - -.dark [data-app-sidebar] { - --background: #000; - --foreground: #f1f3f7; - --card: #000; - --card-foreground: var(--foreground); - --accent: #191a1d; - --accent-foreground: #f7f9ff; - --muted: #0a0a0a; - --muted-foreground: #a3a3a3; - --border: rgb(255 255 255 / 8%); - --input: rgb(255 255 255 / 18%); - --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); - --sidebar-control-surface: var(--muted); - --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); - --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); - --sidebar-border: var(--border); - /* The stage-channel header art must ramp to THIS panel's surface, not the - global chrome background, or the fade shows a seam (same rule as the - light palette above). */ - --sidebar-stage-fade: var(--card); + + @variant dark { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 8%); + --input: rgb(255 255 255 / 18%); + --sidebar: var(--card); + --sidebar-foreground: var(--foreground); + --sidebar-muted-foreground: var(--muted-foreground); + --sidebar-control-surface: var(--muted); + --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); + --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + --sidebar-border: var(--border); + --sidebar-stage-fade: var(--card); + } } /* Theme files are expressed in app color roles and mapped to the existing @@ -1275,8 +1169,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id], -html.dark[data-theme-id] { +html[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); @@ -1340,8 +1233,6 @@ html.dark[data-theme-id] { --terminal-foreground: var(--app-theme-terminal-foreground); --terminal-cursor: var(--app-theme-terminal-cursor); --terminal-selection-background: var(--app-theme-terminal-selection-background); - --terminal-scrollbar: var(--app-theme-terminal-scrollbar); - --terminal-scrollbar-hover: var(--app-theme-terminal-scrollbar-hover); } /* T3 Chat's composer is a translucent lift over --chat-background. Route its @@ -1349,35 +1240,21 @@ html.dark[data-theme-id] { another tint from the canvas, which made the dark composer too red. */ html[data-theme-id] .chat-composer-glass-shell { --chat-composer-glass-surface: var(--app-theme-surface-raised); -} - -html[data-theme-id]:not(.dark) .chat-composer-glass-shell { --chat-composer-outline: var(--app-theme-toolbar-border); -} -html.dark[data-theme-id] .chat-composer-glass-shell { - --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); - --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + @variant dark { + --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); + --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + } } -html.dark[data-theme-id="t3-chat"] .chat-composer-glass-shell { +html[data-theme-id="t3-chat"] .chat-composer-glass-shell { /* T3 Chat's visible composer edge is a dark plum, not the stock translucent white outline. Its highlight is derived from --chat-input-gradient. */ - --chat-composer-outline: #241e28; - --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); -} - -html[data-theme-id]:not(.dark) { - color-scheme: light; -} - -html.dark[data-theme-id] { - color-scheme: dark; -} - -html[data-theme-id] body { - background-color: var(--app-chrome-background); - color: var(--foreground); + @variant dark { + --chat-composer-outline: #241e28; + --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); + } } /* Theme-token dependency probes are restored synchronously, before paint. Keep @@ -1477,8 +1354,8 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { toggle's when the trigger renders the toggle, so match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="toggle"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="tooltip-trigger"] { +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { --control-icon-color: var(--toolbar-foreground); color: var(--toolbar-foreground); } @@ -1522,19 +1399,23 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action { /* T3 Chat renders inline code and compact chat artifacts with its translucent secondary surface flattened over the light chat canvas. The raw muted and secondary tokens are substantially darker than those visible pixels. */ -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state], -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-header] { - background-color: var(--message-surface); -} +html[data-theme-id="t3-chat"] { + @variant light { + & .chat-markdown :not(pre) > code, + & [data-changed-files-state], + & [data-changed-files-header] { + background-color: var(--message-surface); + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state] { - border-color: transparent; -} + & .chat-markdown :not(pre) > code, + & [data-changed-files-state] { + border-color: transparent; + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code { - color: var(--message-foreground); + & .chat-markdown :not(pre) > code { + color: var(--message-foreground); + } + } } html[data-theme-id] .chat-markdown .chat-markdown-chrome-action:hover, @@ -1562,40 +1443,19 @@ html[data-theme-id] [data-app-sidebar] { --sidebar-row-selected: var(--app-theme-sidebar-row-selected); --sidebar-border: var(--app-theme-sidebar-border); --sidebar-stage-fade: var(--app-theme-sidebar); - background-color: var(--sidebar); -} - -/* Keep the navigation edge as quiet as the standard palettes. Theme files may - still use sidebarBorder for controls and internal separators, but the outer - divider should not become more prominent just because a palette is vivid. */ -html[data-theme-id] [data-app-sidebar] { border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); -} -html.dark[data-theme-id] [data-app-sidebar] { - border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + @variant dark { + border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + } } /* T3 Chat's panel divider is deliberately pink, and its resize affordance keeps that color while hovered. Do not neutralize this branded edge. */ -html.dark[data-theme-id="t3-chat"] [data-app-sidebar] { - border-color: var(--sidebar-border); -} - -.theme-json-key { - color: var(--app-theme-accent, var(--color-blue-600)); -} - -.theme-json-string { - color: var(--app-theme-message-action, var(--color-emerald-600)); -} - -.theme-json-number { - color: var(--app-theme-secondary-foreground, var(--color-amber-600)); -} - -.theme-json-constant { - color: var(--app-theme-accent-surface-foreground, var(--color-violet-600)); +html[data-theme-id="t3-chat"] [data-app-sidebar] { + @variant dark { + border-color: var(--sidebar-border); + } } body { @@ -1715,125 +1575,7 @@ code { background: var(--app-scrollbar-thumb-hover); } -/* Settings -> Appearance can point the composer at its own face (for example a - mono font); default follows the sans stack. Applied on the surface wrapper so - the editor and its placeholder inherit together. */ -.composer-editor-surface { - font-family: var(--font-composer, var(--font-sans)); - font-size: var(--font-size-prompt, 0.875rem); -} - -/* Touch browsers zoom the page when a focused field is under 16px, so keep - the floor there regardless of the preference. Gated on a coarse pointer: - the zoom quirk does not exist on desktop, where a narrow window must not - silently override a smaller chosen prompt size. */ -@media (max-width: 39.999rem) and (pointer: coarse) { - .composer-editor-surface { - font-size: max(var(--font-size-prompt, 1rem), 16px); - } -} - -.t3-ghostty-canvas { - cursor: text; -} - -.t3-ghostty-scrollbar { - position: absolute; - z-index: 1; - top: 4px; - right: 1px; - bottom: 4px; - width: var(--app-scrollbar-width); - cursor: default; - touch-action: none; -} - -.t3-ghostty-scrollbar-thumb { - position: absolute; - top: 0; - right: 1px; - left: 1px; - border-radius: 3px; - background: var(--app-scrollbar-thumb); - transition: background-color 120ms ease-out; -} - -.t3-ghostty-scrollbar:hover .t3-ghostty-scrollbar-thumb, -.t3-ghostty-scrollbar:focus-visible .t3-ghostty-scrollbar-thumb { - background: var(--app-scrollbar-thumb-hover); -} - -.model-picker-list::-webkit-scrollbar-track { - margin-block: 0.5rem; -} - -.model-picker-list-scroll-fade-top, -.model-picker-list-scroll-fade-bottom { - -webkit-mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - -webkit-mask-position: left, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; - mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - mask-position: left, right; - mask-repeat: no-repeat; - mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; -} - -.model-picker-list-scroll-fade-top { - --model-picker-list-scroll-mask: linear-gradient(to bottom, transparent, black var(--fade-size)); -} - -.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - black calc(100% - var(--fade-size)), - transparent - ); -} - -.model-picker-list-scroll-fade-top.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - transparent, - black var(--fade-size), - black calc(100% - var(--fade-size)), - transparent - ); -} - -.turn-chip-strip { - scrollbar-width: none; - -ms-overflow-style: none; - overscroll-behavior-x: contain; -} - -.turn-chip-strip::-webkit-scrollbar { - display: none; -} - -/* Reasoning select -- clickable label surface */ -label:has(> select#reasoning-effort) { - position: relative; -} -label:has(> select#reasoning-effort) select { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; - width: 100%; - height: 100%; -} - /* Chat markdown rendering */ -.chat-markdown { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} .chat-markdown > :first-child { margin-top: 0; @@ -1945,18 +1687,6 @@ label:has(> select#reasoning-effort) select { background-size: 4px 2px; } -.chat-markdown .chat-markdown-link-favicon { - @apply inline-flex; - width: 14px; - height: 14px; - margin-inline: 0.25em 0.2em; - vertical-align: -0.125em; -} - -.chat-markdown .chat-markdown-link-leading { - white-space: nowrap; -} - .chat-markdown blockquote { border-left: 2px solid var(--border); padding-left: 0.8rem; @@ -2001,11 +1731,7 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } -.chat-markdown a.chat-markdown-file-link { - color: var(--foreground); - text-decoration: none; -} - +.chat-markdown a.chat-markdown-file-link, .chat-markdown a.chat-markdown-file-link:hover { color: var(--foreground); text-decoration: none; @@ -2023,75 +1749,26 @@ label:has(> select#reasoning-effort) select { border-radius: 0.75rem; background: var(--muted); padding: 0.8rem 0.9rem; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre code { border: none; background: transparent; padding: 0; - font-size: 0.75rem; -} - -.chat-markdown pre { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre::-webkit-scrollbar { height: 7px; } -.chat-markdown pre::-webkit-scrollbar-track { - background: transparent; -} - .chat-markdown pre::-webkit-scrollbar-thumb { border-radius: 999px; background: color-mix(in srgb, var(--border) 78%, transparent); } -.markdown-file-link-tooltip-scroll { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar { - height: 6px; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--border) 78%, transparent); -} - -.chat-markdown .chat-markdown-codeblock { - margin: 0.65rem 0; - overflow: hidden; - border-radius: var(--radius); -} - -.chat-markdown .chat-markdown-codeblock-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.375rem 0.375rem 0 0.75rem; - color: color-mix(in srgb, var(--foreground) 72%, transparent); -} - -.chat-markdown .chat-markdown-codeblock-title { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 0.4rem; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: 0.6875rem; -} - +.chat-markdown .chat-markdown-codeblock-header, .chat-markdown .chat-markdown-chrome-action { color: color-mix(in srgb, var(--foreground) 72%, transparent); } @@ -2167,13 +1844,6 @@ label:has(> select#reasoning-effort) select { overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-table-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 0.125rem; -} - /* Prompt-stash save acknowledgement: the new count fades up from just below its resting position, once, then stops. One-shot and event-driven (React remounts the element by key on each stash) — no continuous animation. */ @@ -2188,19 +1858,6 @@ label:has(> select#reasoning-effort) select { } } -.prompt-stash-count-enter { - animation: prompt-stash-count-enter 180ms ease-out both; -} - -@media (prefers-reduced-motion: reduce) { - .prompt-stash-count-enter { - animation: none; - } - [data-slot="skeleton"]::after { - content: none; - } -} - @keyframes provider-update-pill-countdown { from { transform: scaleX(1); @@ -2210,23 +1867,6 @@ label:has(> select#reasoning-effort) select { } } -.provider-update-pill-progress { - animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; -} - -/* Diffs theme bridge (match diff surfaces to app palette) */ -.diff-panel-viewport { - background: var(--background); -} - -/* Diffs live directly on the panel canvas. Normal chat code blocks may use a - raised code surface, but carrying that fill into the diff creates a card-like - rectangle that does not belong in the panel. */ -.diff-render-surface { - --code-background: var(--background); -} - -.diff-render-file, .diff-render-surface diffs-container { border: 0; border-radius: 0; @@ -2307,40 +1947,3 @@ label:has(> select#reasoning-effort) select { .ultrathink-chroma { animation: ultrathink-chroma-shift 10s linear infinite; } - -.ultrathink-pill { - background: - linear-gradient(var(--card), var(--card)) padding-box, - var(--ultrathink-spectrum) border-box; - background-size: - 100% 100%, - 220% 220%; - background-position: - 0 0, - 0% 50%; - animation: ultrathink-rainbow 10s linear infinite; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent); -} - -.ultrathink-word { - display: inline-block; - color: transparent; - background-image: var(--ultrathink-spectrum); - background-size: 220% 220%; - background-position: 0% 50%; - background-clip: text; - -webkit-background-clip: text; - animation: ultrathink-rainbow 10s linear infinite; -} - -/* Composer chips are non-editable decorators, so the browser skips them when - painting text selection; this overlay stands in for the native highlight. */ -.composer-inline-chip[data-composer-chip-selected]::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 6px; - background-color: Highlight; - opacity: 0.3; - pointer-events: none; -} diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 5e74103a5421..803ba787116b 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the onboarding header with the shared titlebar contract. +// @effect-diagnostics nodeBuiltinImport:off +// Regression coverage compares the onboarding header with the shared titlebar contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -14,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("workspace-topbar"); + expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 12bbdf666c43..4f4da0c751ef 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -145,7 +145,7 @@ function HostedStaticOnboardingState() {
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index aee41fb696d0..66d9f0caa5da 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1308,7 +1308,8 @@ function PullRequestsRouteView() { // anchor the thread view's controls and the sidebar trigger use, so // every titlebar cluster in the app sits one shared inset from its // edge. - className="workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]" + className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]" + data-workspace-titlebar-controls > {panelToggleControls}
@@ -1828,7 +1829,7 @@ function PullRequestsColumn({
{/* The top padding is the fade band's own height (1.5rem here), the same pairing the settings page makes: at rest the controls sit fully below the mask, and only diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index f14793ba5446..a4b248c84ed9 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -75,7 +75,7 @@ function SettingsContentLayout() { {!isElectron && (
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8a9c796b948b..0bb33875568f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -578,8 +578,7 @@ export class GhosttyTerminalSurface { options: GhosttyTerminalSurfaceOptions, ): Promise { const canvas = document.createElement("canvas"); - canvas.className = "t3-ghostty-canvas"; - canvas.style.cssText = "display:block;width:100%;height:100%;"; + canvas.className = "block size-full cursor-text"; canvas.setAttribute("aria-hidden", "true"); const input = document.createElement("textarea"); @@ -592,14 +591,16 @@ export class GhosttyTerminalSurface { "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; const scrollbar = document.createElement("div"); - scrollbar.className = "t3-ghostty-scrollbar"; + scrollbar.className = + "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none"; scrollbar.setAttribute("role", "scrollbar"); scrollbar.setAttribute("aria-label", "Terminal scrollback"); scrollbar.setAttribute("aria-orientation", "vertical"); scrollbar.tabIndex = 0; scrollbar.hidden = true; const scrollbarThumb = document.createElement("div"); - scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; + scrollbarThumb.className = + "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]"; scrollbar.append(scrollbarThumb); mount.replaceChildren(canvas, input, scrollbar); From f0719072a1c6435b5a91243afc57bc8bf1f3e2b6 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 14 Aug 2026 23:56:12 +0200 Subject: [PATCH 036/144] fix(server): handle files named HEAD in git status (#6397) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 9 +++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 18e594512ee8..fc1b4d127bf1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -950,6 +950,27 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports changes to a file named HEAD", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "HEAD", "first line\n"); + yield* git(cwd, ["add", "HEAD"]); + yield* git(cwd, ["commit", "-m", "add HEAD file"]); + yield* writeTextFile(cwd, "HEAD", "first line\nsecond line\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + assert.deepInclude(status.workingTree.files, { + path: "HEAD", + insertions: 1, + deletions: 0, + }); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1489db9b3ff4..9162865370ba 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -420,9 +420,10 @@ function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } function isUnbornHeadStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); return ( - stderr.toLowerCase().includes("unknown revision") && - stderr.toLowerCase().includes("path not in the working tree") + normalized.includes("bad revision 'head'") || + (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) ); } @@ -1600,7 +1601,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff", "HEAD", "--numstat"], + ["diff", "HEAD", "--numstat", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { @@ -1642,7 +1643,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff", "HEAD", "--numstat"], + args: ["diff", "HEAD", "--numstat", "--"], }), detail: "git diff HEAD --numstat failed.", exitCode: result.exitCode, From e25021af767b10c560862fcec714cf67fb22cfae Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 18:32:12 -0400 Subject: [PATCH 037/144] feat(packaging): maintain AUR packages in-repo (#4128) --- .github/workflows/publish-aur.yml | 65 ++++++++++++++ .github/workflows/release.yml | 10 +++ README.md | 10 +++ docs/user/install.md | 8 ++ packaging/aur/.gitignore | 9 ++ packaging/aur/README.md | 20 +++++ packaging/aur/scripts/release.sh | 97 ++++++++++++++++++++ packaging/aur/t3code-bin/PKGBUILD | 101 +++++++++++++++++++++ packaging/aur/t3code-nightly-bin/PKGBUILD | 102 ++++++++++++++++++++++ 9 files changed, 422 insertions(+) create mode 100644 .github/workflows/publish-aur.yml create mode 100644 packaging/aur/.gitignore create mode 100644 packaging/aur/README.md create mode 100755 packaging/aur/scripts/release.sh create mode 100644 packaging/aur/t3code-bin/PKGBUILD create mode 100644 packaging/aur/t3code-nightly-bin/PKGBUILD diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 000000000000..62f8fd1f5470 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,65 @@ +name: Publish AUR package + +# See packaging/aur/README.md. + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + pkgrel: + required: false + default: "1" + type: string + secrets: + AUR_SSH_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + release_tag: + description: "Release tag to publish" + required: true + type: string + pkgrel: + description: "Arch package release override" + required: false + default: "1" + type: string + +permissions: + contents: read + +concurrency: + group: publish-aur + cancel-in-progress: false + +jobs: + publish: + name: Validate and publish + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + container: + image: archlinux:base-devel + + steps: + - name: Install Arch packaging tools + run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo + + - name: Checkout packaging sources + uses: actions/checkout@v6 + + - name: Create unprivileged build user + run: | + useradd --create-home builder + install -Dm0440 /dev/stdin /etc/sudoers.d/builder <<'EOF' + builder ALL=(root) NOPASSWD: /usr/bin/pacman + EOF + + - name: Validate and publish package sources + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + PKGREL: ${{ inputs.pkgrel || '1' }} + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: packaging/aur/scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ef25effc8e..6abd702bf889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -856,6 +856,16 @@ jobs: fail_on_unmatched_files: true token: ${{ github.token }} + publish_aur: + name: Publish AUR package + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + uses: ./.github/workflows/publish-aur.yml + with: + release_tag: ${{ needs.preflight.outputs.tag }} + secrets: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + deploy_web: name: Deploy hosted web app needs: [preflight, relay_public_config, release] diff --git a/README.md b/README.md index c2349e72860a..a7264ef62e97 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,20 @@ brew install --cask t3-code #### Arch Linux (AUR) +Stable: + ```bash yay -S t3code-bin ``` +Nightly: + +```bash +yay -S t3code-nightly-bin +``` + +The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). + ## Some notes We are very very early in this project. Expect bugs. diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..96776c7ea1f1 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -37,10 +37,18 @@ brew install --cask t3-code Arch Linux: +Stable: + ```bash yay -S t3code-bin ``` +Nightly: + +```bash +yay -S t3code-nightly-bin +``` + ## Providers T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want diff --git a/packaging/aur/.gitignore b/packaging/aur/.gitignore new file mode 100644 index 000000000000..e199a3b4e899 --- /dev/null +++ b/packaging/aur/.gitignore @@ -0,0 +1,9 @@ +src/ +pkg/ +*.AppImage +*.pkg.tar.zst +.SRCINFO +t3code-bin-*.png +t3code-bin-*-LICENSE +t3code-nightly-bin-*.png +t3code-nightly-bin-*-LICENSE diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 000000000000..b91da505ace2 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,20 @@ +# AUR packaging + +This directory maintains the [`t3code-bin`](https://aur.archlinux.org/packages/t3code-bin) and +[`t3code-nightly-bin`](https://aur.archlinux.org/packages/t3code-nightly-bin) packages. Both +repackage the official x86_64 AppImage from GitHub Releases. + +## Publishing + +The release workflow calls `.github/workflows/publish-aur.yml` after publishing a GitHub release; +the workflow can also be run manually for a specific tag. It selects the stable or nightly +package, then updates its version and checksums, builds it, regenerates `.SRCINFO`, and pushes it +to the AUR. + +To validate a release on Arch Linux: + +```bash +sudo pacman -Syu --needed base-devel github-cli jq namcap +GH_TOKEN=$(gh auth token) RELEASE_TAG=v0.0.33 \ + packaging/aur/scripts/release.sh +``` diff --git a/packaging/aur/scripts/release.sh b/packaging/aur/scripts/release.sh new file mode 100755 index 000000000000..427ca698ad1a --- /dev/null +++ b/packaging/aur/scripts/release.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +repo='pingdotgg/t3code' +tag="${RELEASE_TAG:?RELEASE_TAG is required}" +pkgrel="${PKGREL:-1}" + +if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + pkgname='t3code-bin' + icon_path='assets/prod/black-universal-1024.png' +elif [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then + pkgname='t3code-nightly-bin' + icon_path='assets/nightly/nightly-universal-1024.png' +else + echo "Release $tag does not publish an AUR package." + exit 0 +fi + +version="${tag#v}" +pkgver="${version//-/_}" +asset_name="T3-Code-${version}-x86_64.AppImage" +release_json="$(gh api "repos/$repo/releases/tags/$tag")" +asset_digest="$(jq -r --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .digest' <<<"$release_json")" +appimage_sha256="${asset_digest#sha256:}" + +if [[ ! "$appimage_sha256" =~ ^[0-9a-f]{64}$ ]]; then + echo "Release $tag is missing $asset_name or its SHA-256 digest." >&2 + exit 1 +fi + +work_dir="$(mktemp -d)" +trap 'rm -rf -- "$work_dir"' EXIT +gh api -H 'Accept: application/vnd.github.raw' \ + "repos/$repo/contents/$icon_path?ref=$tag" > "$work_dir/icon.png" +gh api -H 'Accept: application/vnd.github.raw' \ + "repos/$repo/contents/LICENSE?ref=$tag" > "$work_dir/LICENSE" +icon_sha256="$(sha256sum "$work_dir/icon.png" | awk '{print $1}')" +license_sha256="$(sha256sum "$work_dir/LICENSE" | awk '{print $1}')" + +package_dir="$repo_root/packaging/aur/$pkgname" +cd "$package_dir" +sed -Ei \ + -e "s/^pkgver=.*/pkgver=$pkgver/" \ + -e "s/^pkgrel=.*/pkgrel=$pkgrel/" \ + -e "/# AppImage$/s/'[0-9a-f]{64}'/'$appimage_sha256'/" \ + -e "/# icon$/s/'[0-9a-f]{64}'/'$icon_sha256'/" \ + -e "/# upstream license$/s/'[0-9a-f]{64}'/'$license_sha256'/" \ + PKGBUILD + +run_as_builder() { + if [[ "$(id -u)" == 0 ]]; then + runuser -u builder -- "$@" + else + "$@" + fi +} + +if [[ "$(id -u)" == 0 ]]; then + chown -R builder:builder "$package_dir" +fi +run_as_builder namcap PKGBUILD +run_as_builder makepkg --printsrcinfo > .SRCINFO +run_as_builder makepkg --syncdeps --cleanbuild --clean --noconfirm +run_as_builder namcap "$(run_as_builder makepkg --packagelist)" + +if [[ -z "${AUR_SSH_PRIVATE_KEY:-}" ]]; then + echo 'AUR_SSH_PRIVATE_KEY is not set; build complete, skipping publish.' + exit 0 +fi + +key_file="$work_dir/id_ed25519" +known_hosts_file="$work_dir/known_hosts" +aur_dir="$work_dir/$pkgname" +printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$key_file" +chmod 600 "$key_file" +printf '%s\n' \ + 'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \ + > "$known_hosts_file" +export GIT_SSH_COMMAND="ssh -i $key_file -o IdentitiesOnly=yes -o UserKnownHostsFile=$known_hosts_file -o StrictHostKeyChecking=yes" + +git clone "ssh://aur@aur.archlinux.org/$pkgname.git" "$aur_dir" +cp PKGBUILD .SRCINFO "$aur_dir/" +cd "$aur_dir" +git rm --ignore-unmatch LICENSE .upstream-commit t3code-icon.png +git config user.name 't3code-ci' +git config user.email 't3code-ci@users.noreply.github.com' +git add -A + +if git diff --cached --quiet; then + echo 'AUR package is already up to date.' + exit 0 +fi + +git commit -m "$pkgname: update to $pkgver-$pkgrel" +git push origin HEAD:master diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD new file mode 100644 index 000000000000..0f3d76284139 --- /dev/null +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -0,0 +1,101 @@ +# Maintainer: maria-rcks + +pkgname=t3code-bin +pkgver=0.0.33 +pkgrel=1 +pkgdesc='Desktop control surface for local coding agents' +arch=('x86_64') +url='https://github.com/pingdotgg/t3code' +license=('MIT') +depends=( + 'alsa-lib' + 'at-spi2-core' + 'cairo' + 'dbus' + 'expat' + 'gdk-pixbuf2' + 'glib2' + 'glibc' + 'gtk3' + 'hicolor-icon-theme' + 'libcups' + 'libdrm' + 'libgcc' + 'libstdc++' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'mesa' + 'nspr' + 'nss' + 'pango' + 'systemd-libs' + 'xdg-utils' + 'zlib' +) +optdepends=('openai-codex: use the system-installed Codex CLI') +provides=("t3code=$pkgver") +conflicts=('t3code') +options=('!debug' '!strip') + +_appimage="T3-Code-${pkgver}-x86_64.AppImage" +source=( + "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/$_appimage" + "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/assets/prod/black-universal-1024.png" + "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/LICENSE" +) +sha256sums=( + '415c8648f43c3d22d572f27f2c50fdc8c310ea7fcde9537b903e1e2f1c8775a1' # AppImage + '403e874556ffbecee8d1b2b5d612a874303fac791212a261bb3bd1b71d83e78d' # icon + '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license +) + +prepare() { + chmod +x "$srcdir/$_appimage" + rm -rf "$srcdir/squashfs-root" + "$srcdir/$_appimage" --appimage-extract >/dev/null + + if [[ ! -x "$srcdir/squashfs-root/AppRun" || + ! -f "$srcdir/squashfs-root/chrome-sandbox" ]]; then + echo 'The AppImage payload is missing its launcher or Chromium sandbox.' >&2 + return 1 + fi +} + +package() { + install -d "$pkgdir/opt/$pkgname" + cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/" + chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname" + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + + install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code" <<'EOF' +#!/bin/sh +exec /opt/t3code-bin/AppRun "$@" +EOF + ln -s t3code "$pkgdir/usr/bin/t3-code-desktop" + + install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ + "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code.png" + + install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' +[Desktop Entry] +Name=T3 Code +Comment=Desktop control surface for local coding agents +Exec=t3code %U +TryExec=t3code +Terminal=false +Type=Application +Icon=t3code +StartupWMClass=t3code +Categories=Development; +MimeType=x-scheme-handler/t3code; +EOF + + install -Dm644 "$srcdir/${pkgname}-${pkgver}-LICENSE" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD new file mode 100644 index 000000000000..76704be5ef5c --- /dev/null +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -0,0 +1,102 @@ +# Maintainer: maria-rcks + +pkgname=t3code-nightly-bin +pkgver=0.0.34_nightly.20260814.1095 +pkgrel=1 +pkgdesc='Nightly desktop control surface for local coding agents' +arch=('x86_64') +url='https://github.com/pingdotgg/t3code' +license=('MIT') +depends=( + 'alsa-lib' + 'at-spi2-core' + 'cairo' + 'dbus' + 'expat' + 'gdk-pixbuf2' + 'glib2' + 'glibc' + 'gtk3' + 'hicolor-icon-theme' + 'libcups' + 'libdrm' + 'libgcc' + 'libstdc++' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'mesa' + 'nspr' + 'nss' + 'pango' + 'systemd-libs' + 'xdg-utils' + 'zlib' +) +optdepends=('openai-codex: use the system-installed Codex CLI') +provides=("t3code-nightly=$pkgver") +conflicts=('t3code-nightly' 't3code') +options=('!debug' '!strip') + +_upstream_version="${pkgver/_nightly./-nightly.}" +_appimage="T3-Code-${_upstream_version}-x86_64.AppImage" +source=( + "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${_upstream_version}/$_appimage" + "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/assets/nightly/nightly-universal-1024.png" + "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/LICENSE" +) +sha256sums=( + 'c4dea5bba9ed0b51b2f60f2d4a4867e61d62b57c50ea66f2792a73112e054566' # AppImage + '7e59b6394016ef83ed1e946847769e01bf36d4062c5c5af2577fd3e228285fd9' # icon + '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license +) + +prepare() { + chmod +x "$srcdir/$_appimage" + rm -rf "$srcdir/squashfs-root" + "$srcdir/$_appimage" --appimage-extract >/dev/null + + if [[ ! -x "$srcdir/squashfs-root/AppRun" || + ! -f "$srcdir/squashfs-root/chrome-sandbox" ]]; then + echo 'The AppImage payload is missing its launcher or Chromium sandbox.' >&2 + return 1 + fi +} + +package() { + install -d "$pkgdir/opt/$pkgname" + cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/" + chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname" + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + + install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code-nightly" <<'EOF' +#!/bin/sh +exec /opt/t3code-nightly-bin/AppRun "$@" +EOF + ln -s t3code-nightly "$pkgdir/usr/bin/t3-code-nightly-desktop" + + install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ + "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code-nightly.png" + + install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' +[Desktop Entry] +Name=T3 Code Nightly +Comment=Nightly desktop control surface for local coding agents +Exec=t3code-nightly %U +TryExec=t3code-nightly +Terminal=false +Type=Application +Icon=t3code-nightly +StartupWMClass=t3code +Categories=Development; +MimeType=x-scheme-handler/t3code; +EOF + + install -Dm644 "$srcdir/${pkgname}-${pkgver}-LICENSE" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} From 74f7b434865c2d758c7b1cd5f52f4c96b76d03fb Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 15 Aug 2026 01:32:33 +0200 Subject: [PATCH 038/144] fix(web): bound OKLCH gamut mapping (#6485) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/web/src/themePalette.test.ts | 12 ++++++++++++ apps/web/src/themePalette.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 95ce10af9316..3c5897c70152 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -246,6 +246,18 @@ describe("theme files", () => { } }); + it("gamut maps extreme finite OKLCH chroma from theme files", () => { + const theme = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Extreme chroma", + appearance: "light", + colors: { accent: "oklch(0.5 1e303 0)" }, + }); + + expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)"); + expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e"); + }); + it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 00b29dce83ba..7bf4f0426bd3 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -902,7 +902,11 @@ function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { let low = 0; let high = color.C; - const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001))); + const chromaResolution = 0.000001; + const steps = Math.max( + 1, + Math.ceil(Math.log2(Math.max(color.C, chromaResolution)) - Math.log2(chromaResolution)), + ); for (let step = 0; step < steps; step += 1) { const mid = (low + high) / 2; if (isInGamut(mid)) low = mid; From 57a299a7852b430613dfdd97ba76249ee9f374d5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 20:35:34 -0400 Subject: [PATCH 039/144] feat(web): open remote environments in your local editor over SSH (#6572) Co-authored-by: Claude Fable 5 --- apps/desktop/src/electron/ElectronShell.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 28 +++ apps/desktop/src/preload.ts | 1 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + .../src/environment/RemoteOpenTargets.test.ts | 126 ++++++++++++ .../src/environment/RemoteOpenTargets.ts | 72 +++++++ apps/server/src/preview/PortScanner.test.ts | 3 + apps/server/src/server.test.ts | 14 +- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 7 + .../src/components/chat/ChatHeader.test.ts | 21 +- apps/web/src/components/chat/ChatHeader.tsx | 16 +- apps/web/src/components/chat/OpenInPicker.tsx | 100 ++++++--- .../src/components/files/FilePreviewPanel.tsx | 5 +- apps/web/src/remoteOpen.test.ts | 149 ++++++++++++++ apps/web/src/remoteOpen.ts | 189 ++++++++++++++++++ packages/contracts/src/editor.ts | 79 +++++++- packages/contracts/src/ipc.ts | 7 + packages/contracts/src/server.ts | 8 +- packages/shared/src/Net.ts | 7 + packages/ssh/src/tunnel.test.ts | 1 + scripts/dev-runner.test.ts | 1 + 24 files changed, 806 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/environment/RemoteOpenTargets.test.ts create mode 100644 apps/server/src/environment/RemoteOpenTargets.ts create mode 100644 apps/web/src/remoteOpen.test.ts create mode 100644 apps/web/src/remoteOpen.ts diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138bfa6..126be71b6d4f 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,3 +1,4 @@ +import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) +// must reach the OS handler; every other non-web scheme stays blocked. +const SAFE_EXTERNAL_PROTOCOLS = new Set([ + "http:", + "https:", + ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const scheme = remoteSchemeForEditor(id); + return scheme === undefined ? [] : [`${scheme}:`]; + }), +]); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index cb35ad19ac7f..3d9ff022c92d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -36,6 +36,7 @@ import { getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, + probeRemoteEditors, pickFolder, pickThemeFiles, setTheme, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4a1213e4ec66..0e31082afb5f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 7a39eb429275..16f7a4694afa 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,12 +3,16 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + EDITORS, + EditorId, PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, + REMOTE_CAPABLE_EDITOR_IDS, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, + payload: Schema.Undefined, + result: Schema.Array(EditorId), + // Probes THIS machine (where the renderer runs) for remote-capable editor + // CLIs, unlike the server's probe which walks the environment host's PATH. + // A Finder-launched app can miss PATH entries; an empty result makes the + // renderer fall back to VS Code only, so that fails soft. + handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { + const available: Array = []; + for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { + const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; + if (!commands) continue; + for (const command of commands) { + if (yield* isCommandAvailable(command, { env: process.env })) { + available.push(editorId); + break; + } + } + } + return available; + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2aa345ee5847..61e345b90848 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adcfb8..ed8911d40075 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( const netLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41773), findAvailablePort: (preferred) => Effect.succeed(preferred), } satisfies NetService.NetService["Service"]); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts new file mode 100644 index 000000000000..2f876b9955c1 --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -0,0 +1,126 @@ +import { it } from "@effect/vitest"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect } from "vite-plus/test"; + +import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; + +const encoder = new TextEncoder(); + +const TAILSCALE_STATUS_JSON = JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +/** Spawner whose `tailscale status --json` exits with the given output. */ +const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(input.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); + +const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => + Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }); + +const resolveTargets = (input: { + readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; + readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly hostname: string; +}) => + Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( + Effect.provideService(HostProcessHostname, input.hostname), + Effect.provide( + RemoteOpenTargets.layer.pipe( + Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + ), + ), + ); + +const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; +const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; + +describe("RemoteOpenTargets", () => { + it.effect("advertises nothing when no sshd accepts on either loopback", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + + it.effect("orders the tailnet name before the mDNS name", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, + { kind: "mdns", host: "bb-1.local" }, + ]); + }), + ); + + it.effect("accepts an sshd bound to IPv6 loopback only", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("falls back to mDNS alone when tailscale is unavailable", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: false }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("shortens an FQDN hostname to its first label for mDNS", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1.example.com", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); +}); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts new file mode 100644 index 000000000000..f70dfa68aaab --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -0,0 +1,72 @@ +/** + * RemoteOpenTargets - resolves the SSH hostnames this environment advertises + * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). + * + * The server can only check itself: sshd listening locally, tailscaled + * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a + * given name resolves from the viewer's machine is inherently client-side. + * Targets are ordered most-reachable first (tailnet name works from anywhere + * on the tailnet; `.local` only on the same LAN). + */ +import { type RemoteOpenTarget } from "@t3tools/contracts"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import { readTailscaleStatus } from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +const SSH_PORT = 22; + +export class RemoteOpenTargets extends Context.Service< + RemoteOpenTargets, + { + readonly resolveTargets: () => Effect.Effect>; + } +>()("t3/environment/RemoteOpenTargets") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const net = yield* NetService.NetService; + + const resolveTargets = Effect.gen(function* () { + // No local sshd means no name can work; advertise nothing so clients + // render a clear "no SSH route" state instead of links that hang. + // Check both loopback families: sshd can be bound IPv6-only. + const sshdListening = yield* Effect.zipWith( + net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), + net.hasListenerOnHost(SSH_PORT, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (!sshdListening) { + return []; + } + + const targets: Array = []; + + // Tailscale absent or down is the common case, not an error. + const magicDnsName = yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + if (magicDnsName !== null) { + targets.push({ kind: "tailscale", host: magicDnsName }); + } + + // os.hostname() may already be an FQDN (macOS often reports + // "Name.local"); mDNS names are always `.local`. + const hostname = yield* HostProcessHostname; + const shortHostname = hostname.split(".")[0]?.trim(); + if (shortHostname !== undefined && shortHostname.length > 0) { + targets.push({ kind: "mdns", host: `${shortHostname}.local` }); + } + + return targets; + }); + + return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); +}); + +export const layer = Layer.effect(RemoteOpenTargets, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 944cbd85a9c6..7fa15defeca9 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; const TestIntegrationNet = Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), + hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }); @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef7..89f903c4f895 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), - ...options?.layers?.externalLauncher, - }), + Layer.mergeAll( + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + ...options?.layers?.externalLauncher, + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + ), ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96b..2226449eec05 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), + Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6436a5e441ac..56ea24a4a8b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; @@ -361,6 +362,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; + const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1010,6 +1012,11 @@ const makeWsRpcLayer = ( availableEditors: yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ), + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 94fe070ee3dc..a200a2069240 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: "codething-mvp", activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(true); }); - it("hides the picker when hosted static mode has no primary environment", () => { + it("shows the picker for remote environments in deep-link mode", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + remoteOpenMode: "remote-links", + }), + ).toBe(true); + }); + + it("shows the picker's unavailable state for remote environments without an SSH route", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, + remoteOpenMode: "remote-unavailable", }), - ).toBe(false); + ).toBe(true); }); - it("hides the picker for remote environments", () => { + it("hides the picker for non-primary local backends", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(false); }); @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: undefined, activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "remote-links", }), ).toBe(false); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 643cf95ee88e..08e0422dd255 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -30,6 +30,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; + readonly remoteOpenMode: RemoteOpenMode; }): boolean { - return ( - Boolean(input.activeProjectName) && + if (!input.activeProjectName) return false; + if ( input.primaryEnvironmentId !== null && input.activeThreadEnvironmentId === input.primaryEnvironmentId - ); + ) { + return true; + } + // Remote environments get the picker in deep-link mode (or its explicit + // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it + // hidden, matching pre-remote behavior. + return input.remoteOpenMode !== "local-exec"; } export const ChatHeader = memo(function ChatHeader({ @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, ); + const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, }); const activeThreadRef = useMemo( () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8b7a96880b81..afe35e185203 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,7 +1,19 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; +import { + openRemoteEditorUrl, + useRemoteCapableEditors, + useRemoteOpenHint, + useRemoteOpenState, +} from "../../remoteOpen"; +import { useEnvironment } from "../../state/environments"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ enableShortcut?: boolean; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); + const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); + const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; + // Remote mode ignores the server's PATH probe: what matters is what runs on + // the viewing machine, which only the desktop app can probe. + const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; + const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); const options = useMemo( - () => resolveOptions(navigator.platform, availableEditors), - [availableEditors], + () => resolveOptions(navigator.platform, effectiveEditors), + [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; + if (remote.mode === "remote-unavailable") return; + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor, + host: remote.host.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + // Only record hint-seen/preferred when the shell actually accepted + // the URL (an older desktop build can refuse the editor scheme). + void openRemoteEditorUrl(url).then((opened) => { + if (!opened) return; + markRemoteHintSeen(); + setPreferredEditor(editor); + }); + return; + } const result = openInEditorMutation({ environmentId, input: { @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + markRemoteHintSeen, + openInCwd, + openInEditorMutation, + preferredEditor, + remote, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!preferredEditor) return; e.preventDefault(); - void openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor: preferredEditor, - }, - }); + void openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [ - enableShortcut, - environmentId, - keybindings, - openInCwd, - openInEditorMutation, - preferredEditor, - ]); + }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); return ( @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ className="ps-[8.5px]" size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd} + disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} onClick={() => openInEditor(preferredEditor)} > {primaryOption?.Icon && ( @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index c4ca57b805fd..19bf2d5ad167 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre import { useAssetUrlState } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ ))}
- {absolutePath && environmentId === primaryEnvironmentId ? ( + {absolutePath && + (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( + new PrimaryConnectionTarget({ + environmentId, + label: "sol", + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace("http", "ws"), + }); + +const TAILSCALE_TARGETS = [ + { kind: "tailscale", host: "sol.tail1234.ts.net" }, + { kind: "mdns", host: "sol.local" }, +] as const; + +describe("resolveRemoteOpenState", () => { + it("keeps exec behavior for a loopback primary target", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://127.0.0.1:8000"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("uses deep links for a primary target reached over the network", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("https://sol.tail1234.ts.net"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ + mode: "remote-links", + host: { kind: "tailscale", host: "sol.tail1234.ts.net" }, + }); + }); + + it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => { + // wsl-only mode binds the primary to the WSL2 NAT address; it is still + // this machine because the desktop app manages its own primary backend. + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://172.29.112.1:14369"), + sshAlias: null, + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("keeps exec behavior for desktop-local secondary backends", () => { + expect( + resolveRemoteOpenState({ + target: new BearerConnectionTarget({ + environmentId, + label: "WSL (Ubuntu)", + connectionId: "local:wsl-1", + }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("prefers the desktop SSH alias over server-advertised hosts", () => { + expect( + resolveRemoteOpenState({ + target: new SshConnectionTarget({ + environmentId, + label: "sol", + connectionId: "ssh-1", + }), + sshAlias: "sol", + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + }); + + it("reports unavailable when a remote environment advertises no hosts", () => { + for (const remoteOpenTargets of [[], undefined] as const) { + expect( + resolveRemoteOpenState({ + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets, + }), + ).toEqual({ mode: "remote-unavailable" }); + } + }); + + it("falls back to exec when the environment has no catalog entry", () => { + expect( + resolveRemoteOpenState({ + target: null, + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: undefined, + }), + ).toEqual({ mode: "local-exec" }); + }); +}); + +describe("buildRemoteOpenUrl", () => { + it("builds a vscode-remote deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + + it("uses the fork's scheme", () => { + expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( + "cursor://vscode-remote/ssh-remote+sol/tmp/x", + ); + }); + + it("roots Windows paths", () => { + expect( + buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); + }); + + it("returns undefined for editors without remote support", () => { + expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + undefined, + ); + }); +}); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts new file mode 100644 index 000000000000..dff8e9afa889 --- /dev/null +++ b/apps/web/src/remoteOpen.ts @@ -0,0 +1,189 @@ +/** + * Remote open-in-editor: when this client is not on the environment's + * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` + * deep link (local editor connects over SSH) instead of exec'ing an editor + * on the environment host. + * + * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias + * beats server-advertised names; among advertised names the tailnet MagicDNS + * name beats mDNS `.local` (server sends them in that order). + */ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + type EditorId, + type EnvironmentId, + type RemoteOpenTarget, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { useEffect, useMemo, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isLoopbackHostname } from "~/environments/primary/target"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useEnvironmentPresentation } from "~/state/presentation"; + +export interface RemoteOpenHost { + readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; + readonly host: string; +} + +export type RemoteOpenState = + | { readonly mode: "local-exec" } + | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } + | { readonly mode: "remote-unavailable" }; + +export type RemoteOpenMode = RemoteOpenState["mode"]; + +const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; +const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; + +function parseHostname(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function resolveRemoteOpenState(input: { + readonly target: ConnectionTarget | null; + /** Real ssh alias for desktop-SSH environments; null elsewhere. */ + readonly sshAlias: string | null; + /** Server-advertised hosts; undefined on servers that predate the feature. */ + readonly remoteOpenTargets: ReadonlyArray | undefined; + /** True when running inside the desktop app's renderer. */ + readonly isDesktopRenderer: boolean; +}): RemoteOpenState { + const { target } = input; + // No catalog entry: keep today's exec behavior rather than guessing. + if (target === null) { + return LOCAL_EXEC; + } + if (target._tag === "PrimaryConnectionTarget") { + // The desktop app manages its own primary backend, so it is always on + // this machine even when its URL is not loopback (wsl-only mode binds + // the WSL2 NAT address). In a browser, a loopback primary means the + // browser runs on the serving machine; a tailnet/LAN URL means remote. + if (input.isDesktopRenderer) { + return LOCAL_EXEC; + } + const hostname = parseHostname(target.httpBaseUrl); + if (hostname !== null && isLoopbackHostname(hostname)) { + return LOCAL_EXEC; + } + } else if (isDesktopLocalConnectionTarget(target)) { + return LOCAL_EXEC; + } + + if (input.sshAlias !== null && input.sshAlias.length > 0) { + return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } }; + } + const advertised = input.remoteOpenTargets?.[0]; + if (advertised !== undefined) { + return { mode: "remote-links", host: advertised }; + } + return REMOTE_UNAVAILABLE; +} + +export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { + const { presentation } = useEnvironmentPresentation(environmentId); + + return useMemo(() => { + if (presentation === null) { + return LOCAL_EXEC; + } + const profile = Option.getOrNull(presentation.entry.profile); + const sshAlias = + profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; + return resolveRemoteOpenState({ + target: presentation.entry.target, + sshAlias, + remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, + isDesktopRenderer: window.desktopBridge !== undefined, + }); + }, [presentation]); +} + +/** + * Editors offered in remote-link mode. The desktop app probes the machine the + * renderer runs on; a browser cannot, so it offers VS Code only. + */ +const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; + +let cachedProbedEditors: ReadonlyArray | null = null; + +export function __resetRemoteEditorProbeForTests(): void { + cachedProbedEditors = null; +} + +export function useRemoteCapableEditors(): ReadonlyArray { + const [editors, setEditors] = useState>( + () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, + ); + + useEffect(() => { + if (cachedProbedEditors !== null) { + return; + } + const probe = window.desktopBridge?.probeRemoteEditors; + if (probe === undefined) { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + return; + } + let cancelled = false; + probe().then( + (ids) => { + const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); + cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS; + if (!cancelled) { + setEditors(cachedProbedEditors); + } + }, + () => { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + }, + ); + return () => { + cancelled = true; + }; + }, []); + + return editors; +} + +/** + * Fire a remote editor deep link. In desktop, route through the Electron + * shell so the OS handler opens without navigating the renderer; in a + * browser, assign the location — unlike window.open this does not leave a + * blank tab behind. + * + * Resolves false when the desktop shell refused the URL (e.g. an older + * build whose protocol allowlist predates editor schemes) so callers do not + * record a successful open that never happened. + */ +export async function openRemoteEditorUrl(url: string): Promise { + const bridge = window.desktopBridge; + if (bridge !== undefined) { + try { + return await bridge.openExternal(url); + } catch { + return false; + } + } + window.location.assign(url); + return true; +} + +/** + * One-time "you need SSH keys on that machine" hint, shown in the picker menu + * until the first remote open fires (we cannot observe SSH success from here, + * so first click is the dismiss signal). + */ +const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; + +export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { + const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean); + return [seen, () => setSeen(true)] as const; +} diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 5948d87e1d23..d714a0e02664 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,20 +10,45 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; + /** + * URL scheme for editors that support VS Code's remote deep links + * (`://vscode-remote/ssh-remote+`). Only set for VS Code + * and forks that ship the Remote-SSH machinery. + */ + readonly remoteScheme?: string; }; export const EDITORS = [ - { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, + { + id: "cursor", + label: "Cursor", + commands: ["cursor"], + launchStyle: "goto", + remoteScheme: "cursor", + }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, - { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, + { + id: "vscode", + label: "VS Code", + commands: ["code"], + launchStyle: "goto", + remoteScheme: "vscode", + }, { id: "vscode-insiders", label: "VS Code Insiders", commands: ["code-insiders"], launchStyle: "goto", + remoteScheme: "vscode-insiders", + }, + { + id: "vscodium", + label: "VSCodium", + commands: ["codium"], + launchStyle: "goto", + remoteScheme: "vscodium", }, - { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; +const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; + +/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => + remoteSchemeOf(editor) !== undefined ? [editor.id] : [], +); + +export const remoteSchemeForEditor = (id: EditorId): string | undefined => { + const editor = EDITORS.find((candidate) => candidate.id === id); + return editor === undefined ? undefined : remoteSchemeOf(editor); +}; + +/** + * Builds a `://vscode-remote/ssh-remote+` deep link that + * opens `absolutePath` on `host` in the local editor over SSH. Returns + * undefined for editors without remote deep-link support. + */ +export const buildRemoteOpenUrl = (input: { + readonly editor: EditorId; + readonly host: string; + readonly absolutePath: string; +}): string | undefined => { + const scheme = remoteSchemeForEditor(input.editor); + if (scheme === undefined) { + return undefined; + } + // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + const posixPath = input.absolutePath.replaceAll("\\", "/"); + const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; +}; + +/** + * SSH hostnames an environment advertises for remote open links. Reachability + * is client-side; the server only advertises names that resolve to itself and + * gates them on a local sshd listen check. Ordered most-reachable first + * (tailnet MagicDNS name, then mDNS `.local`). + */ +export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); +export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; + +export const RemoteOpenTarget = Schema.Struct({ + kind: RemoteOpenTargetKind, + host: TrimmedNonEmptyString, +}); +export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; + export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( "ExternalLauncherUnknownEditorError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f99d4d34b4d2..09d7d7a4602a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings } from "./settings.ts"; +import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -1072,6 +1073,12 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Probe this desktop machine for installed remote-capable editor CLIs + * (used for remote open-in-editor deep links). Optional: older desktop + * builds lack it; callers fall back to VS Code only. + */ + probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d7bc4c5c1898..9791a4f62185 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -17,7 +17,7 @@ import { KeybindingWhen, ResolvedKeybindingsConfig, } from "./keybindings.ts"; -import { EditorId } from "./editor.ts"; +import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({ // Editor ids grow over time; drop ones this build does not know rather than // failing the whole config decode. availableEditors: ForwardCompatibleArray(EditorId), + /** + * SSH hosts this environment advertises for remote open-in-editor links. + * Absent on servers that predate the feature; empty when the machine has no + * sshd or no advertisable name. + */ + remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d7713a726126..4644576296bc 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -39,6 +39,12 @@ export interface NetServiceShape { */ readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; + /** + * Returns true when something accepts TCP connections on {host, port}. + * Unlike the bind-side checks this works for privileged ports (<1024). + */ + readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect; + /** * Reserve an ephemeral loopback port and release it immediately. */ @@ -183,6 +189,7 @@ export const make = () => { return { canListenOnHost, isPortAvailableOnLoopback, + hasListenerOnHost, reserveLoopbackPort, findAvailablePort: (preferred) => Effect.gen(function* () { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4c2ecb331836..76b8ecccb304 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); const testNetService = NetService.NetService.of({ canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41_773), findAvailablePort: (preferred) => Effect.succeed(preferred), }); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 6914ebb69770..9b4f44475d95 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } const netServiceLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(49_152), findAvailablePort: (port) => Effect.succeed(port), }); From d7abd7f3bb6f392ecb4ca21a1eeaec5bbc2d9393 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 21:54:04 -0400 Subject: [PATCH 040/144] feat(web): refresh workspace layouts and tool activity --- .../desktop/src/electron/ElectronMenu.test.ts | 11 +- apps/desktop/src/electron/ElectronMenu.ts | 10 +- .../ActivityPayloadProjection.test.ts | 41 +- .../ActivityPayloadProjection.ts | 34 +- .../Layers/ProviderRuntimeIngestion.test.ts | 30 +- .../Layers/ProviderRuntimeIngestion.ts | 6 + apps/web/src/components/ChatView.tsx | 58 +- .../src/components/NoActiveThreadState.tsx | 19 +- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 316 ++++----- .../src/components/ThreadTerminalDrawer.tsx | 664 ++++++++++-------- .../src/components/WorkspacePageContainer.tsx | 62 ++ .../components/chat/ChangedFilesTree.test.tsx | 23 +- .../src/components/chat/ChangedFilesTree.tsx | 62 +- .../chat/MessagesTimeline.logic.test.ts | 216 +++++- .../components/chat/MessagesTimeline.logic.ts | 412 ++++++++++- .../components/chat/MessagesTimeline.test.tsx | 46 +- .../src/components/chat/MessagesTimeline.tsx | 491 +++++++++---- .../components/chat/PanelLayoutControls.tsx | 18 +- apps/web/src/components/composerInlineChip.ts | 14 +- .../pullRequest/PullRequestDetailPanel.tsx | 617 ++++++++-------- .../pullRequest/PullRequestGhosts.tsx | 115 ++- .../pullRequest/PullRequestListFilters.tsx | 15 +- .../pullRequest/PullRequestSummaryTab.tsx | 6 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/KeybindingsSettings.tsx | 2 +- .../settings/ProjectSettingsPanel.tsx | 26 +- .../settings/SettingsSidebarNav.tsx | 25 +- .../src/components/settings/ThemeSettings.tsx | 2 +- .../components/settings/settingsLayout.tsx | 9 +- .../src/components/sidebar/SidebarChrome.tsx | 146 ++-- .../components/threadActionMenu.logic.test.ts | 14 +- .../src/components/threadActionMenu.logic.ts | 42 +- apps/web/src/components/ui/segmented-tabs.tsx | 40 ++ apps/web/src/components/ui/toggle.tsx | 6 + apps/web/src/components/usage/UsagePage.tsx | 393 ++++------- .../components/usage/UsageProviderChart.tsx | 137 ++-- .../src/components/usage/usageProviders.ts | 4 +- apps/web/src/contextMenuFallback.ts | 98 ++- apps/web/src/index.css | 83 ++- apps/web/src/lib/openPullRequestLink.ts | 11 + .../web/src/routes/-chatIndexTitlebar.test.ts | 4 +- apps/web/src/routes/_chat.index.tsx | 12 +- apps/web/src/routes/_chat.pull-requests.tsx | 103 ++- apps/web/src/routes/settings.tsx | 46 +- apps/web/src/session-logic.test.ts | 133 +++- apps/web/src/session-logic.ts | 105 ++- apps/web/src/terminalUiStateStore.test.ts | 8 + apps/web/src/terminalUiStateStore.ts | 170 ++++- packages/contracts/src/ipc.ts | 4 + packages/shared/src/usageMerge.test.ts | 1 + packages/shared/src/usageMerge.ts | 40 +- 52 files changed, 3228 insertions(+), 1727 deletions(-) create mode 100644 apps/web/src/components/WorkspacePageContainer.tsx create mode 100644 apps/web/src/components/ui/segmented-tabs.tsx diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1db..e3c5d5dd6431 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,7 +98,10 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [{ id: "copy", label: "Copy" }], + items: [ + { id: "copy", label: "Copy" }, + { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, + ], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -110,6 +113,12 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); + assert.deepEqual( + buildFromTemplateMock.mock.calls[0]?.[0].map( + (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, + ), + ["Copy", "separator", "Delete"], + ); }).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c2416..ca8cc246e895 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, + ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { @@ -141,10 +142,17 @@ export const make = Effect.gen(function* () { ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; + const appendSeparator = () => { + if (template.length === 0 || template.at(-1)?.type === "separator") return; + template.push({ type: "separator" }); + }; for (const item of entries) { + if (item.separatorBefore) { + appendSeparator(); + } if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); + appendSeparator(); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b62268..047e40ccf490 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload agent-field survival", () => { +describe("projectActivityPayload", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "claude-call-1", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + result: { content: "x".repeat(5_000) }, + }, + }), + ); + const openCode = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "opencode-call-1", + data: { + tool: "bash", + state: { + status: "running", + input: { command: "vp lint" }, + output: "x".repeat(5_000), + }, + }, + }), + ); + + expect(claude.payload).toMatchObject({ + toolCallId: "claude-call-1", + data: { command: "vp test run" }, + }); + expect(openCode.payload).toMatchObject({ + toolCallId: "opencode-call-1", + data: { command: "vp lint" }, + }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9b..659760c049a4 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,6 +104,24 @@ function projectCommandData(data: Record): Record 0 ? projectedItem : undefined; } +function projectCommandValue(data: Record): unknown { + if (data.command !== undefined) { + return data.command; + } + + const input = asRecord(data.input); + if (input?.command !== undefined) { + return input.command; + } + + const stateInput = asRecord(asRecord(data.state)?.input); + if (stateInput?.command !== undefined) { + return stateInput.command; + } + + return undefined; +} + function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -287,8 +305,9 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; + const command = projectCommandValue(data); + if (command !== undefined) { + projectedData.command = command; } const changedFiles: string[] = []; @@ -368,10 +387,10 @@ function dropStaleContextWindowActivities( /** * Identity both clients use to fold a tool lifecycle row into the call it * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter - * emits one, otherwise the itemType/title/detail triple. Returns null for rows - * with no identity at all — those never collapse on the client either, so they - * must not be dropped here. + * mobile's `threadActivity`): the runtime item id ingestion stamps as + * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. + * Returns null for rows with no identity at all — those never collapse on the + * client either, so they must not be dropped here. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = + asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..b5feda5052d8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), + itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "in_progress", - title: "Read file", - detail: "/tmp/file.ts", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, }, }); @@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - expect( - thread.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", - ), - ).toBe(true); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", + ); + const payload = activity?.payload as Record | undefined; + expect(payload).toMatchObject({ + itemType: "command_execution", + toolCallId: "tool-call-9", + status: "inProgress", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..1eb7e54b3b36 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,6 +794,7 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -821,6 +822,8 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -847,7 +850,10 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c9..e7193a7d0ffd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,7 +167,6 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -220,7 +219,11 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + selectThreadTerminalCustomLabels, + selectThreadTerminalUiState, + useTerminalUiStateStore, +} from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, @@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; + onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, + onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} + onHide={onHide} splitShortcutLabel={visible ? splitShortcutLabel : undefined} splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} @@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); + const activeTerminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), + ); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + activeTerminalCustomLabels[session.target.terminalId] ?? + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } + for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { + if (!labels.has(terminalId)) labels.set(terminalId, label); + } return labels; - }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => - activeThreadEnvironmentId && activeThreadId - ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) - : null, - [activeThreadEnvironmentId, activeThreadId], - ); + }, [activeTerminalCustomLabels, activeThreadKnownSessions]); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); + const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } - chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} -
{!rightPanelOpen ? panelLayoutControls : null} -
+ ))} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 82dddd8f41e0..cfc40f93638b 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,26 +1,15 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; export function NoActiveThreadState() { return (
-
+ {isElectron ? ( - - No active thread - + No active thread ) : (
@@ -28,7 +17,7 @@ export function NoActiveThreadState() {
)} -
+
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df09..f43bd5ea629b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, + hasProjectScope = false, ): boolean { - return shiftKey || projectGroupCount <= 1; + return hasProjectScope || shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a221405..010571b915df 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -366,26 +367,20 @@ function SnoozePopoverButton(props: { ); return ( - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - /> - } - > - - - Snooze thread - + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + } + > + + {presets.map((preset) => ( ) : ( @@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } + + {props.isPinned ? ( + props.pinningSupported ? ( + + ) : ( + + + + ) + ) : null} + {/* Only the visible state owns this slot's width: the pin stays + directly beside the idle status and beside the first action + when the hover controls replace it. */} - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - + + {topStatus.label} + + ) : ( + + {topStatus.icon === "working" ? ( + - } + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + + ) : null} +
@@ -3218,17 +3211,25 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. + // A selected project scope owns creation: users should not have to choose + // the same project twice. "All projects" keeps the picker in multi-project + // setups, while Shift+click retains the direct-create shortcut. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - // One project: nothing to pick, create immediately. Shift+click creates - // directly in the current project even with several projects, skipping - // the palette picker. - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + projectGroups.length, + scopedProjectGroup !== null, + ) + ) { if (isMobile) setOpenMobile(false); + if (scopedProjectGroup) { + void newThreadContext.handleNewThread( + scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), + ); + return; + } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3240,20 +3241,19 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], ); - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. In multi-project setups the label is only - // the picker's shortcut: falling back to chat.newLocal would advertise the - // same shortcut for both the picker and direct create. In single-project - // setups both commands create directly, so chat.newLocal is a valid - // fallback. The second tooltip line (multi-project only) advertises - // shift+click and its keyboard twin chat.newLocal for direct create. + // With no explicit scope the button mirrors chat.new. A scoped button has + // intentionally more specific behavior, so it does not advertise the + // broader command's shortcut. const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + scopedProjectGroup === null + ? (shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined)) + : undefined; const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,7 +3332,9 @@ export default function Sidebar() { /> - {projectGroups.length > 1 ? ( + {scopedProjectGroup ? ( + `New thread in ${scopedProjectGroup.displayName}` + ) : projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e94..deec13ec3bda 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { + PanelBottomCloseIcon, Plus, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, - Trash2, XIcon, } from "lucide-react"; import { @@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, - type ReactNode, type SetStateAction, useCallback, useEffect, @@ -30,9 +29,9 @@ import { useRef, useState, } from "react"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -60,6 +59,7 @@ import { import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -72,10 +72,15 @@ import { resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../appearanceFonts"; +import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; +const TERMINAL_SIDEBAR_MIN_WIDTH = 144; +const TERMINAL_SIDEBAR_MAX_WIDTH = 320; +const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +export function shouldShowTerminalSidebar(terminalCount: number): boolean { + return terminalCount > 1; +} + export function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number }; @@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; + onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } -interface TerminalActionButtonProps { - label: string; - className: string; - onClick: () => void; - children: ReactNode; -} - -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { - return ( - - } - > - {children} - - - {label} - - - ); -} - export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, + onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = + useResizableWidth({ + storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, + defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, + minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, + maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, + edge: "left", + }); + const terminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), + ); + const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); + const [renamingTerminalId, setRenamingTerminalId] = useState(null); + const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); + const cancelTerminalRenameRef = useRef(false); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); const isSplitView = visibleTerminalIds.length > 1; - const showGroupHeaders = - resolvedTerminalGroups.length > 1 || - resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const terminalLabelById = useMemo(() => { + const automaticTerminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const terminalLabelById = useMemo(() => { + const next = new Map(); + for (const terminalId of normalizedTerminalIds) { + next.set( + terminalId, + terminalCustomLabels[terminalId]?.trim() || + automaticTerminalLabelById.get(terminalId) || + getTerminalLabel(terminalId), + ); + } + return next; + }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({ }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; @@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - const onNewTerminalAction = useCallback(() => { - onNewTerminal(); - }, [onNewTerminal]); + const startTerminalRename = useCallback( + (terminalId: string) => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(terminalId); + setTerminalRenameDraft( + terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", + ); + }, + [terminalCustomLabels, terminalLabelById], + ); + const finishTerminalRename = useCallback(() => { + if (!renamingTerminalId) return; + const nextLabel = terminalRenameDraft.trim(); + const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; + setTerminalCustomLabel( + threadRef, + renamingTerminalId, + nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, + ); + setRenamingTerminalId(null); + }, [ + automaticTerminalLabelById, + renamingTerminalId, + setTerminalCustomLabel, + terminalRenameDraft, + threadRef, + ]); + const cancelTerminalRename = useCallback(() => { + cancelTerminalRenameRef.current = true; + setRenamingTerminalId(null); + }, []); + + useEffect(() => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(null); + setTerminalRenameDraft(""); + }, [threadRef.environmentId, threadRef.threadId]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - + const compactTerminalToolbar = ( + <> + + + + + {!isPanel && onHide ? ( + <> + + + + ) : null} + + ); return (
); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx new file mode 100644 index 000000000000..4613dd465b1c --- /dev/null +++ b/apps/web/src/components/WorkspacePageContainer.tsx @@ -0,0 +1,62 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; + +export type WorkspacePageWidth = "readable" | "wide" | "expanded"; + +const WIDTH_CLASS: Record = { + readable: "max-w-4xl", + wide: "max-w-5xl", + expanded: "max-w-6xl", +}; + +/** Shared full-page frame for workspace routes beneath their top bar. */ +export function WorkspacePageContainer({ + width = "readable", + className, + ...props +}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { + return ( +
+ ); +} + +/** Shared top-bar geometry for every full-width workspace surface. */ +export function WorkspacePageHeader({ + electron = false, + reserveNativeControls = electron, + className, + ...props +}: ComponentPropsWithoutRef<"header"> & { + readonly electron?: boolean; + readonly reserveNativeControls?: boolean; +}) { + return ( +
+ ); +} + +/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */ +export function WorkspacePageHeaderEdgeControl({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return
; +} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index e9fa1895bf99..bc3c4fa80dfc 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,13 +23,9 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain( - 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', - ); + expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); - expect(markup).toContain("@[24rem]/changed-files:inline"); - expect(markup).not.toContain("sm:inline"); + expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); + expect(markup).not.toContain("Hide files"); + expect(markup).not.toContain("ml-auto"); }); - it("renders a scope and representative-file preview for a large latest change", () => { + it("renders a clean representative-file preview for a large latest change", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-changed-files-state="preview"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps"); - expect(markup).toContain("2 files"); - expect(markup).toContain("packages"); - expect(markup).toContain("root"); + expect(markup).toContain("apps/web/src/"); + expect(markup).toContain("packages/shared/src/"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("basis-0"); + expect(markup).not.toContain("+1 more"); + expect(markup).not.toContain("Show files"); + expect(markup).toContain('aria-label="120 additions, 20 deletions"'); expect(markup).not.toContain("App.test.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index d29d8b7f2f44..a8bb461c0e12 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - changedFileName, - selectChangedFilePreview, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; +import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); - const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); const compactPreviewVisible = showCompactPreview && !expanded; return (
onExpandedChange(!expanded)} > )} - - {expanded ? "Hide files" : "Show files"} -
{expanded ? ( @@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} /> ) : compactPreviewVisible ? ( -
-

- {scopeSummary.map((scope, index) => ( - - {index > 0 ? : null} - {scope.label} - - {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} - - - ))} -

-
+
+
{previewFiles.map((file) => ( ))} -
) : null} @@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1ca..82338dec2a89 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", - "turn-fold:turn-1", "assistant-thought-entry", - "work-entry-1", + "work-toggle:work-entry-1", + "turn-fold:turn-1", "assistant-final-entry", ]); expect( @@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); + it("keeps a superseded turn fold beside the final response after a steer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "initial-user" as never, + role: "user", + text: "Start the work", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "superseded-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:10Z", + entry: { + id: "superseded-work", + createdAt: "2026-01-01T00:00:10Z", + turnId: "turn-1" as never, + label: "Ran command", + tone: "tool", + }, + }, + { + id: "steer-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "steer-user" as never, + role: "user", + text: "Change the approach", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Implemented locally, uncommitted.", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:20Z", + updatedAt: "2026-01-01T00:00:21Z", + streaming: false, + }, + }, + ], + latestTurn: { + turnId: "turn-2" as never, + state: "completed", + startedAt: "2026-01-01T00:00:12Z", + completedAt: "2026-01-01T00:00:21Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "steer-user-entry", + "turn-fold:turn-1", + "assistant-final-entry", + ]); + }); + it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", "assistant-thought-entry", - "work-entry-1", + "work-live:work-entry-1", + ]); + }); + + it("keeps the current tool batch expandable while live entries append", () => { + const timelineEntries = [ + { + id: "work-entry-1", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-1", + label: "Read file", + tone: "tool" as const, + }, + }, + { + id: "work-entry-2", + kind: "work" as const, + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + toolCallId: "call-2", + label: "Run command", + command: "vp test run", + tone: "tool" as const, + }, + }, + ]; + const baseInput = { + timelineEntries, + latestTurn: { + turnId: "turn-1" as never, + state: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + const collapsedRows = deriveMessagesTimelineRows(baseInput); + const expandedRows = deriveMessagesTimelineRows({ + ...baseInput, + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(collapsedRows.map((row) => row.id)).toEqual([ "working-indicator-row", + "work-live:tool:call-1", ]); + expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: false, + groupedEntries: [{ id: "work-1" }, { id: "work-2" }], + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + ]); + expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: true, + }); + + const appendedRows = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "work-entry-3", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "work-3", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + toolCallId: "call-3", + label: "Changed file", + tone: "tool" as const, + }, + }, + ], + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(appendedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + "work-3", + ]); + + const rowsWithLaterPlan = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "plan:thread-1:turn:turn-1", + kind: "proposed-plan" as const, + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan:thread-1:turn:turn-1", + turnId: "turn-1" as never, + planMarkdown: "# Next steps", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + }); + expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); + expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("running-work-entry"); + expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 2, + hiddenCount: 3, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ + "work-toggle:work-entry-1", "work-1", "work-2", "work-3", - "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203c..8d7fc52fdca6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import * as Equal from "effect/Equal"; import { formatDuration, + workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -166,6 +167,17 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + isExpandedToolGroupEntry: boolean; + isLastExpandedToolGroupEntry: boolean; + } + | { + kind: "work-live"; + id: string; + createdAt: string; + entry: WorkLogEntry; + groupedEntries: WorkLogEntry[]; + groupId: string; + expanded: boolean; } | { kind: "work-toggle"; @@ -175,6 +187,9 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; + summary: string | null; + summaryKind: ToolGroupAction | "mixed" | null; + hasFailure: boolean; } | { kind: "turn-fold"; @@ -208,7 +223,12 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { kind: "working"; id: string; createdAt: string | null }; + | { + kind: "working"; + id: string; + createdAt: string | null; + showThinking: boolean; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; + +function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { + if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read"; + if ( + entry.requestKind === "file-change" || + entry.itemType === "file_change" || + (entry.changedFiles?.length ?? 0) > 0 + ) { + return "edit"; + } + if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (entry.itemType === "web_search") return "search"; + return "other"; +} + +function toolGroupActionCount( + action: ToolGroupAction, + entries: ReadonlyArray, +): number { + if (action !== "edit") return entries.length; + + const changedFiles = new Set(); + let editsWithoutFileDetails = 0; + for (const entry of entries) { + if (!entry.changedFiles || entry.changedFiles.length === 0) { + editsWithoutFileDetails += 1; + continue; + } + for (const file of entry.changedFiles) changedFiles.add(file); + } + return changedFiles.size + editsWithoutFileDetails; +} + +function toolGroupActionLabel(action: ToolGroupAction, count: number): string { + switch (action) { + case "read": + return `Read ${count} ${count === 1 ? "file" : "files"}`; + case "edit": + return `Changed ${count} ${count === 1 ? "file" : "files"}`; + case "command": + return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "search": + return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; + case "other": + return `Used ${count} ${count === 1 ? "tool" : "tools"}`; + } +} + +/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ +export function summarizeToolGroup(entries: ReadonlyArray): string { + const groupedEntries = new Map(); + for (const entry of entries) { + const action = toolGroupAction(entry); + const group = groupedEntries.get(action); + if (group) group.push(entry); + else groupedEntries.set(action, [entry]); + } + const labels = [...groupedEntries].map(([action, actionEntries]) => + toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), + ); + const sentenceLabels = labels.map((label, index) => + index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), + ); + if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; + if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); + return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; +} + +function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupAction | "mixed" { + const actions = new Set(entries.map(toolGroupAction)); + return actions.size === 1 ? actions.values().next().value! : "mixed"; +} + +function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { + return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; +} + +function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { + return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -310,17 +414,34 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } +function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { + return timelineEntries.findLastIndex( + (entry) => entry.kind === "message" && entry.message.role === "user", + ); +} + +function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") { + return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; + } + if (entry.kind === "turn-plan") { + return entry.turnPlan.turnId; + } + return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; +} + /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row anchored at the turn's first foldable entry; the - * terminal assistant message stays visible below the fold. + * "Worked for ..." row placed immediately before the next terminal assistant + * response. A steer can split one visible response across turn ids, so tying + * the disclosure to the first hidden entry would strand it above the steer. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap { +}): ReadonlyMap> { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -375,7 +496,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -405,6 +526,24 @@ function deriveTurnFolds(input: { if (!firstEntry || !lastEntry) { continue; } + const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => + hiddenEntryIds.has(entry.id), + ); + if (lastHiddenEntryIndex < 0) { + continue; + } + const nextTerminalAssistantEntry = input.timelineEntries + .slice(lastHiddenEntryIndex + 1) + .find( + (entry) => + entry.kind === "message" && + entry.message.role === "assistant" && + input.terminalAssistantMessageIds.has(entry.message.id), + ); + const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; + if (!anchorEntry) { + continue; + } const isLatestInterruptedTurn = input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; @@ -431,13 +570,16 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstEntry.id, { + const fold = { turnId, - anchorEntryId: firstEntry.id, - createdAt: firstEntry.createdAt, + anchorEntryId: anchorEntry.id, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, - }); + }; + const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); + if (anchoredFolds) anchoredFolds.push(fold); + else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); } return foldsByAnchorEntryId; } @@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); + for (const folds of foldsByAnchorEntryId.values()) { + for (const fold of folds) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } } } } + let activeTurnHeaderIndex = input.timelineEntries.length; + if (input.isWorking) { + const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); + const firstOwnedAfterUser = + unsettledTurnId === null + ? -1 + : input.timelineEntries.findIndex( + (entry, index) => + index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, + ); + activeTurnHeaderIndex = + firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; + } + const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => + input.isWorking && + index >= activeTurnHeaderIndex && + (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); + const isVisibleActiveToolEntry = (entry: WorkLogEntry) => + workLogEntryIsToolLike(entry) && + (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); + const activeEntries = input.isWorking + ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) + : []; + const activeTurnHasVisibleContent = + activeEntries.some((entry) => { + if (entry.kind === "message") { + return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; + } + if (entry.kind === "work") { + return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); + } + if (entry.kind === "turn-plan") return true; + return false; + }) || + input.timelineEntries + .slice(activeTurnHeaderIndex) + .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); + + const activeWorkEntryIds = new Set(); + const activeWorkRowsByAnchorId = new Map< + string, + Extract + >(); + const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); + for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { + const entry = input.timelineEntries[index]; + if (!entry) continue; + const isVisibleTurnContent = + (entry.kind === "message" && entry.message.role === "user") || + entry.kind === "proposed-plan" || + (entryBelongsToActiveTurn(entry, index) && + ((entry.kind === "message" && entry.message.role === "assistant") || + entry.kind === "turn-plan" || + (entry.kind === "work" && + entry.entry.agentSpawn === undefined && + isVisibleActiveToolEntry(entry.entry)))); + hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; + } + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entry = input.timelineEntries[index]; + if ( + !entry || + entry.kind !== "work" || + entry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(entry, index) + ) { + continue; + } + if (!isVisibleActiveToolEntry(entry.entry)) { + continue; + } + + const anchorEntry = entry; + let latestToolEntry = entry; + const batchEntryIds = [entry.id]; + const visibleBatchEntries = [entry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if ( + !nextEntry || + nextEntry.kind !== "work" || + nextEntry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(nextEntry, cursor) + ) { + break; + } + batchEntryIds.push(nextEntry.id); + if (isVisibleActiveToolEntry(nextEntry.entry)) { + latestToolEntry = nextEntry; + visibleBatchEntries.push(nextEntry.entry); + } + cursor += 1; + } + + // Once newer commentary, a plan, or another tool batch exists, this batch + // is history. Let the regular work-group path turn it into an expandable + // summary so none of its calls disappear behind the live one-line view. + if (hasLaterTurnContent[cursor] !== true) { + for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); + const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); + activeWorkRowsByAnchorId.set(anchorEntry.id, { + kind: "work-live", + id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, + createdAt: anchorEntry.createdAt, + entry: latestToolEntry.entry, + groupedEntries: visibleBatchEntries, + groupId, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + }); + } + index = cursor - 1; + } + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); - if (turnFold) { + if (input.isWorking && index === activeTurnHeaderIndex) { nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } + const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); + if (anchoredTurnFolds) { + for (const turnFold of anchoredTurnFolds) { + nextRows.push({ + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + }); + } + } + if (collapsedEntryIds.has(timelineEntry.id)) { continue; } + if (activeWorkEntryIds.has(timelineEntry.id)) { + const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); + if (activeWorkRow) { + nextRows.push(activeWorkRow); + if (activeWorkRow.expanded) { + for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, + }); + } + } + } + continue; + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || + activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + const onlyToolEntries = visibleGroupedEntries.every( + (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, + ); + if (onlyToolEntries) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); + nextRows.push({ + kind: "work-toggle", + id: `work-toggle:${timelineEntry.id}`, + createdAt: timelineEntry.createdAt, + groupId, + hiddenCount: visibleGroupedEntries.length, + expanded, + onlyToolEntries: true, + summary: summarizeToolGroup(visibleGroupedEntries), + summaryKind, + hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), + }); + if (expanded) { + for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, + }); + } + } + } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } else { - const groupId = `work-group:${timelineEntry.id}`; + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } @@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries: visibleGroupedEntries.every((entry) => - workLogEntryIsToolLike(entry), + onlyToolEntries, + summary: null, + summaryKind: null, + hasFailure: visibleGroupedEntries.some((entry) => + workEntryIndicatesToolFailure(entry), ), }); } @@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking) { + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } @@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking + ); case "turn-fold": { const bf = b as typeof a; @@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": - return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); + case "work": { + const bw = b as typeof a; + return ( + a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && + a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } + + case "work-live": { + const bw = b as typeof a; + return ( + a.createdAt === bw.createdAt && + a.groupId === bw.groupId && + a.expanded === bw.expanded && + Equal.equals(a.entry, bw.entry) && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } case "work-toggle": { const bw = b as typeof a; @@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries + a.onlyToolEntries === bw.onlyToolEntries && + a.summary === bw.summary && + a.summaryKind === bw.summaryKind && + a.hasFailure === bw.hasFailure ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5bb..3dcf6cf2a302 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,7 +554,49 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("formats changed file paths from the workspace root", () => { + it("makes the whole live tool row expandable without adding a chevron", () => { + const turnId = TurnId.make("turn-live-tools"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('aria-label="Expand current tool calls"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("Running psql"); + expect(markup).not.toContain("lucide-chevron-right"); + expect(markup).not.toContain("hover:bg-accent/20"); + }); + + it("summarizes completed changed-file activity", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("Changed 1 file"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b2..3ccd4808d064 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,7 +35,6 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -57,7 +56,6 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; + const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; + const isExpandedToolGroupHeader = + (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || + (row.kind === "work-live" && row.expanded); + return (
- {row.kind === "work" ? : null} + {row.kind === "work" ? ( + + ) : null} + {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} - +
); @@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
- - - - - - +
+
+
{row.createdAt ? ( <> Working for @@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} +
+ {row.showThinking ? ( +
+ +
+ ) : null}
); } @@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, + isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; + isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} + isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} + +function ThinkingActivityRow() { + return ; +} + +function LiveActivityContent({ + label, + iconName, + highlighted = false, +}: { + label: string; + iconName: WorkEntryIconName | undefined; + highlighted?: boolean; +}) { + return ( +
+ {iconName ? ( + + + + ) : null} + {label} +
+ ); +} + +function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + + return ( + + ); +} + +function toolGroupSummaryIconName( + kind: Extract["summaryKind"], +): WorkEntryIconName { + switch (kind) { + case "read": + return "eye"; + case "edit": + return "square-pen"; + case "command": + return "terminal"; + case "search": + return "globe"; + case "other": + return "wrench"; + case "mixed": + case null: + return "hammer"; + } +} + function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); + if (row.onlyToolEntries && row.summary) { + return ( + + ); + } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -2019,32 +2159,101 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -function workEntryRawCommand( - workEntry: Pick, -): string | null { - const rawCommand = workEntry.rawCommand?.trim(); - if (!rawCommand || !workEntry.command) { - return null; +type CommandWrapper = "env" | "sudo"; + +const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { + env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), + sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), +}; + +const COMMAND_WRAPPER_FLAGS: Record> = { + env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]), + sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), +}; + +function commandProgramName(command: string): string | null { + const tokens = command.trim().split(/\s+/); + let index = 0; + let wrapper: CommandWrapper | null = null; + + while (index < tokens.length) { + const token = tokens[index]?.replace(/^["']|["']$/g, ""); + if (!token) return null; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + index += 1; + continue; + } + if (token === "env" || token === "sudo") { + wrapper = token; + index += 1; + continue; + } + if (wrapper !== null && token === "--") { + wrapper = null; + index += 1; + continue; + } + if (wrapper !== null && token.startsWith("-")) { + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { + index += 1; + continue; + } + if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { + let consumesNextToken = false; + for (const [optionIndex, option] of token.slice(1).split("").entries()) { + const shortOption = `-${option}`; + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { + consumesNextToken = optionIndex === token.length - 2; + break; + } + if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; + } + if (consumesNextToken && tokens[index + 1] === undefined) return null; + index += consumesNextToken ? 2 : 1; + continue; + } + return null; + } + return token.split(/[\\/]/).at(-1) || null; } - return rawCommand === workEntry.command.trim() ? null : rawCommand; + + return null; +} + +function liveWorkEntryLabel( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): string { + const command = workEntry.command?.trim(); + if (command) { + const program = commandProgramName(command); + if (program) return `Running ${program}`; + return "Running command"; + } + + return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { + const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); const blocks: string[] = []; + if (command) { + blocks.push(command); + } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); - } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + const detail = workEntry.detail?.trim(); + if (detail && detail !== command) { + blocks.push(detail); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time : "working" : failed > 0 ? `${failed} failed` - : "✓ completed"; + : "Completed"; return ( - +
+
+
+ + + {lead} + {workflowName ? ( + + {workflowName} + + ) : null} + + {status} + {totalTokens > 0 ? ( + + Σ {formatSubagentTokenCount(totalTokens)} + + ) : null} + +
+ +
+
); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ; + return ( + + ); }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; - const activity = use(TimelineRowActivityCtx); + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); + const entryIconName = + showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); + const isCommandEntry = + workEntry.requestKind === "command" || + workEntry.itemType === "command_execution" || + Boolean(workEntry.command); + const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-5 shrink-0 items-center justify-center", - showWarningIndicator + "flex size-6 shrink-0 items-center justify-center", + showWarningIndicator || showFailedIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground"; - const turnSettled = !activity.activeTurnInProgress; - const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); - const showSuccessIndicator = - workEntryIndicatesToolSuccess(workEntry) || - (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + : workLogEntryIsToolLike(workEntry) + ? "text-secondary-label" + : "text-foreground/80"; + const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- - - + {showEntryIcon ? ( + + + + ) : null}
-

- {heading} - {preview && ( - {preview} +

-

-
- - {canExpand ? ( - - ) : null} - - - {showFailedIndicator ? ( - - - } - > - - - Failed - - ) : showSuccessIndicator ? ( - - } - > - - - - - Completed - - ) : showNeutralIndicator ? ( - - } - > - - - Empty - - ) : null} - + {displayText} +

{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 6f281558ff80..c2fa204ffbc8 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,6 +1,7 @@ import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { memo } from "react"; +import { cn } from "../../lib/utils"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -12,6 +13,7 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; + rightPanelUnavailableLabel?: string; /** Running + waiting subagents in this thread; badges the right panel toggle. */ liveAgentCount: number; onToggleTerminal: () => void; @@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, + rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = + "block self-center truncate leading-none select-none"; -// The skill label is smaller than the surrounding prompt text; offset its -// glyphs without moving the pill box or changing the editor's line height. -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300"; export const SKILL_CHIP_ICON_SVG = ``; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3fd2..7457d04d2c9e 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,6 +25,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, + LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -50,6 +51,7 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -74,6 +77,7 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; import { Menu, MenuItem, @@ -116,6 +120,7 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { + PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -349,7 +354,6 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", - chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -381,12 +385,6 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; - /** - * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` - * folds the whole of it into the top row once the active tab scrolls, and unfolds at the - * top — the chrome spends its height on what is being read. - */ - chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -423,26 +421,13 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); - // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll - // events, so the capture handler always writes the active tab's entry — and a tab switch - // reads the destination's memory instead of inheriting the tab being left. A tab too short - // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome - // it has no scrollbar to reopen. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeVariant === "collapse" && chromeCondensed; - // Collapsing removes the fold's height from the chrome, which would otherwise hand that - // height to the scrollport and leap the content up by it mid-scroll. The cure is exact - // compensation: collapse only once the reader has scrolled at least the fold's height, - // then give that height back to `scrollTop` before the next paint — the content under - // their eyes does not move, and the collapse itself is the only thing that changes. + const condensed = chromeCondensed; const scrollerRef = useRef(null); const foldRef = useRef(null); - // The condensed chrome's second row opens as the fold closes, so the height the scrollport - // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` - // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); const compensationRef = useRef(null); useLayoutEffect(() => { @@ -463,7 +448,6 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); - // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -502,6 +486,30 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const baseBranchRefQuery = useEnvironmentQuery( + detail === null + ? null + : vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: detail.workspaceRoot, + query: detail.baseBranch, + includeMatchingRemoteRefs: true, + limit: 20, + }, + }), + ); + const matchingBaseBranchRefs = + detail === null + ? [] + : (baseBranchRefQuery.data?.refs.filter( + (refName) => + refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), + ) ?? []); + const isStackedPullRequest = + matchingBaseBranchRefs.length > 0 && + !matchingBaseBranchRefs.some((refName) => refName.isDefault); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes - // to the thing that would help instead of a Merge button that only ever says no. + // One live action holds the slot. Conflicts take priority because every other completion action + // depends on resolving them first, even for a reader who cannot merge on the host themselves. const primaryAction = detail === null || detail.state !== "open" ? null - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null - : conflicting - ? "resolve" + : conflicting + ? "resolve" + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. Conflicts keep their own row below: an open pull request remains green there. + // it. The conflict action is separate from this state: an open pull request remains green. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; const checksState = detail ? pullRequestChecksState(detail.checks) : null; + if (detailQuery.isPending && !detail) { + return ; + } + return (
- {/* The top row's geometry never changes: both of its states occupy the same stacked - cell and crossfade, so the actions on the right have one home whatever the chrome - is doing below. The fold and this fade share one 200ms clock. */}
- {/* The fixed height lives on the two top-row cells — not the grid, whose later rows - are the fold — so the actions have one immovable home in both states. */} -
+
{detail && statePresentation ? ( <> - - {detail.repository} - + {repositoryUrl ? ( + + ) : ( + + {detail.repository} + + )} - +

{detail.title} - - {conflicting ? ( - - - Conflicts - - ) : checksSummary ? ( - - {detail && checksState !== null ? ( - - ) : null} - {checksSummary} - - ) : null} +

) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "ready" ? ( + {primaryAction === "resolve" ? ( + + } + > + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + ) : primaryAction === "ready" ? ( @@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({ ) : null}
- {/* The condensed chrome's second row: the tabs that the closing fold takes with it, - and compact copies of the branch pair and diff stat so they stay in sight while - the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} -
+
{detail ? ( -
- - - {detail.baseBranch} - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" + + {detail.changedFiles.toLocaleString()} + + - ) : null} - - {detail.headBranch} - - - - - {detail.changedFiles.toLocaleString()} - - +
) : null}
- {/* Folding is a grid track going to zero: the rows below stay mounted, the track - animates closed over them, and `inert` takes the hidden controls out of the tab - order for as long as the chrome is condensed. */} -
+
{detail ? ( -
+
{titleDraft === null ? (

@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({
- - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} + + {isStackedPullRequest ? ( + + ) : null} + {detail.baseBranch} + + {freshness ? ( + void perform("update-branch", undefined, method)} + /> + ) : null} + - ) : null} - - + + {detail.headBranch} + + + + @@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({

) : null} +
+
- {detail && conflicting ? ( -
- + + {visibleTabs.map((item) => ( + setTab(item.value)} > - - Merge conflicts - + {item.label} + + ))} + + {tab === "summary" ? ( + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + + ) : tab === "timeline" ? ( +
+ + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commentCount.toLocaleString()} + + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commits.length.toLocaleString()} + +
) : null} - - {detail ? ( - - ) : null} -
-
+ + ) : null}
{ - if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; - // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; - // The chrome trades the fold for the condensed second row, so the height the - // scrollport actually gains is the difference between the two. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.isPending && !detail ? ( - // The ghost wears the shape of the tab being waited on, so switching tabs mid-load - // does not flash a summary outline under a timeline heading. - tab === "timeline" ? ( - - ) : tab === "code" ? ( - - ) : ( - - ) - ) : detailQuery.error && !detail ? ( + {detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 09b79cf340e6..38a3ab70d642 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,13 +45,11 @@ export function PullRequestListGhost({
- +
- +
))} @@ -59,32 +57,101 @@ export function PullRequestListGhost({ ); } -/** The summary's own shape: a title, a byline, the facts rows, the description. */ +/** + * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description + * boundaries in the ghost prevents the loaded pull request from replacing one layout with + * another a moment later. + */ export function PullRequestDetailGhost() { return (
-
- - +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+ + + +
+ +
-
- {Array.from({ length: 4 }, (_, index) => ( -
+ +
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ +
+
+ - -
- ))} -
-
- - - - +
+ + + + +
+
); @@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 3066eafc38a1..04fee465b506 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,6 +25,7 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Button } from "../ui/button"; import { Menu, @@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({ return ( + } > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index a57f2a4d1602..29566e048d10 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -196,12 +196,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index a472c6a8d3d7..9c36d32ff51a 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + +
- {!isElectron && ( -
- -
- )} - {isElectron && ( -
- -
- )} + + +
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 174c9e9fe97c..1618f5045eb8 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { ArchiveIcon, - ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -19,7 +18,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -34,6 +33,7 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; +import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); @@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); - const handleBackClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, isMobile, navigate, setOpenMobile]); - return ( <> @@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
- - - - - Back - - - +
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 7e4b80d19511..5399d071be9d 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -768,7 +768,7 @@ export function ThemeLibrary({
{STANDARD_THEME_CARDS.map((standardTheme) => ( location.hash }); @@ -247,12 +250,12 @@ export function SettingsPageContainer({ return (
-
+ {children} -
+
); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index f4a98dec86c7..8fc6b835bf1a 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,8 +4,9 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; +import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -117,16 +118,44 @@ function T3Wordmark() { ); } -export const SidebarChromeFooter = memo(function SidebarChromeFooter() { +function SidebarUtilityItem({ + icon, + label, + onClick, +}: { + icon: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + + + {icon} + + } + /> + {label} + + + ); +} + +export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); + const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + /^\/settings(?:\/|$)/.test(location.pathname) + ? "settings" + : location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const handleBackClick = useCallback(() => { closeMobileSidebar(); + if (canGoBack) { + window.history.back(); + return; + } void navigate({ to: "/" }); - }, [closeMobileSidebar, navigate]); + }, [canGoBack, closeMobileSidebar, navigate]); + return ( + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> + } + label="Settings" + onClick={handleSettingsClick} + /> + {pullRequestsSupported ? ( + } + label="Pull Requests" + onClick={handlePullRequestsClick} + /> + ) : null} + } + label="Usage" + onClick={handleUsageClick} + /> + + )} + + + ); +}); + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - - - - - - } - /> - Settings - - - {pullRequestsSupported ? ( - - - - - - } - /> - Pull Requests - - - ) : null} - - - - - - } - /> - Usage - - - - )} - - + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0a..477bc9c02630 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] { return buildThreadActionMenuItems(state).map((item) => item.id); } +function allIds(state: ThreadActionMenuState): string[] { + const flatten = (items: ReturnType): string[] => + items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]); + return flatten(buildThreadActionMenuItems(state)); +} + describe("buildThreadActionMenuItems", () => { it("hides lifecycle items when the environment lacks the capabilities", () => { expect( @@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = ids({ ...baseState, branch: "feat/menu" }); + const withBranch = allIds({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(ids(baseState)).not.toContain("new-thread-on-branch"); - expect(ids(baseState)).not.toContain("copy-branch"); + expect(allIds(baseState)).not.toContain("new-thread-on-branch"); + expect(allIds(baseState)).not.toContain("copy-branch"); }); it("flips lifecycle labels with thread state", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdacd..1218e2dd58cb 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,6 +18,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" + | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -53,14 +54,15 @@ export function buildThreadActionMenuItems( { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, + icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread" } - : { id: "pin" as const, label: "Pin thread" }, + ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } + : { id: "pin" as const, label: "Pin thread", icon: "pin" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -69,17 +71,18 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread" } - : { id: "settle" as const, label: "Settle thread" }, + ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } + : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread" } + ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } : { id: "snooze" as const, label: "Snooze", + icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -88,20 +91,37 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread" }, + { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), - { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + { + id: "copy", + label: "Copy", + icon: "copy", + separatorBefore: true, + children: [ + { id: "copy-path", label: "Path", icon: "folder" }, + ...(state.branch + ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + : []), + { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, + ], + }, + { + id: "delete", + label: "Delete", + destructive: true, + icon: "trash", + separatorBefore: true, + }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx new file mode 100644 index 000000000000..29b91e18bb40 --- /dev/null +++ b/apps/web/src/components/ui/segmented-tabs.tsx @@ -0,0 +1,40 @@ +import type { ComponentProps, HTMLAttributes } from "react"; + +import { cn } from "~/lib/utils"; +import { Toggle } from "~/components/ui/toggle"; + +function SegmentedTabList({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +function SegmentedTab({ + selected, + density = "default", + className, + ...props +}: { + selected: boolean; + density?: "default" | "compact"; +} & Omit, "aria-pressed" | "pressed" | "size" | "type" | "variant">) { + return ( + + ); +} + +export { SegmentedTab, SegmentedTabList }; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 5bf04adf41a1..7173eab140ec 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,6 +18,10 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", + segmented: + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + "segmented-compact": + "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -27,6 +31,8 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", + segmented: + "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883db2..b9bebadc00e7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,13 +19,22 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; -import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; -import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../WorkspacePageContainer"; +import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -66,21 +75,6 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); - // Ranked by whatever the toggle is showing, so the bars always descend. - const orderedProviders = useMemo( - () => - merged.providers.toSorted((a, b) => - metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, - ), - [merged.providers, metric], - ); - - const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( - (period) => period.totalTokens > 0, - ).length; - const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; - const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; - const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -100,78 +94,66 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; + const windowLabel = + isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; + const topbarContent = ( +
+ + +

Usage

+
+ + + {windowLabel} + +
+
+ + {(["cost", "tokens"] as const).map((option) => ( + setMetric(option)} + > + {option === "cost" ? "Cost" : "Tokens"} + + ))} + + + {WINDOW_OPTIONS.map((option) => ( + selectWindow(option.days)} + > + {option.label} + + ))} + + + + +
+
+ ); return (
- {!isElectron && ( -
- - Usage - -
- )} - - {isElectron && ( -
- - Usage - -
- )} + + {topbarContent} + -
-
-

- {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined - ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` - : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} -

-
-
- {WINDOW_OPTIONS.map((option) => ( - - ))} -
- -
-
- + {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -181,88 +163,62 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> - {/* Cost first: the financial answer, then the provider split. */} -
- {/* The summary follows the chart toggle, so the headline and the - series are always reading the same units. */} -
+
+
- - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` + ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" - : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + ? `${formatCount(merged.sessions)} sessions · API estimate` + : `${formatCount(merged.sessions)} sessions`}
- {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + {PROVIDER_ORDER.map((provider) => { + const totals = merged.providers.find((entry) => entry.provider === provider); + const share = + metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); + const providerSessions = totals?.sessions ?? 0; + const sessionLabel = `${formatCount(providerSessions)} ${ + providerSessions === 1 ? "session" : "sessions" + }`; return ( -
-
- - - {PROVIDER_LABEL[provider.provider]} +
+
+ + + + {PROVIDER_LABEL[provider]} + + {sessionLabel} + + - + {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)}
-
-
-
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
); })}
-
-
-

- {isPast24Hours ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

-
-
- {(["cost", "tokens"] as const).map((option) => ( - - ))} -
- -
-
+
+

+ {isPast24Hours ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"} +

-
- - - - - 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" - } - /> +
+

Totals

+
+ + + + + +

Breakdown

-
+ {( [ - { value: "model", label: "model" }, - { value: "time", label: isPast24Hours ? "hour" : "day" }, + { value: "model", label: "Model" }, + { value: "time", label: isPast24Hours ? "Hour" : "Day" }, ] as const ).map((option) => ( - + ))} -
+
{breakdown === "model" ? ( @@ -356,7 +291,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -403,7 +338,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -433,7 +368,7 @@ export function UsagePage() {
)} -
+
@@ -452,20 +387,11 @@ function ProviderMark({ return ; } -function Metric({ - label, - value, - detail, -}: { - readonly label: string; - readonly value: string; - readonly detail: string; -}) { +function Metric({ label, value }: { readonly label: string; readonly value: string }) { return ( -
+
{label} - {value} - {detail} + {value}
); } @@ -569,70 +495,51 @@ function UsageDeviceStrip({ ); } -/** Deterministic bar heights (each unique: they double as keys). */ -const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; - /** - * Static stand-in with the loaded page's shape: headline, provider split, - * chart and metrics strip. No shimmer; blocks fill in exactly once when the - * last device answers. + * Static stand-in with the loaded page's shape. No shimmer; blocks fill in + * exactly once when the last device answers. */ -function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { +function UsageSkeleton() { return ( <> -
+
- - Raw token cost - -
-
+
+
- {PROVIDER_ORDER.map((provider) => ( -
-
- +
+
+ - {PROVIDER_LABEL[provider]} +
-
))}
-

- {resolution === "hour" ? "Hourly" : "Daily"} cost -

- {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a - relayout when the real chart swaps in. */} -
- {SKELETON_BAR_HEIGHTS.map((height) => ( -
- ))} -
+
+
-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
-
- ), - )} +
+

Totals

+
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+ ), + )} +
); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe286..963c28fe6a01 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,13 +68,7 @@ function buildPeriodColumns( }); } -/** - * Monotone cubic tangents (Fritsch-Carlson). - * - * Plain cubic smoothing overshoots on spiky daily data and would dip the area - * below zero between points, which reads as negative spend. This variant is - * shape-preserving, so a smoothed series never leaves the range of its samples. - */ +/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } -/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -123,7 +116,6 @@ interface CurveSegment { readonly to: Point; } -/** Smoothed polyline through `points`, as explicit cubic control points. */ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { +function curvePath(segments: readonly CurveSegment[]): string { const first = segments[0]; if (first === undefined) return ""; - let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; } @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re /** * Turns the merged daily totals into one column per day. * - * Values are absolute, not cumulative: the series are layered from a shared - * zero baseline rather than stacked. A stacked chart puts whichever provider is - * drawn last permanently above the other, which reads as "that one is bigger" - * even on days where it is not. + * Values are absolute, not cumulative: each provider is drawn from the same + * zero baseline so the chart never implies that one provider is always larger. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -216,42 +206,39 @@ export function UsageProviderChart({ ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); + const tooltipRef = useRef(null); + const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const { paths, ticks, stepX, toY, series } = useMemo(() => { + const { paths, series, stepX, ticks, toY } = useMemo(() => { if (periods.length === 0) { return { paths: [], - ticks: [0] as readonly number[], + series: [] as readonly DayColumn[], stepX: 0, + ticks: [0] as readonly number[], toY: () => VIEW_HEIGHT, - series: [] as readonly DayColumn[], }; } const columns = buildPeriodColumns(periods, byPeriod, metric); - - // The scale tops out at the largest single provider-day, not the largest - // sum: layered series each measure from zero, so a combined peak would - // leave the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); - // Reserve a sliver above the top gridline so the series stroke, which is - // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const curve = smoothCurve( - columns.map((column, dayIndex) => ({ - x: dayIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), + const line = curvePath( + smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })), + ), ); - const line = curvePath(curve, "M"); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -260,30 +247,65 @@ export function UsageProviderChart({ }; }); - // Paint the heavier series first so the lighter one is never buried under - // it. The fills are faint enough that the order barely shows, but the - // strokes are drawn in a second pass regardless, so neither can be hidden. - const ordered = [...built].sort((a, b) => b.total - a.total); - - return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; + return { + paths: built.toSorted((a, b) => b.total - a.total), + series: columns, + stepX: step, + ticks: tickValues, + toY, + }; }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; + const positionTooltip = useCallback(() => { + const plot = plotRef.current; + const tooltip = tooltipRef.current; + const hoverPosition = hoverPositionRef.current; + if (plot === null || tooltip === null || hoverPosition === null) return; + + const gap = 12; + const tooltipWidth = tooltip.offsetWidth; + const tooltipHeight = tooltip.offsetHeight; + const plotWidth = plot.clientWidth; + const plotHeight = plot.clientHeight; + const preferredLeft = + hoverPosition.x + gap + tooltipWidth <= plotWidth + ? hoverPosition.x + gap + : hoverPosition.x - gap - tooltipWidth; + const preferredTop = + hoverPosition.y + gap + tooltipHeight <= plotHeight + ? hoverPosition.y + gap + : hoverPosition.y - gap - tooltipHeight; + const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); + const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); + plot.style.setProperty("--usage-tooltip-left", `${left}px`); + plot.style.setProperty("--usage-tooltip-top", `${top}px`); + }, []); + + useLayoutEffect(() => { + if (hoverIndex !== null) positionTooltip(); + }, [hoverIndex, positionTooltip]); + const handleMove = useCallback( (event: React.MouseEvent) => { - const bounds = plotRef.current?.getBoundingClientRect(); - if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; - const fraction = (event.clientX - bounds.left) / bounds.width; + const plot = plotRef.current; + if (plot === null || periods.length === 0) return; + const bounds = plot.getBoundingClientRect(); + if (bounds.width === 0) return; + const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); + const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); + const fraction = localX / bounds.width; const index = Math.round(fraction * (periods.length - 1)); + hoverPositionRef.current = { x: localX, y: localY }; + positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [periods.length], + [periods.length, positionTooltip], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; - const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -311,7 +333,10 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => setHoverIndex(null)} + onMouseLeave={() => { + hoverPositionRef.current = null; + setHoverIndex(null); + }} > ( ))} @@ -368,10 +392,11 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", + left: "var(--usage-tooltip-left, 0px)", + top: "var(--usage-tooltip-top, 0px)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -418,21 +443,3 @@ export function UsageProviderChart({
); } - -export function UsageChartLegend() { - return ( -
- {PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; - return ( - - - {PROVIDER_LABEL[provider]} - - ); - })} -
- ); -} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf4..3ec171859027 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. + * Stable provider reading order across summaries, tables, and hover rows. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 769826e3999c..4bc3237d2a66 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,6 +4,15 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { + "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], + "circle-check": [ + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, + ], + clock: [ + { tag: "path", attrs: { d: "M12 6v6l4 2" } }, + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + ], pencil: [ { tag: "path", @@ -17,6 +26,71 @@ const ICON_PATHS: Record( "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { + if (item.separatorBefore === true && inner.childElementCount > 0) { + const separator = document.createElement("div"); + separator.className = "my-1 h-px bg-border/70"; + separator.style.cssText = + "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; + separator.dataset.contextMenuSeparator = "true"; + separator.setAttribute("role", "separator"); + inner.appendChild(separator); + } + if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; @@ -247,10 +331,12 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - const chevron = document.createElement("span"); - chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; - chevron.textContent = ">"; - button.appendChild(chevron); + const chevron = createIconElement("chevron-right", "neutral"); + if (chevron) { + chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); + chevron.dataset.contextMenuChevron = "true"; + button.appendChild(chevron); + } } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4e636eb4ff0f..bd49f53702cd 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,6 +241,22 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } + @keyframes live-activity-focus { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } + } + @keyframes live-activity-focus-counter { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } + } @keyframes status-ping { /* Burst first (immediate feedback for click ripples), then hold invisible for the rest of the cycle. Mirrors animate-ping's @@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility live-activity-focus { + --live-activity-focus-width: 4.5rem; + + right: auto; + left: calc(-1 * var(--live-activity-focus-width)); + width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + mask-repeat: no-repeat; + animation: live-activity-focus 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 0; + will-change: auto; + } +} + +@utility live-activity-focus-counter { + width: 100%; + animation: live-activity-focus-counter 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + will-change: auto; + } +} + +@utility live-activity-focus-aligned { + width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); + margin-left: var(--live-activity-focus-width); +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { /* The panel layout toggles stay ghost: they render both inside the header and in the titlebar strip, so filling them would make them change appearance as - the panel opens. They only take the themed foreground; hover and pressed - keep the base ghost accent. The tooltip trigger's data-slot wins over the - toggle's when the trigger renders the toggle, so match both. */ + the panel opens. Their icons use the same themed foreground as the toolbar + action text; hover and pressed keep the base ghost accent. The tooltip + trigger's data-slot wins over the toggle's when it renders the toggle, so + match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-foreground); - color: var(--toolbar-foreground); + --control-icon-color: var(--toolbar-control-foreground); + color: var(--toolbar-control-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0b7e6bf0f970..5bfb80bfec32 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } +/** The repository root behind a recognised change-request URL, without PR-specific state. */ +export function changeRequestRepositoryUrl(targetUrl: string): string | null { + const changeRequest = parseChangeRequestUrl(targetUrl); + if (changeRequest === null) return null; + const url = new URL(targetUrl); + url.pathname = `/${changeRequest.repository}`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 803ba787116b..0e1fdc9f884c 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); + expect(onboardingHeader).toContain(''); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 4f4da0c751ef..271715be3ca1 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -17,8 +18,6 @@ import { import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -143,18 +142,13 @@ function HostedStaticOnboardingState() { return (
-
+
{APP_DISPLAY_NAME}
-
+
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 66d9f0caa5da..7d73a225d5a0 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,6 +74,12 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../components/WorkspacePageContainer"; +import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -99,7 +105,6 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1181,6 +1186,17 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); + const linkedSelectionMatchesSurface = + linkedSelection !== null && + selectedPullRequestSurface !== null && + linkedSelection.environmentId === selectedPullRequestSurface.environmentId && + linkedSelection.projectId === selectedPullRequestSurface.projectId && + linkedSelection.repository === selectedPullRequestSurface.repository && + linkedSelection.number === selectedPullRequestSurface.number; + // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are + // history, though, not a current selection: without this check they leave the toggle looking + // available after the selected pull request has been cleared. + const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1294,9 +1310,10 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelState.surfaces.length > 0} + rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} + rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1603,7 +1620,6 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} - chromeVariant="collapse" /> ) : null} @@ -1827,18 +1843,10 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
-
+ {/* A closed right panel leaves this column full-width, so the shared header + reserves native window controls. While the panel is open, the column ends + at the panel and the absolute controls strip owns the top-right corner. */} + {condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1880,27 +1888,24 @@ function PullRequestsColumn({ )}
{condensed ? ( - { - topbarSearchFocusedRef.current = focused; - }} - /> +
+ { + topbarSearchFocusedRef.current = focused; + }} + /> + +
+ ) : null} + {rightPanelControl ? ( + {rightPanelControl} ) : null} - - {rightPanelControl} -
+
+
{searchInput} {filtersMenu} + {!condensed ? ( + + ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} -
+
); } + +function PullRequestRefreshControl({ + compact = false, + refreshing, + onRefresh, +}: { + compact?: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a4b248c84ed9..431e196de8b1 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,9 +13,8 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -72,41 +71,16 @@ function SettingsContentLayout() { return (
- {!isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
-
- )} - - {isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
+ +
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null}
- )} +
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f5effff6602c..2eadd1fc5fb5 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("omits tool started entries and keeps completed entries", () => { + it("shows a command from its start event while it is still running", () => { const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + id: "tool-start", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "inProgress", + sourceActivityKind: "tool.started", + }); + }); + + it("retains the start command when the matching completion omits it", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + data: { input: { command: "vp test run" } }, + }, + }), + makeActivity({ + id: "other-tool-start", + createdAt: "2026-02-23T00:00:02.500Z", + summary: "Other command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "inProgress", + title: "Other command", + data: { input: { command: "vp lint" } }, + }, + }), makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:03.000Z", - summary: "Tool call complete", + summary: "Command run", kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "completed", + title: "Command run", + }, }), makeActivity({ - id: "tool-start", + id: "other-tool-complete", + createdAt: "2026-02-23T00:00:04.000Z", + summary: "Other command", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "completed", + title: "Other command", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "tool-complete", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + expect(entries[1]).toMatchObject({ + id: "other-tool-complete", + command: "vp lint", + toolCallId: "call-2", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + }); + + it("does not merge non-adjacent tool starts without stable call ids", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unkeyed-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + makeActivity({ + id: "keyed-start", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Tool call", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-between", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "unkeyed-start-2", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Search started", kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, }), ]; - const entries = deriveWorkLogEntries(activities); - expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "unkeyed-start-1", + "keyed-start", + "unkeyed-start-2", + ]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", + toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..efe1876dfc1c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,6 +65,8 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; + /** Stable provider identity across in-progress and completed lifecycle updates. */ + toolCallId?: string; label: string; detail?: string; command?: string; @@ -748,7 +750,6 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -757,8 +758,13 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Plan updates have a dedicated task row. Keeping the raw activity here + // duplicates it as a legacy "Work Log / Plan updated" row when history + // is expanded. + if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isCodexTerminalInteractionActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -769,7 +775,11 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + if ( + activity.kind !== "tool.started" && + activity.kind !== "tool.updated" && + activity.kind !== "tool.completed" + ) { return false; } @@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } +/** + * Codex terminal interactions report bytes written to an already-running PTY. + * Some thread histories contain them as generic tool.updated rows, so filter + * their exact wire shape from the presentation model. This repairs existing + * history without deleting or rewriting persisted activities. + */ +function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated") { + return false; + } + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + return ( + payload?.itemType === "command_execution" && + typeof data?.itemId === "string" && + typeof data.processId === "string" && + typeof data.stdin === "string" && + typeof data.threadId === "string" && + typeof data.turnId === "string" + ); +} + function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.started") { + toolLifecycleStatus = "inProgress"; + } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } +function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { + return undefined; + } + return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries( }); continue; } + const lifecycleKey = toolLifecycleCollapseMapKey(entry); + if (lifecycleKey !== undefined) { + const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); + if (matchingLifecycleIndex !== undefined) { + const matchingEntry = collapsed[matchingLifecycleIndex]; + if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { + toolLifecycleRowIndex.delete(lifecycleKey); + const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); + collapsed[matchingLifecycleIndex] = merged; + if (merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); + } + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const previousIndex = collapsed.length - 1; + const previousKey = toolLifecycleCollapseMapKey(previous); + if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); + const merged = mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + const mergedKey = toolLifecycleCollapseMapKey(merged); + if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(mergedKey, previousIndex); + } continue; } collapsed.push(entry); + if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } @@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.activityKind !== "tool.started" && + previous.activityKind !== "tool.updated" && + previous.activityKind !== "tool.completed" + ) { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { return false; } if (previous.activityKind === "tool.completed") { @@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { return undefined; } if (entry.toolCallId) { @@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); + const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, + dataInput?.command, + stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(data?.toolCallId); + return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1fe..f7a6412d51db 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => { it("reconciles terminal ids from an external ordered list", () => { const store = useTerminalUiStateStore.getState(); store.setTerminalOpen(THREAD_REF, true); + store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); + store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); const terminalUiState = selectThreadTerminalUiState( @@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => { { id: "group-term-a", terminalIds: ["term-a"] }, { id: "group-term-b", terminalIds: ["term-b"] }, ]); + expect( + useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ + scopedThreadKey(THREAD_REF) + ], + ).toEqual({ "term-a": "API server" }); }); it("does not import a closed panel terminal from stale metadata", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954c..545e195a1287 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,8 +32,11 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; terminalStateByThreadKey?: Record; + terminalCustomLabelsByThreadKey?: Record>; } +const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly> = Object.freeze({}); + export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, @@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState( parseScopedThreadKey(threadKey), ), ); + const terminalCustomLabelsByThreadKey = Object.fromEntries( + Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( + ([threadKey, labels]) => { + if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; + const normalizedLabels = Object.fromEntries( + Object.entries(labels).flatMap(([terminalId, label]) => { + const normalizedTerminalId = terminalId.trim(); + const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; + return normalizedTerminalId && normalizedLabel + ? [[normalizedTerminalId, normalizedLabel] as const] + : []; + }), + ); + return Object.keys(normalizedLabels).length > 0 + ? [[threadKey, normalizedLabels] as const] + : []; + }, + ), + ); - return { terminalUiStateByThreadKey }; + return { + terminalUiStateByThreadKey, + ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 + ? { terminalCustomLabelsByThreadKey } + : {}), + }; } function createTerminalUiStateStorage() { @@ -489,6 +516,18 @@ export function selectThreadTerminalUiState( ); } +export function selectThreadTerminalCustomLabels( + terminalCustomLabelsByThreadKey: Record>, + threadRef: ScopedThreadRef | null | undefined, +): Readonly> { + if (!threadRef || threadRef.threadId.length === 0) { + return EMPTY_TERMINAL_CUSTOM_LABELS; + } + return ( + terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS + ); +} + function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record, threadRef: ScopedThreadRef, @@ -562,6 +601,7 @@ function removeRecordEntry(record: Record, key: string): Record; + terminalCustomLabelsByThreadKey: Record>; /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -575,6 +615,11 @@ interface TerminalUiStateStoreState { options?: { open?: boolean; active?: boolean }, ) => void; setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + setTerminalCustomLabel: ( + threadRef: ScopedThreadRef, + terminalId: string, + label: string | null, + ) => void; closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; clearTerminalUiState: (threadRef: ScopedThreadRef) => void; @@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { terminalId: string; suppressed: boolean }, + suppression?: { + terminalId: string; + suppressed: boolean; + clearCustomLabel?: boolean; + }, + pruneCustomLabels = false, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create()( suppression.suppressed, ) : state.suppressedTerminalIdsByThreadKey; + const terminalIdToClear = suppression?.clearCustomLabel + ? suppression.terminalId.trim() + : ""; + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + let nextTerminalCustomLabelsByThreadKey = + terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined + ? Object.keys(currentLabels).length === 1 + ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) + : { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), + } + : state.terminalCustomLabelsByThreadKey; + if (pruneCustomLabels) { + const survivingIds = new Set( + selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, + ); + const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; + const survivingLabels = Object.fromEntries( + Object.entries(labelsForThread).filter(([terminalId]) => + survivingIds.has(terminalId), + ), + ); + if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { + nextTerminalCustomLabelsByThreadKey = + Object.keys(survivingLabels).length > 0 + ? { + ...nextTerminalCustomLabelsByThreadKey, + [threadKey]: survivingLabels, + } + : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); + } + } if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && + nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create()( ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), + setTerminalCustomLabel: (threadRef, terminalId, label) => + set((state) => { + const normalizedTerminalId = terminalId.trim(); + if (normalizedTerminalId.length === 0) return state; + const threadKey = terminalThreadKey(threadRef); + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + const normalizedLabel = label?.trim().slice(0, 80) ?? ""; + if (normalizedLabel.length > 0) { + if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; + return { + terminalCustomLabelsByThreadKey: { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, + }, + }; + } + if (currentLabels[normalizedTerminalId] === undefined) return state; + const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; + return { + terminalCustomLabelsByThreadKey: + Object.keys(remainingLabels).length > 0 + ? { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: remainingLabels, + } + : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), + }; + }), closeTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { terminalId, suppressed: true, + clearCustomLabel: true, }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal(threadRef, (state, suppressedTerminalIds) => { - if (suppressedTerminalIds.length === 0) { - return reconcileThreadTerminalSessionIds(state, nextIds); - } - const suppressedIds = new Set(suppressedTerminalIds); - return reconcileThreadTerminalSessionIds( - state, - nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), - ); - }), + updateTerminal( + threadRef, + (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }, + undefined, + true, + ), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds + !hadSuppressedTerminalIds && + !hadCustomLabels ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds) { + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; + if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { return state; } return { @@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create()( const orphanedIds = new Set( [ ...Object.keys(state.terminalUiStateByThreadKey), + ...Object.keys(state.terminalCustomLabelsByThreadKey), ...Object.keys(state.suppressedTerminalIdsByThreadKey), ].filter((key) => !activeThreadKeys.has(key)), ); @@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create()( const nextSuppressedTerminalIdsByThreadKey = { ...state.suppressedTerminalIdsByThreadKey, }; + const nextTerminalCustomLabelsByThreadKey = { + ...state.terminalCustomLabelsByThreadKey, + }; for (const id of orphanedIds) { delete nextTerminalUiStateByThreadKey[id]; + delete nextTerminalCustomLabelsByThreadKey[id]; delete nextSuppressedTerminalIdsByThreadKey[id]; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), @@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 4, + version: 5, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, }), }, ), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602a..03451cc7b2ec 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,6 +111,8 @@ export interface ContextMenuItem { header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; + /** Inserts a visual section divider immediately before this item. */ + separatorBefore?: boolean; children?: readonly ContextMenuItem[]; } @@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; + readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), + separatorBefore: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index c2fa9e2a86a1..81270ad320cb 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,6 +248,7 @@ describe("mergeUsage", () => { ); expect(merged.sessions).toBe(1); + expect(merged.providers[0]?.sessions).toBe(1); }); it("returns empty totals with no environments", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 886b214183bc..f5e54434fd97 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,6 +25,7 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { +): { + readonly buckets: readonly UsageBucket[]; + readonly sessionsByProvider: ReadonlyMap; +} { const ownedProviders = new Set(); - let sessions = 0; + const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - ownedProviders.add(source.fingerprint.provider); + const provider = source.fingerprint.provider; + ownedProviders.add(provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessions += source.distinctSessions; + sessionsByProvider.set( + provider, + (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + ); } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessions, + sessionsByProvider, }; } @@ -228,7 +236,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number } + { costUsd: number; totalTokens: number; records: number; sessions: number } >(); const modelAccumulator = new Map< string, @@ -255,12 +263,20 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessions: environmentSessions } = ownedContribution( - environment, - ownerByFingerprint, - ); + const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - sessions += environmentSessions; + + for (const [providerKind, providerSessions] of sessionsByProvider) { + sessions += providerSessions; + const provider = providerAccumulator.get(providerKind) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + sessions: 0, + }; + provider.sessions += providerSessions; + providerAccumulator.set(providerKind, provider); + } for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -280,6 +296,7 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -341,6 +358,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) From 804cba4305b15f929937833c93e85db0835d8903 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 22:00:02 -0400 Subject: [PATCH 041/144] revert: refresh workspace layouts and tool activity (#6657) --- .../desktop/src/electron/ElectronMenu.test.ts | 11 +- apps/desktop/src/electron/ElectronMenu.ts | 10 +- .../ActivityPayloadProjection.test.ts | 41 +- .../ActivityPayloadProjection.ts | 34 +- .../Layers/ProviderRuntimeIngestion.test.ts | 30 +- .../Layers/ProviderRuntimeIngestion.ts | 6 - apps/web/src/components/ChatView.tsx | 58 +- .../src/components/NoActiveThreadState.tsx | 19 +- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 316 +++++---- .../src/components/ThreadTerminalDrawer.tsx | 664 ++++++++---------- .../src/components/WorkspacePageContainer.tsx | 62 -- .../components/chat/ChangedFilesTree.test.tsx | 23 +- .../src/components/chat/ChangedFilesTree.tsx | 62 +- .../chat/MessagesTimeline.logic.test.ts | 216 +----- .../components/chat/MessagesTimeline.logic.ts | 412 +---------- .../components/chat/MessagesTimeline.test.tsx | 46 +- .../src/components/chat/MessagesTimeline.tsx | 491 ++++--------- .../components/chat/PanelLayoutControls.tsx | 18 +- apps/web/src/components/composerInlineChip.ts | 14 +- .../pullRequest/PullRequestDetailPanel.tsx | 617 ++++++++-------- .../pullRequest/PullRequestGhosts.tsx | 115 +-- .../pullRequest/PullRequestListFilters.tsx | 15 +- .../pullRequest/PullRequestSummaryTab.tsx | 6 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/KeybindingsSettings.tsx | 2 +- .../settings/ProjectSettingsPanel.tsx | 26 +- .../settings/SettingsSidebarNav.tsx | 25 +- .../src/components/settings/ThemeSettings.tsx | 2 +- .../components/settings/settingsLayout.tsx | 9 +- .../src/components/sidebar/SidebarChrome.tsx | 146 ++-- .../components/threadActionMenu.logic.test.ts | 14 +- .../src/components/threadActionMenu.logic.ts | 42 +- apps/web/src/components/ui/segmented-tabs.tsx | 40 -- apps/web/src/components/ui/toggle.tsx | 6 - apps/web/src/components/usage/UsagePage.tsx | 393 +++++++---- .../components/usage/UsageProviderChart.tsx | 137 ++-- .../src/components/usage/usageProviders.ts | 4 +- apps/web/src/contextMenuFallback.ts | 98 +-- apps/web/src/index.css | 83 +-- apps/web/src/lib/openPullRequestLink.ts | 11 - .../web/src/routes/-chatIndexTitlebar.test.ts | 4 +- apps/web/src/routes/_chat.index.tsx | 12 +- apps/web/src/routes/_chat.pull-requests.tsx | 103 +-- apps/web/src/routes/settings.tsx | 46 +- apps/web/src/session-logic.test.ts | 133 +--- apps/web/src/session-logic.ts | 105 +-- apps/web/src/terminalUiStateStore.test.ts | 8 - apps/web/src/terminalUiStateStore.ts | 170 +---- packages/contracts/src/ipc.ts | 4 - packages/shared/src/usageMerge.test.ts | 1 - packages/shared/src/usageMerge.ts | 40 +- 52 files changed, 1727 insertions(+), 3228 deletions(-) delete mode 100644 apps/web/src/components/WorkspacePageContainer.tsx delete mode 100644 apps/web/src/components/ui/segmented-tabs.tsx diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index e3c5d5dd6431..58870bbab1db 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,10 +98,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [ - { id: "copy", label: "Copy" }, - { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, - ], + items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -113,12 +110,6 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Delete"], - ); }).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ca8cc246e895..4d3e5a1c2416 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, - ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { @@ -142,17 +141,10 @@ export const make = Effect.gen(function* () { ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; - const appendSeparator = () => { - if (template.length === 0 || template.at(-1)?.type === "separator") return; - template.push({ type: "separator" }); - }; for (const item of entries) { - if (item.separatorBefore) { - appendSeparator(); - } if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - appendSeparator(); + template.push({ type: "separator" }); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 047e40ccf490..fc9ea4b62268 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload", () => { +describe("projectActivityPayload agent-field survival", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,45 +44,6 @@ describe("projectActivityPayload", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); - it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { - const claude = projectActivityPayload( - activity({ - itemType: "command_execution", - toolCallId: "claude-call-1", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - result: { content: "x".repeat(5_000) }, - }, - }), - ); - const openCode = projectActivityPayload( - activity({ - itemType: "command_execution", - toolCallId: "opencode-call-1", - data: { - tool: "bash", - state: { - status: "running", - input: { command: "vp lint" }, - output: "x".repeat(5_000), - }, - }, - }), - ); - - expect(claude.payload).toMatchObject({ - toolCallId: "claude-call-1", - data: { command: "vp test run" }, - }); - expect(openCode.payload).toMatchObject({ - toolCallId: "opencode-call-1", - data: { command: "vp lint" }, - }); - expect(JSON.stringify(claude.payload).length).toBeLessThan(200); - expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); - }); - it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 659760c049a4..f68a3ee96e9b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,24 +104,6 @@ function projectCommandData(data: Record): Record 0 ? projectedItem : undefined; } -function projectCommandValue(data: Record): unknown { - if (data.command !== undefined) { - return data.command; - } - - const input = asRecord(data.input); - if (input?.command !== undefined) { - return input.command; - } - - const stateInput = asRecord(asRecord(data.state)?.input); - if (stateInput?.command !== undefined) { - return stateInput.command; - } - - return undefined; -} - function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -305,9 +287,8 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - const command = projectCommandValue(data); - if (command !== undefined) { - projectedData.command = command; + if ("command" in data) { + projectedData.command = data.command; } const changedFiles: string[] = []; @@ -387,10 +368,10 @@ function dropStaleContextWindowActivities( /** * Identity both clients use to fold a tool lifecycle row into the call it * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): the runtime item id ingestion stamps as - * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. - * Returns null for rows with no identity at all — those never collapse on the - * client either, so they must not be dropped here. + * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter + * emits one, otherwise the itemType/title/detail triple. Returns null for rows + * with no identity at all — those never collapse on the client either, so they + * must not be dropped here. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = - asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b5feda5052d8..258aa010e3e6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), - itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "inProgress", - title: "Command run", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, + status: "in_progress", + title: "Read file", + detail: "/tmp/file.ts", }, }); @@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - const activity = thread.activities.find( - (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", - ); - const payload = activity?.payload as Record | undefined; - expect(payload).toMatchObject({ - itemType: "command_execution", - toolCallId: "tool-call-9", - status: "inProgress", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, - }); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), + ).toBe(true); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 1eb7e54b3b36..03253797242e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,7 +794,6 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -822,8 +821,6 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), - ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -850,10 +847,7 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), - ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7193a7d0ffd..6eab33aec1c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -219,11 +220,7 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { - selectThreadTerminalCustomLabels, - selectThreadTerminalUiState, - useTerminalUiStateStore, -} from "../terminalUiStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { WorkspacePageHeader } from "./WorkspacePageContainer"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, @@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; - onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, - onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} - onHide={onHide} splitShortcutLabel={visible ? splitShortcutLabel : undefined} splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} @@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; - const activeThreadRef = useMemo( - () => - activeThreadEnvironmentId && activeThreadId - ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) - : null, - [activeThreadEnvironmentId, activeThreadId], - ); - const activeTerminalCustomLabels = useTerminalUiStateStore((state) => - selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), - ); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - activeTerminalCustomLabels[session.target.terminalId] ?? - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } - for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { - if (!labels.has(terminalId)) labels.set(terminalId, label); - } return labels; - }, [activeTerminalCustomLabels, activeThreadKnownSessions]); + }, [activeThreadKnownSessions]); + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); - const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } + chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} - {!rightPanelOpen ? panelLayoutControls : null} - +
))} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index cfc40f93638b..82dddd8f41e0 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,15 +1,26 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { WorkspacePageHeader } from "./WorkspacePageContainer"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return (
- +
{isElectron ? ( - No active thread + + No active thread + ) : (
@@ -17,7 +28,7 @@ export function NoActiveThreadState() {
)} - +
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f43bd5ea629b..9cb09219df09 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, - hasProjectScope = false, ): boolean { - return hasProjectScope || shiftKey || projectGroupCount <= 1; + return shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 010571b915df..2f0c5a221405 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,7 +184,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; -const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -367,20 +366,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - /> - } - > - - + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + {presets.map((preset) => ( ) : ( @@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - - {props.isPinned ? ( - props.pinningSupported ? ( - - ) : ( - - - - ) - ) : null} - {/* Only the visible state owns this slot's width: the pin stays - directly beside the idle status and beside the first action - when the hover controls replace it. */} + + + Unpin thread + + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, + actions on hover/keyboard focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - + ) : null} + {props.settlementSupported ? ( + + - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( - - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - ) : null} - - ) : null} - + + Settle thread + + ) : null} + + ) : null}
@@ -3211,25 +3218,17 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // A selected project scope owns creation: users should not have to choose - // the same project twice. "All projects" keeps the picker in multi-project - // setups, while Shift+click retains the direct-create shortcut. + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - if ( - shouldCreateNewThreadInCurrentProject( - event?.shiftKey ?? false, - projectGroups.length, - scopedProjectGroup !== null, - ) - ) { + // One project: nothing to pick, create immediately. Shift+click creates + // directly in the current project even with several projects, skipping + // the palette picker. + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); - if (scopedProjectGroup) { - void newThreadContext.handleNewThread( - scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), - ); - return; - } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3241,19 +3240,20 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, setOpenMobile], ); - // With no explicit scope the button mirrors chat.new. A scoped button has - // intentionally more specific behavior, so it does not advertise the - // broader command's shortcut. + // The button mirrors chat.new: in multi-project setups both route through + // the command palette's "New thread in..." picker, and in single-project + // setups both create immediately. In multi-project setups the label is only + // the picker's shortcut: falling back to chat.newLocal would advertise the + // same shortcut for both the picker and direct create. In single-project + // setups both commands create directly, so chat.newLocal is a valid + // fallback. The second tooltip line (multi-project only) advertises + // shift+click and its keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = - scopedProjectGroup === null - ? (shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 - ? shortcutLabelForCommand(keybindings, "chat.newLocal") - : undefined)) - : undefined; + shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,9 +3332,7 @@ export default function Sidebar() { /> - {scopedProjectGroup ? ( - `New thread in ${scopedProjectGroup.displayName}` - ) : projectGroups.length > 1 ? ( + {projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index deec13ec3bda..1266e5ed7e94 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { - PanelBottomCloseIcon, Plus, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, + Trash2, XIcon, } from "lucide-react"; import { @@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, + type ReactNode, type SetStateAction, useCallback, useEffect, @@ -29,9 +30,9 @@ import { useRef, useState, } from "react"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -59,7 +60,6 @@ import { import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -72,15 +72,10 @@ import { resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../appearanceFonts"; -import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; -const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; -const TERMINAL_SIDEBAR_MIN_WIDTH = 144; -const TERMINAL_SIDEBAR_MAX_WIDTH = 320; -const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } -export function shouldShowTerminalSidebar(terminalCount: number): boolean { - return terminalCount > 1; -} - export function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number }; @@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; - onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } +interface TerminalActionButtonProps { + label: string; + className: string; + onClick: () => void; + children: ReactNode; +} + +function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { + return ( + + } + > + {children} + + + {label} + + + ); +} + export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, - onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; - const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = - useResizableWidth({ - storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, - defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, - minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, - maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, - edge: "left", - }); - const terminalCustomLabels = useTerminalUiStateStore((state) => - selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), - ); - const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); - const [renamingTerminalId, setRenamingTerminalId] = useState(null); - const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); - const cancelTerminalRenameRef = useRef(false); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); + const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; + const showGroupHeaders = + resolvedTerminalGroups.length > 1 || + resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const automaticTerminalLabelById = useMemo(() => { + const terminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); - const terminalLabelById = useMemo(() => { - const next = new Map(); - for (const terminalId of normalizedTerminalIds) { - next.set( - terminalId, - terminalCustomLabels[terminalId]?.trim() || - automaticTerminalLabelById.get(terminalId) || - getTerminalLabel(terminalId), - ); - } - return next; - }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({ }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; @@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - const startTerminalRename = useCallback( - (terminalId: string) => { - cancelTerminalRenameRef.current = false; - setRenamingTerminalId(terminalId); - setTerminalRenameDraft( - terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", - ); - }, - [terminalCustomLabels, terminalLabelById], - ); - const finishTerminalRename = useCallback(() => { - if (!renamingTerminalId) return; - const nextLabel = terminalRenameDraft.trim(); - const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; - setTerminalCustomLabel( - threadRef, - renamingTerminalId, - nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, - ); - setRenamingTerminalId(null); - }, [ - automaticTerminalLabelById, - renamingTerminalId, - setTerminalCustomLabel, - terminalRenameDraft, - threadRef, - ]); - const cancelTerminalRename = useCallback(() => { - cancelTerminalRenameRef.current = true; - setRenamingTerminalId(null); - }, []); - - useEffect(() => { - cancelTerminalRenameRef.current = false; - setRenamingTerminalId(null); - setTerminalRenameDraft(""); - }, [threadRef.environmentId, threadRef.threadId]); + const onNewTerminalAction = useCallback(() => { + onNewTerminal(); + }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - const compactTerminalToolbar = ( - <> - - - - - {!isPanel && onHide ? ( - <> - - - - ) : null} - - ); + return ( + {showGroupHeaders && ( + + )} + + {normalizedTerminalIds.length > 1 && ( + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + + )} +
+ ); + })} +
+
+ ); + })} +
+ + )} +
); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx deleted file mode 100644 index 4613dd465b1c..000000000000 --- a/apps/web/src/components/WorkspacePageContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { ComponentPropsWithoutRef } from "react"; - -import { cn } from "../lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; - -export type WorkspacePageWidth = "readable" | "wide" | "expanded"; - -const WIDTH_CLASS: Record = { - readable: "max-w-4xl", - wide: "max-w-5xl", - expanded: "max-w-6xl", -}; - -/** Shared full-page frame for workspace routes beneath their top bar. */ -export function WorkspacePageContainer({ - width = "readable", - className, - ...props -}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { - return ( -
- ); -} - -/** Shared top-bar geometry for every full-width workspace surface. */ -export function WorkspacePageHeader({ - electron = false, - reserveNativeControls = electron, - className, - ...props -}: ComponentPropsWithoutRef<"header"> & { - readonly electron?: boolean; - readonly reserveNativeControls?: boolean; -}) { - return ( -
- ); -} - -/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */ -export function WorkspacePageHeaderEdgeControl({ - className, - ...props -}: ComponentPropsWithoutRef<"div">) { - return
; -} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index bc3c4fa80dfc..e9fa1895bf99 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,9 +23,13 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); + expect(markup).toContain( + 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', + ); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); + expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); + expect(markup).toContain("@[24rem]/changed-files:inline"); + expect(markup).not.toContain("sm:inline"); expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); - expect(markup).not.toContain("Hide files"); - expect(markup).not.toContain("ml-auto"); }); - it("renders a clean representative-file preview for a large latest change", () => { + it("renders a scope and representative-file preview for a large latest change", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-changed-files-state="preview"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps/web/src/"); - expect(markup).toContain("packages/shared/src/"); + expect(markup).toContain("apps"); + expect(markup).toContain("2 files"); + expect(markup).toContain("packages"); + expect(markup).toContain("root"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).not.toContain("basis-0"); - expect(markup).not.toContain("+1 more"); - expect(markup).not.toContain("Show files"); - expect(markup).toContain('aria-label="120 additions, 20 deletions"'); + expect(markup).toContain("Show all 4 files"); expect(markup).not.toContain("App.test.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a8bb461c0e12..d29d8b7f2f44 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; +import { + changedFileName, + selectChangedFilePreview, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); + const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); const compactPreviewVisible = showCompactPreview && !expanded; return (
onExpandedChange(!expanded)} > )} + + {expanded ? "Hide files" : "Show files"} +
{expanded ? ( @@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} /> ) : compactPreviewVisible ? ( -
-
+
+

+ {scopeSummary.map((scope, index) => ( + + {index > 0 ? : null} + {scope.label} + + {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} + + + ))} +

+
{previewFiles.map((file) => ( ))} +
) : null} @@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 82338dec2a89..6d74204bc1ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", - "assistant-thought-entry", - "work-toggle:work-entry-1", "turn-fold:turn-1", + "assistant-thought-entry", + "work-entry-1", "assistant-final-entry", ]); expect( @@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); - it("keeps a superseded turn fold beside the final response after a steer", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "initial-user-entry", - kind: "message", - createdAt: "2026-01-01T00:00:00Z", - message: { - id: "initial-user" as never, - role: "user", - text: "Start the work", - turnId: null, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - streaming: false, - }, - }, - { - id: "superseded-work-entry", - kind: "work", - createdAt: "2026-01-01T00:00:10Z", - entry: { - id: "superseded-work", - createdAt: "2026-01-01T00:00:10Z", - turnId: "turn-1" as never, - label: "Ran command", - tone: "tool", - }, - }, - { - id: "steer-user-entry", - kind: "message", - createdAt: "2026-01-01T00:00:12Z", - message: { - id: "steer-user" as never, - role: "user", - text: "Change the approach", - turnId: null, - createdAt: "2026-01-01T00:00:12Z", - updatedAt: "2026-01-01T00:00:12Z", - streaming: false, - }, - }, - { - id: "assistant-final-entry", - kind: "message", - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "assistant-final" as never, - role: "assistant", - text: "Implemented locally, uncommitted.", - turnId: "turn-2" as never, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:21Z", - streaming: false, - }, - }, - ], - latestTurn: { - turnId: "turn-2" as never, - state: "completed", - startedAt: "2026-01-01T00:00:12Z", - completedAt: "2026-01-01T00:00:21Z", - }, - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - expect(rows.map((row) => row.id)).toEqual([ - "initial-user-entry", - "steer-user-entry", - "turn-fold:turn-1", - "assistant-final-entry", - ]); - }); - it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ - "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", - ]); - }); - - it("keeps the current tool batch expandable while live entries append", () => { - const timelineEntries = [ - { - id: "work-entry-1", - kind: "work" as const, - createdAt: "2026-01-01T00:00:01Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:01Z", - turnId: "turn-1" as never, - toolCallId: "call-1", - label: "Read file", - tone: "tool" as const, - }, - }, - { - id: "work-entry-2", - kind: "work" as const, - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "work-2", - createdAt: "2026-01-01T00:00:02Z", - turnId: "turn-1" as never, - toolCallId: "call-2", - label: "Run command", - command: "vp test run", - tone: "tool" as const, - }, - }, - ]; - const baseInput = { - timelineEntries, - latestTurn: { - turnId: "turn-1" as never, - state: "running" as const, - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }; - - const collapsedRows = deriveMessagesTimelineRows(baseInput); - const expandedRows = deriveMessagesTimelineRows({ - ...baseInput, - expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), - }); - - expect(collapsedRows.map((row) => row.id)).toEqual([ + "work-entry-1", "working-indicator-row", - "work-live:tool:call-1", ]); - expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ - groupId: "work-group:tool:call-1", - expanded: false, - groupedEntries: [{ id: "work-1" }, { id: "work-2" }], - }); - expect(expandedRows.map((row) => row.id)).toEqual([ - "working-indicator-row", - "work-live:tool:call-1", - "work-1", - "work-2", - ]); - expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ - groupId: "work-group:tool:call-1", - expanded: true, - }); - - const appendedRows = deriveMessagesTimelineRows({ - ...baseInput, - timelineEntries: [ - ...timelineEntries, - { - id: "work-entry-3", - kind: "work" as const, - createdAt: "2026-01-01T00:00:03Z", - entry: { - id: "work-3", - createdAt: "2026-01-01T00:00:03Z", - turnId: "turn-1" as never, - toolCallId: "call-3", - label: "Changed file", - tone: "tool" as const, - }, - }, - ], - expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), - }); - - expect(appendedRows.map((row) => row.id)).toEqual([ - "working-indicator-row", - "work-live:tool:call-1", - "work-1", - "work-2", - "work-3", - ]); - - const rowsWithLaterPlan = deriveMessagesTimelineRows({ - ...baseInput, - timelineEntries: [ - ...timelineEntries, - { - id: "plan:thread-1:turn:turn-1", - kind: "proposed-plan" as const, - createdAt: "2026-01-01T00:00:03Z", - proposedPlan: { - id: "plan:thread-1:turn:turn-1", - turnId: "turn-1" as never, - planMarkdown: "# Next steps", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-01-01T00:00:03Z", - updatedAt: "2026-01-01T00:00:03Z", - }, - }, - ], - }); - expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); - expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 3, + hiddenCount: 2, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ - "work-toggle:work-entry-1", "work-1", "work-2", "work-3", + "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8d7fc52fdca6..6bc0a2a6203c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,6 @@ import * as Equal from "effect/Equal"; import { formatDuration, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -167,17 +166,6 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; - isExpandedToolGroupEntry: boolean; - isLastExpandedToolGroupEntry: boolean; - } - | { - kind: "work-live"; - id: string; - createdAt: string; - entry: WorkLogEntry; - groupedEntries: WorkLogEntry[]; - groupId: string; - expanded: boolean; } | { kind: "work-toggle"; @@ -187,9 +175,6 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; - summary: string | null; - summaryKind: ToolGroupAction | "mixed" | null; - hasFailure: boolean; } | { kind: "turn-fold"; @@ -223,12 +208,7 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { - kind: "working"; - id: string; - createdAt: string | null; - showThinking: boolean; - }; + | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; - -function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { - if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read"; - if ( - entry.requestKind === "file-change" || - entry.itemType === "file_change" || - (entry.changedFiles?.length ?? 0) > 0 - ) { - return "edit"; - } - if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { - return "command"; - } - if (entry.itemType === "web_search") return "search"; - return "other"; -} - -function toolGroupActionCount( - action: ToolGroupAction, - entries: ReadonlyArray, -): number { - if (action !== "edit") return entries.length; - - const changedFiles = new Set(); - let editsWithoutFileDetails = 0; - for (const entry of entries) { - if (!entry.changedFiles || entry.changedFiles.length === 0) { - editsWithoutFileDetails += 1; - continue; - } - for (const file of entry.changedFiles) changedFiles.add(file); - } - return changedFiles.size + editsWithoutFileDetails; -} - -function toolGroupActionLabel(action: ToolGroupAction, count: number): string { - switch (action) { - case "read": - return `Read ${count} ${count === 1 ? "file" : "files"}`; - case "edit": - return `Changed ${count} ${count === 1 ? "file" : "files"}`; - case "command": - return `Ran ${count} ${count === 1 ? "command" : "commands"}`; - case "search": - return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; - case "other": - return `Used ${count} ${count === 1 ? "tool" : "tools"}`; - } -} - -/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ -export function summarizeToolGroup(entries: ReadonlyArray): string { - const groupedEntries = new Map(); - for (const entry of entries) { - const action = toolGroupAction(entry); - const group = groupedEntries.get(action); - if (group) group.push(entry); - else groupedEntries.set(action, [entry]); - } - const labels = [...groupedEntries].map(([action, actionEntries]) => - toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), - ); - const sentenceLabels = labels.map((label, index) => - index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), - ); - if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; - if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); - return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; -} - -function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupAction | "mixed" { - const actions = new Set(entries.map(toolGroupAction)); - return actions.size === 1 ? actions.values().next().value! : "mixed"; -} - -function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { - return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; -} - -function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { - return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; -} - export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -414,34 +310,17 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } -function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { - return timelineEntries.findLastIndex( - (entry) => entry.kind === "message" && entry.message.role === "user", - ); -} - -function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { - if (entry.kind === "message") { - return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; - } - if (entry.kind === "turn-plan") { - return entry.turnPlan.turnId; - } - return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; -} - /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row placed immediately before the next terminal assistant - * response. A steer can split one visible response across turn ids, so tying - * the disclosure to the first hidden entry would strand it above the steer. + * "Worked for ..." row anchored at the turn's first foldable entry; the + * terminal assistant message stays visible below the fold. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap> { +}): ReadonlyMap { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -496,7 +375,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -526,24 +405,6 @@ function deriveTurnFolds(input: { if (!firstEntry || !lastEntry) { continue; } - const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => - hiddenEntryIds.has(entry.id), - ); - if (lastHiddenEntryIndex < 0) { - continue; - } - const nextTerminalAssistantEntry = input.timelineEntries - .slice(lastHiddenEntryIndex + 1) - .find( - (entry) => - entry.kind === "message" && - entry.message.role === "assistant" && - input.terminalAssistantMessageIds.has(entry.message.id), - ); - const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; - if (!anchorEntry) { - continue; - } const isLatestInterruptedTurn = input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; @@ -570,16 +431,13 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - const fold = { + foldsByAnchorEntryId.set(firstEntry.id, { turnId, - anchorEntryId: anchorEntry.id, - createdAt: anchorEntry.createdAt, + anchorEntryId: firstEntry.id, + createdAt: firstEntry.createdAt, hiddenEntryIds, label, - }; - const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); - if (anchoredFolds) anchoredFolds.push(fold); - else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); + }); } return foldsByAnchorEntryId; } @@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - for (const folds of foldsByAnchorEntryId.values()) { - for (const fold of folds) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); - } + for (const fold of foldsByAnchorEntryId.values()) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); } } } - let activeTurnHeaderIndex = input.timelineEntries.length; - if (input.isWorking) { - const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); - const firstOwnedAfterUser = - unsettledTurnId === null - ? -1 - : input.timelineEntries.findIndex( - (entry, index) => - index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, - ); - activeTurnHeaderIndex = - firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; - } - const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => - input.isWorking && - index >= activeTurnHeaderIndex && - (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); - const isVisibleActiveToolEntry = (entry: WorkLogEntry) => - workLogEntryIsToolLike(entry) && - (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = - activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); - } - if (entry.kind === "turn-plan") return true; - return false; - }) || - input.timelineEntries - .slice(activeTurnHeaderIndex) - .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); - - const activeWorkEntryIds = new Set(); - const activeWorkRowsByAnchorId = new Map< - string, - Extract - >(); - const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); - for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { - const entry = input.timelineEntries[index]; - if (!entry) continue; - const isVisibleTurnContent = - (entry.kind === "message" && entry.message.role === "user") || - entry.kind === "proposed-plan" || - (entryBelongsToActiveTurn(entry, index) && - ((entry.kind === "message" && entry.message.role === "assistant") || - entry.kind === "turn-plan" || - (entry.kind === "work" && - entry.entry.agentSpawn === undefined && - isVisibleActiveToolEntry(entry.entry)))); - hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; - } - - for (let index = 0; index < input.timelineEntries.length; index += 1) { - const entry = input.timelineEntries[index]; - if ( - !entry || - entry.kind !== "work" || - entry.entry.agentSpawn !== undefined || - !entryBelongsToActiveTurn(entry, index) - ) { - continue; - } - if (!isVisibleActiveToolEntry(entry.entry)) { - continue; - } - - const anchorEntry = entry; - let latestToolEntry = entry; - const batchEntryIds = [entry.id]; - const visibleBatchEntries = [entry.entry]; - let cursor = index + 1; - while (cursor < input.timelineEntries.length) { - const nextEntry = input.timelineEntries[cursor]; - if ( - !nextEntry || - nextEntry.kind !== "work" || - nextEntry.entry.agentSpawn !== undefined || - !entryBelongsToActiveTurn(nextEntry, cursor) - ) { - break; - } - batchEntryIds.push(nextEntry.id); - if (isVisibleActiveToolEntry(nextEntry.entry)) { - latestToolEntry = nextEntry; - visibleBatchEntries.push(nextEntry.entry); - } - cursor += 1; - } - - // Once newer commentary, a plan, or another tool batch exists, this batch - // is history. Let the regular work-group path turn it into an expandable - // summary so none of its calls disappear behind the live one-line view. - if (hasLaterTurnContent[cursor] !== true) { - for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); - const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); - activeWorkRowsByAnchorId.set(anchorEntry.id, { - kind: "work-live", - id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, - createdAt: anchorEntry.createdAt, - entry: latestToolEntry.entry, - groupedEntries: visibleBatchEntries, - groupId, - expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - }); - } - index = cursor - 1; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - if (input.isWorking && index === activeTurnHeaderIndex) { + const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); + if (turnFold) { nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, }); } - const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); - if (anchoredTurnFolds) { - for (const turnFold of anchoredTurnFolds) { - nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, - }); - } - } - if (collapsedEntryIds.has(timelineEntry.id)) { continue; } - if (activeWorkEntryIds.has(timelineEntry.id)) { - const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); - if (activeWorkRow) { - nextRows.push(activeWorkRow); - if (activeWorkRow.expanded) { - for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, - }); - } - } - } - continue; - } - if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || - activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - const onlyToolEntries = visibleGroupedEntries.every( - (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, - ); - if (onlyToolEntries) { - const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; - const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); - nextRows.push({ - kind: "work-toggle", - id: `work-toggle:${timelineEntry.id}`, - createdAt: timelineEntry.createdAt, - groupId, - hiddenCount: visibleGroupedEntries.length, - expanded, - onlyToolEntries: true, - summary: summarizeToolGroup(visibleGroupedEntries), - summaryKind, - hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), - }); - if (expanded) { - for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, - }); - } - } - } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, }); } else { - const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const groupId = `work-group:${timelineEntry.id}`; const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, }); } @@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries, - summary: null, - summaryKind: null, - hasFailure: visibleGroupedEntries.some((entry) => - workEntryIndicatesToolFailure(entry), + onlyToolEntries: visibleGroupedEntries.every((entry) => + workLogEntryIsToolLike(entry), ), }); } @@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + if (input.isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, }); } @@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": { - const bw = b as typeof a; - return ( - a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && - a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && - Equal.equals(a.groupedEntries, bw.groupedEntries) - ); - } - - case "work-live": { - const bw = b as typeof a; - return ( - a.createdAt === bw.createdAt && - a.groupId === bw.groupId && - a.expanded === bw.expanded && - Equal.equals(a.entry, bw.entry) && - Equal.equals(a.groupedEntries, bw.groupedEntries) - ); - } + case "work": + return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); case "work-toggle": { const bw = b as typeof a; @@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries && - a.summary === bw.summary && - a.summaryKind === bw.summaryKind && - a.hasFailure === bw.hasFailure + a.onlyToolEntries === bw.onlyToolEntries ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3dcf6cf2a302..194edc0bd5bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,49 +554,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("makes the whole live tool row expandable without adding a chevron", () => { - const turnId = TurnId.make("turn-live-tools"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain('aria-label="Expand current tool calls"'); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("Running psql"); - expect(markup).not.toContain("lucide-chevron-right"); - expect(markup).not.toContain("hover:bg-accent/20"); - }); - - it("summarizes completed changed-file activity", () => { + it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Changed 1 file"); + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3ccd4808d064..e190f47569b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,6 +35,7 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, + workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -56,6 +57,7 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, + MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { - const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; - const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; - const isExpandedToolGroupHeader = - (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || - (row.kind === "work-live" && row.expanded); - return (
- {row.kind === "work" ? ( - - ) : null} - {row.kind === "work-live" ? : null} + {row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} - +
); @@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
-
+
+
+ + + + + + {row.createdAt ? ( <> Working for @@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract + + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
- {row.showThinking ? ( -
- -
- ) : null}
); } @@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, - isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; - isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} - isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); -function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) { - return ( -
- -
-
-
- -
-
-
-
- ); -} - -function ThinkingActivityRow() { - return ; -} - -function LiveActivityContent({ - label, - iconName, - highlighted = false, -}: { - label: string; - iconName: WorkEntryIconName | undefined; - highlighted?: boolean; -}) { - return ( -
- {iconName ? ( - - - - ) : null} - {label} -
- ); -} - -function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { - const ctx = use(TimelineRowCtx); - - return ( - - ); -} - -function toolGroupSummaryIconName( - kind: Extract["summaryKind"], -): WorkEntryIconName { - switch (kind) { - case "read": - return "eye"; - case "edit": - return "square-pen"; - case "command": - return "terminal"; - case "search": - return "globe"; - case "other": - return "wrench"; - case "mixed": - case null: - return "hammer"; - } -} - function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); - if (row.onlyToolEntries && row.summary) { - return ( - - ); - } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -2159,101 +2019,32 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -type CommandWrapper = "env" | "sudo"; - -const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { - env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), - sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), -}; - -const COMMAND_WRAPPER_FLAGS: Record> = { - env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]), - sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), -}; - -function commandProgramName(command: string): string | null { - const tokens = command.trim().split(/\s+/); - let index = 0; - let wrapper: CommandWrapper | null = null; - - while (index < tokens.length) { - const token = tokens[index]?.replace(/^["']|["']$/g, ""); - if (!token) return null; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { - index += 1; - continue; - } - if (token === "env" || token === "sudo") { - wrapper = token; - index += 1; - continue; - } - if (wrapper !== null && token === "--") { - wrapper = null; - index += 1; - continue; - } - if (wrapper !== null && token.startsWith("-")) { - if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { - if (tokens[index + 1] === undefined) return null; - index += 2; - continue; - } - if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { - index += 1; - continue; - } - if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { - let consumesNextToken = false; - for (const [optionIndex, option] of token.slice(1).split("").entries()) { - const shortOption = `-${option}`; - if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { - consumesNextToken = optionIndex === token.length - 2; - break; - } - if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; - } - if (consumesNextToken && tokens[index + 1] === undefined) return null; - index += consumesNextToken ? 2 : 1; - continue; - } - return null; - } - return token.split(/[\\/]/).at(-1) || null; - } - - return null; -} - -function liveWorkEntryLabel( - workEntry: TimelineWorkEntry, - workspaceRoot: string | undefined, -): string { - const command = workEntry.command?.trim(); - if (command) { - const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; } - - return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + return rawCommand === workEntry.command.trim() ? null : rawCommand; } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { - const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); const blocks: string[] = []; - if (command) { - blocks.push(command); - } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } - const detail = workEntry.detail?.trim(); - if (detail && detail !== command) { - blocks.push(detail); + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + blocks.push(raw.trim()); + } else if (workEntry.command?.trim()) { + blocks.push(workEntry.command.trim()); + } + if (workEntry.detail?.trim()) { + blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time : "working" : failed > 0 ? `${failed} failed` - : "Completed"; + : "✓ completed"; return ( -
-
-
- - - {lead} - {workflowName ? ( - - {workflowName} - - ) : null} - - {status} - {totalTokens > 0 ? ( - - Σ {formatSubagentTokenCount(totalTokens)} - - ) : null} - -
- -
-
+ ); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; - isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ( - - ); + return ; }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; - isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = props; + const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); - const entryIconName = - showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); - const isCommandEntry = - workEntry.requestKind === "command" || - workEntry.itemType === "command_execution" || - Boolean(workEntry.command); - const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); + const heading = toolWorkEntryHeading(workEntry); + const rawPreview = workEntryPreview(workEntry, workspaceRoot); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; + const displayText = preview ? `${heading} - ${preview}` : heading; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-6 shrink-0 items-center justify-center", - showWarningIndicator || showFailedIndicator + "flex size-5 shrink-0 items-center justify-center", + showWarningIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : workLogEntryIsToolLike(workEntry) - ? "text-secondary-label" - : "text-foreground/80"; - const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; + : "font-medium text-foreground"; + const turnSettled = !activity.activeTurnInProgress; + const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); + const showSuccessIndicator = + workEntryIndicatesToolSuccess(workEntry) || + (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, - "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- {showEntryIcon ? ( - - - - ) : null} + + +
-

+ {heading} + {preview && ( + {preview} )} - > - {displayText}

+
+ + {canExpand ? ( + + ) : null} + + + {showFailedIndicator ? ( + + + } + > + + + Failed + + ) : showSuccessIndicator ? ( + + } + > + + + + + Completed + + ) : showNeutralIndicator ? ( + + } + > + + + Empty + + ) : null} + +
{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index c2fa204ffbc8..6f281558ff80 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,7 +1,6 @@ import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { memo } from "react"; -import { cn } from "../../lib/utils"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -13,7 +12,6 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; - rightPanelUnavailableLabel?: string; /** Running + waiting subagents in this thread; badges the right panel toggle. */ liveAgentCount: number; onToggleTerminal: () => void; @@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, - rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = - "block self-center truncate leading-none select-none"; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; +// The skill label is smaller than the surrounding prompt text; offset its +// glyphs without moving the pill box or changing the editor's line height. +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; export const SKILL_CHIP_ICON_SVG = ``; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 7457d04d2c9e..2f4e84dc3fd2 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,7 +25,6 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, - LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -51,7 +50,6 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; -import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -77,7 +74,6 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; import { Menu, MenuItem, @@ -120,7 +116,6 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { - PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -354,6 +349,7 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", + chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -385,6 +381,12 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; + /** + * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` + * folds the whole of it into the top row once the active tab scrolls, and unfolds at the + * top — the chrome spends its height on what is being read. + */ + chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -421,13 +423,26 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); + // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll + // events, so the capture handler always writes the active tab's entry — and a tab switch + // reads the destination's memory instead of inheriting the tab being left. A tab too short + // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome + // it has no scrollbar to reopen. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeCondensed; + const condensed = chromeVariant === "collapse" && chromeCondensed; + // Collapsing removes the fold's height from the chrome, which would otherwise hand that + // height to the scrollport and leap the content up by it mid-scroll. The cure is exact + // compensation: collapse only once the reader has scrolled at least the fold's height, + // then give that height back to `scrollTop` before the next paint — the content under + // their eyes does not move, and the collapse itself is the only thing that changes. const scrollerRef = useRef(null); const foldRef = useRef(null); + // The condensed chrome's second row opens as the fold closes, so the height the scrollport + // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` + // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); const compensationRef = useRef(null); useLayoutEffect(() => { @@ -448,6 +463,7 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); + // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -486,30 +502,6 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); - const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); - const baseBranchRefQuery = useEnvironmentQuery( - detail === null - ? null - : vcsEnvironment.listRefs({ - environmentId, - input: { - cwd: detail.workspaceRoot, - query: detail.baseBranch, - includeMatchingRemoteRefs: true, - limit: 20, - }, - }), - ); - const matchingBaseBranchRefs = - detail === null - ? [] - : (baseBranchRefQuery.data?.refs.filter( - (refName) => - refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), - ) ?? []); - const isStackedPullRequest = - matchingBaseBranchRefs.length > 0 && - !matchingBaseBranchRefs.some((refName) => refName.isDefault); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. Conflicts take priority because every other completion action - // depends on resolving them first, even for a reader who cannot merge on the host themselves. + // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes + // to the thing that would help instead of a Merge button that only ever says no. const primaryAction = detail === null || detail.state !== "open" ? null - : conflicting - ? "resolve" - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null + : conflicting + ? "resolve" : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts keep their own row below: an open pull request remains green there. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; const checksState = detail ? pullRequestChecksState(detail.checks) : null; - if (detailQuery.isPending && !detail) { - return ; - } - return (
+ {/* The top row's geometry never changes: both of its states occupy the same stacked + cell and crossfade, so the actions on the right have one home whatever the chrome + is doing below. The fold and this fade share one 200ms clock. */}
-
+ {/* The fixed height lives on the two top-row cells — not the grid, whose later rows + are the fold — so the actions have one immovable home in both states. */} +
{detail && statePresentation ? ( <> - {repositoryUrl ? ( - - ) : ( - - {detail.repository} - - )} + + {detail.repository} + -

+ {detail.title} -

+ + {conflicting ? ( + + + Conflicts + + ) : checksSummary ? ( + + {detail && checksState !== null ? ( + + ) : null} + {checksSummary} + + ) : null} ) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "resolve" ? ( - - } - > - - {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} - - ) : primaryAction === "ready" ? ( + {primaryAction === "ready" ? ( @@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({ ) : null}
-
+ {/* The condensed chrome's second row: the tabs that the closing fold takes with it, + and compact copies of the branch pair and diff stat so they stay in sight while + the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} +
{detail ? ( -
-
- - - {detail.author?.login ?? "ghost"} - {formatRelativeTimeLabel(detail.updatedAt)} - - - - - {isStackedPullRequest ? ( - - ) : null} - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" - /> - ) : null} - - {detail.headBranch} - - - + + + {detail.baseBranch} + {freshness ? ( + void perform("update-branch", undefined, method)} + iconClassName="size-3" /> + ) : null} + + {detail.headBranch} + + + + + {detail.changedFiles.toLocaleString()} -
+ +
) : null}
-
+ {/* Folding is a grid track going to zero: the rows below stay mounted, the track + animates closed over them, and `inert` takes the hidden controls out of the tab + order for as long as the chrome is condensed. */} +
{detail ? ( -
+
{titleDraft === null ? (

@@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({
- - - {isStackedPullRequest ? ( - - ) : null} - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - /> - ) : null} - + {freshness ? ( + void perform("update-branch", undefined, method)} /> - - + {detail.headBranch} + + + @@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({

) : null} -
-
- {detail ? ( - - ) : null} + + {detail ? ( + + ) : null} +
+
{ + if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; + // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; + // The chrome trades the fold for the condensed second row, so the height the + // scrollport actually gains is the difference between the two. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.error && !detail ? ( + {detailQuery.isPending && !detail ? ( + // The ghost wears the shape of the tab being waited on, so switching tabs mid-load + // does not flash a summary outline under a timeline heading. + tab === "timeline" ? ( + + ) : tab === "code" ? ( + + ) : ( + + ) + ) : detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..09b79cf340e6 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,11 +45,13 @@ export function PullRequestListGhost({
- +
- +
))} @@ -57,101 +59,32 @@ export function PullRequestListGhost({ ); } -/** - * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description - * boundaries in the ghost prevents the loaded pull request from replacing one layout with - * another a moment later. - */ +/** The summary's own shape: a title, a byline, the facts rows, the description. */ export function PullRequestDetailGhost() { return (
-
-
-
- - -
-
- - -
-
- -
- -
- - -
-
- - - -
- - -
-
-
- -
-
- - - -
- -
+
+ +
- -
-
-
-
- - -
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - -
- -
-
- -
-
- +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
-
- - - - -
-
+ ))} +
+
+ + + +
); @@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 04fee465b506..3066eafc38a1 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,7 +25,6 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; -import { Button } from "../ui/button"; import { Menu, @@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({ return ( - } + className={cn( + // The icon-button size that pairs with a full-height input, so the two read as one strip. + "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8", + filtered && "text-foreground", + )} + aria-label="Filter pull requests" > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 29566e048d10..a57f2a4d1602 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -196,12 +196,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..a472c6a8d3d7 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + +
- - - + {!isElectron && ( +
+ +
+ )} + {isElectron && ( +
+ +
+ )}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 1618f5045eb8..174c9e9fe97c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { ArchiveIcon, + ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -18,7 +19,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -33,7 +34,6 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; -import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); + const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); @@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); + const handleBackClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, isMobile, navigate, setOpenMobile]); + return ( <> @@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
- + + + + + Back + + +
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 5399d071be9d..7e4b80d19511 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -768,7 +768,7 @@ export function ThemeLibrary({
{STANDARD_THEME_CARDS.map((standardTheme) => ( location.hash }); @@ -250,12 +247,12 @@ export function SettingsPageContainer({ return (
- +
{children} - +
); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1a..f4a98dec86c7 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,9 +4,8 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; -import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -118,44 +117,16 @@ function T3Wordmark() { ); } -function SidebarUtilityItem({ - icon, - label, - onClick, -}: { - icon: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - - ); -} - -export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - /^\/settings(?:\/|$)/.test(location.pathname) - ? "settings" - : location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -186,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const handleBackClick = useCallback(() => { closeMobileSidebar(); - if (canGoBack) { - window.history.back(); - return; - } void navigate({ to: "/" }); - }, [canGoBack, closeMobileSidebar, navigate]); - - return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - } - label="Settings" - onClick={handleSettingsClick} - /> - {pullRequestsSupported ? ( - } - label="Pull Requests" - onClick={handlePullRequestsClick} - /> - ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> - - )} - - - ); -}); + }, [closeMobileSidebar, navigate]); -export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> + + + + + + } + /> + Settings + + + {pullRequestsSupported ? ( + + + + + + } + /> + Pull Requests + + + ) : null} + + + + + + } + /> + Usage + + + + )} + + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 477bc9c02630..93dc653e7c0a 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] { return buildThreadActionMenuItems(state).map((item) => item.id); } -function allIds(state: ThreadActionMenuState): string[] { - const flatten = (items: ReturnType): string[] => - items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]); - return flatten(buildThreadActionMenuItems(state)); -} - describe("buildThreadActionMenuItems", () => { it("hides lifecycle items when the environment lacks the capabilities", () => { expect( @@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = allIds({ ...baseState, branch: "feat/menu" }); + const withBranch = ids({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(allIds(baseState)).not.toContain("new-thread-on-branch"); - expect(allIds(baseState)).not.toContain("copy-branch"); + expect(ids(baseState)).not.toContain("new-thread-on-branch"); + expect(ids(baseState)).not.toContain("copy-branch"); }); it("flips lifecycle labels with thread state", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 1218e2dd58cb..ef4b38dcdacd 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,7 +18,6 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" - | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -54,15 +53,14 @@ export function buildThreadActionMenuItems( { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, - icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } - : { id: "pin" as const, label: "Pin thread", icon: "pin" }, + ? { id: "unpin" as const, label: "Unpin thread" } + : { id: "pin" as const, label: "Pin thread" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -71,18 +69,17 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } - : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, + ? { id: "unsettle" as const, label: "Un-settle thread" } + : { id: "settle" as const, label: "Settle thread" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } + ? { id: "unsnooze" as const, label: "Wake thread" } : { id: "snooze" as const, label: "Snooze", - icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -91,37 +88,20 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, + { id: "rename", label: "Rename thread" }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", - icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), - { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, - { - id: "copy", - label: "Copy", - icon: "copy", - separatorBefore: true, - children: [ - { id: "copy-path", label: "Path", icon: "folder" }, - ...(state.branch - ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] - : []), - { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, - ], - }, - { - id: "delete", - label: "Delete", - destructive: true, - icon: "trash", - separatorBefore: true, - }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx deleted file mode 100644 index 29b91e18bb40..000000000000 --- a/apps/web/src/components/ui/segmented-tabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ComponentProps, HTMLAttributes } from "react"; - -import { cn } from "~/lib/utils"; -import { Toggle } from "~/components/ui/toggle"; - -function SegmentedTabList({ className, ...props }: HTMLAttributes) { - return ( -
- ); -} - -function SegmentedTab({ - selected, - density = "default", - className, - ...props -}: { - selected: boolean; - density?: "default" | "compact"; -} & Omit, "aria-pressed" | "pressed" | "size" | "type" | "variant">) { - return ( - - ); -} - -export { SegmentedTab, SegmentedTabList }; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 7173eab140ec..5bf04adf41a1 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,10 +18,6 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", - segmented: - "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", - "segmented-compact": - "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -31,8 +27,6 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", - segmented: - "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b9bebadc00e7..7a5cdd883db2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,22 +19,13 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; import { SidebarInset } from "../ui/sidebar"; -import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { - WorkspacePageContainer, - WorkspacePageHeader, - WorkspacePageHeaderEdgeControl, -} from "../WorkspacePageContainer"; -import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -75,6 +66,21 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); + // Ranked by whatever the toggle is showing, so the bars always descend. + const orderedProviders = useMemo( + () => + merged.providers.toSorted((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ), + [merged.providers, metric], + ); + + const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( + (period) => period.totalTokens > 0, + ).length; + const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -94,66 +100,78 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; - const windowLabel = - isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined - ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` - : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; - const topbarContent = ( -
- - -

Usage

-
- - - {windowLabel} - -
-
- - {(["cost", "tokens"] as const).map((option) => ( - setMetric(option)} - > - {option === "cost" ? "Cost" : "Tokens"} - - ))} - - - {WINDOW_OPTIONS.map((option) => ( - selectWindow(option.days)} - > - {option.label} - - ))} - - - - -
-
- ); return (
- - {topbarContent} - + {!isElectron && ( +
+ + Usage + +
+ )} + + {isElectron && ( +
+ + Usage + +
+ )} - +
+
+

+ {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} +

+
+
+ {WINDOW_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -163,62 +181,88 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> -
-
+ {/* Cost first: the financial answer, then the provider split. */} +
+ {/* The summary follows the chart toggle, so the headline and the + series are always reading the same units. */} +
+ + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {metric === "cost" - ? formatUsd(merged.costUsd) + ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
- {PROVIDER_ORDER.map((provider) => { - const totals = merged.providers.find((entry) => entry.provider === provider); - const share = - metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); - const providerSessions = totals?.sessions ?? 0; - const sessionLabel = `${formatCount(providerSessions)} ${ - providerSessions === 1 ? "session" : "sessions" - }`; + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( -
-
- - - - {PROVIDER_LABEL[provider]} - - {sessionLabel} - - +
+
+ + + {PROVIDER_LABEL[provider.provider]} - + {metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)}
+
+
+
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
); })}
-
-

- {isPast24Hours ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

+
+
+

+ {isPast24Hours ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"} +

+
+
+ {(["cost", "tokens"] as const).map((option) => ( + + ))} +
+ +
+
-
-

Totals

-
- - - - - -
+
+ + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + />

Breakdown

- +
{( [ - { value: "model", label: "Model" }, - { value: "time", label: isPast24Hours ? "Hour" : "Day" }, + { value: "model", label: "model" }, + { value: "time", label: isPast24Hours ? "hour" : "day" }, ] as const ).map((option) => ( - setBreakdown(option.value)} + className={cn( + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", + option.value === breakdown + ? "bg-muted text-foreground" + : "text-muted-foreground hover:text-foreground", + )} > {option.label} - + ))} - +
{breakdown === "model" ? ( @@ -291,7 +356,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -338,7 +403,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -368,7 +433,7 @@ export function UsagePage() {
)} - +
@@ -387,11 +452,20 @@ function ProviderMark({ return ; } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { return ( -
+
{label} - {value} + {value} + {detail}
); } @@ -495,51 +569,70 @@ function UsageDeviceStrip({ ); } +/** Deterministic bar heights (each unique: they double as keys). */ +const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; + /** - * Static stand-in with the loaded page's shape. No shimmer; blocks fill in - * exactly once when the last device answers. + * Static stand-in with the loaded page's shape: headline, provider split, + * chart and metrics strip. No shimmer; blocks fill in exactly once when the + * last device answers. */ -function UsageSkeleton() { +function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { return ( <> -
+
-
-
+ + Raw token cost + +
+
+ {PROVIDER_ORDER.map((provider) => ( -
-
- +
+
+ -
+ {PROVIDER_LABEL[provider]}
+
))}
-
-
+

+ {resolution === "hour" ? "Hourly" : "Daily"} cost +

+ {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a + relayout when the real chart swaps in. */} +
+ {SKELETON_BAR_HEIGHTS.map((height) => ( +
+ ))} +
-
-

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
- ), - )} -
+
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+
+ ), + )}
); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 963c28fe6a01..f41945bfe286 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,7 +68,13 @@ function buildPeriodColumns( }); } -/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ +/** + * Monotone cubic tangents (Fritsch-Carlson). + * + * Plain cubic smoothing overshoots on spiky daily data and would dip the area + * below zero between points, which reads as negative spend. This variant is + * shape-preserving, so a smoothed series never leaves the range of its samples. + */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } +/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -116,6 +123,7 @@ interface CurveSegment { readonly to: Point; } +/** Smoothed polyline through `points`, as explicit cubic control points. */ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); @@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[]): string { +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { const first = segments[0]; if (first === undefined) return ""; - let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; } @@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re /** * Turns the merged daily totals into one column per day. * - * Values are absolute, not cumulative: each provider is drawn from the same - * zero baseline so the chart never implies that one provider is always larger. + * Values are absolute, not cumulative: the series are layered from a shared + * zero baseline rather than stacked. A stacked chart puts whichever provider is + * drawn last permanently above the other, which reads as "that one is bigger" + * even on days where it is not. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -206,39 +216,42 @@ export function UsageProviderChart({ ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); - const tooltipRef = useRef(null); - const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const { paths, series, stepX, ticks, toY } = useMemo(() => { + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { paths: [], - series: [] as readonly DayColumn[], - stepX: 0, ticks: [0] as readonly number[], + stepX: 0, toY: () => VIEW_HEIGHT, + series: [] as readonly DayColumn[], }; } const columns = buildPeriodColumns(periods, byPeriod, metric); + + // The scale tops out at the largest single provider-day, not the largest + // sum: layered series each measure from zero, so a combined peak would + // leave the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); + // Reserve a sliver above the top gridline so the series stroke, which is + // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), + const curve = smoothCurve( + columns.map((column, dayIndex) => ({ + x: dayIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })), ); + const line = curvePath(curve, "M"); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -247,65 +260,30 @@ export function UsageProviderChart({ }; }); - return { - paths: built.toSorted((a, b) => b.total - a.total), - series: columns, - stepX: step, - ticks: tickValues, - toY, - }; + // Paint the heavier series first so the lighter one is never buried under + // it. The fills are faint enough that the order barely shows, but the + // strokes are drawn in a second pass regardless, so neither can be hidden. + const ordered = [...built].sort((a, b) => b.total - a.total); + + return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; - const positionTooltip = useCallback(() => { - const plot = plotRef.current; - const tooltip = tooltipRef.current; - const hoverPosition = hoverPositionRef.current; - if (plot === null || tooltip === null || hoverPosition === null) return; - - const gap = 12; - const tooltipWidth = tooltip.offsetWidth; - const tooltipHeight = tooltip.offsetHeight; - const plotWidth = plot.clientWidth; - const plotHeight = plot.clientHeight; - const preferredLeft = - hoverPosition.x + gap + tooltipWidth <= plotWidth - ? hoverPosition.x + gap - : hoverPosition.x - gap - tooltipWidth; - const preferredTop = - hoverPosition.y + gap + tooltipHeight <= plotHeight - ? hoverPosition.y + gap - : hoverPosition.y - gap - tooltipHeight; - const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); - const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); - plot.style.setProperty("--usage-tooltip-left", `${left}px`); - plot.style.setProperty("--usage-tooltip-top", `${top}px`); - }, []); - - useLayoutEffect(() => { - if (hoverIndex !== null) positionTooltip(); - }, [hoverIndex, positionTooltip]); - const handleMove = useCallback( (event: React.MouseEvent) => { - const plot = plotRef.current; - if (plot === null || periods.length === 0) return; - const bounds = plot.getBoundingClientRect(); - if (bounds.width === 0) return; - const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); - const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; + const bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; + const fraction = (event.clientX - bounds.left) / bounds.width; const index = Math.round(fraction * (periods.length - 1)); - hoverPositionRef.current = { x: localX, y: localY }; - positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [periods.length, positionTooltip], + [periods.length], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; + const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -333,10 +311,7 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => { - hoverPositionRef.current = null; - setHoverIndex(null); - }} + onMouseLeave={() => setHoverIndex(null)} > ( ))} @@ -392,11 +368,10 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -443,3 +418,21 @@ export function UsageProviderChart({
); } + +export function UsageChartLegend() { + return ( +
+ {PROVIDER_ORDER.map((provider) => { + // The marks carry the same fills as the bands, so they key the chart + // just as a colour swatch would. + const Mark = PROVIDER_MARK[provider]; + return ( + + + {PROVIDER_LABEL[provider]} + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 3ec171859027..f8b65877dcf4 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Stable provider reading order across summaries, tables, and hover rows. + * Series and table order. The chart layers both providers from a shared zero + * baseline, so this only fixes the reading order of legends, tables and hover + * rows; it does not decide which series sits above the other. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 4bc3237d2a66..769826e3999c 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,15 +4,6 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { - "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], - "circle-check": [ - { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, - { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, - ], - clock: [ - { tag: "path", attrs: { d: "M12 6v6l4 2" } }, - { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, - ], pencil: [ { tag: "path", @@ -26,71 +17,6 @@ const ICON_PATHS: Record( "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { - if (item.separatorBefore === true && inner.childElementCount > 0) { - const separator = document.createElement("div"); - separator.className = "my-1 h-px bg-border/70"; - separator.style.cssText = - "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; - separator.dataset.contextMenuSeparator = "true"; - separator.setAttribute("role", "separator"); - inner.appendChild(separator); - } - if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; @@ -331,12 +247,10 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - const chevron = createIconElement("chevron-right", "neutral"); - if (chevron) { - chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); - chevron.dataset.contextMenuChevron = "true"; - button.appendChild(chevron); - } + const chevron = document.createElement("span"); + chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; + chevron.textContent = ">"; + button.appendChild(chevron); } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bd49f53702cd..4e636eb4ff0f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } - @keyframes live-activity-focus { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(100%); - } - } - @keyframes live-activity-focus-counter { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-100%); - } - } @keyframes status-ping { /* Burst first (immediate feedback for click ripples), then hold invisible for the rest of the cycle. Mirrors animate-ping's @@ -447,62 +431,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -@utility live-activity-focus { - --live-activity-focus-width: 4.5rem; - - right: auto; - left: calc(-1 * var(--live-activity-focus-width)); - width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); - -webkit-mask-image: linear-gradient( - to right, - transparent 0, - rgb(0 0 0 / 12%) 0.675rem, - rgb(0 0 0 / 55%) 1.575rem, - black 2.25rem, - rgb(0 0 0 / 55%) 2.925rem, - rgb(0 0 0 / 12%) 3.825rem, - transparent var(--live-activity-focus-width), - transparent 100% - ); - -webkit-mask-repeat: no-repeat; - mask-image: linear-gradient( - to right, - transparent 0, - rgb(0 0 0 / 12%) 0.675rem, - rgb(0 0 0 / 55%) 1.575rem, - black 2.25rem, - rgb(0 0 0 / 55%) 2.925rem, - rgb(0 0 0 / 12%) 3.825rem, - transparent var(--live-activity-focus-width), - transparent 100% - ); - mask-repeat: no-repeat; - animation: live-activity-focus 2.2s linear infinite; - will-change: transform; - - @media (prefers-reduced-motion: reduce) { - animation: none; - opacity: 0; - will-change: auto; - } -} - -@utility live-activity-focus-counter { - width: 100%; - animation: live-activity-focus-counter 2.2s linear infinite; - will-change: transform; - - @media (prefers-reduced-motion: reduce) { - animation: none; - will-change: auto; - } -} - -@utility live-activity-focus-aligned { - width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); - margin-left: var(--live-activity-focus-width); -} - @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -1421,16 +1349,15 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { /* The panel layout toggles stay ghost: they render both inside the header and in the titlebar strip, so filling them would make them change appearance as - the panel opens. Their icons use the same themed foreground as the toolbar - action text; hover and pressed keep the base ghost accent. The tooltip - trigger's data-slot wins over the toggle's when it renders the toggle, so - match both. */ + the panel opens. They only take the themed foreground; hover and pressed + keep the base ghost accent. The tooltip trigger's data-slot wins over the + toggle's when the trigger renders the toggle, so match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-control-foreground); - color: var(--toolbar-control-foreground); + --control-icon-color: var(--toolbar-foreground); + color: var(--toolbar-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 5bfb80bfec32..0b7e6bf0f970 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,17 +118,6 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } -/** The repository root behind a recognised change-request URL, without PR-specific state. */ -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - url.pathname = `/${changeRequest.repository}`; - url.search = ""; - url.hash = ""; - return url.toString(); -} - function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 0e1fdc9f884c..803ba787116b 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain(''); + expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 271715be3ca1..4f4da0c751ef 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; -import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -18,6 +17,8 @@ import { import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -142,13 +143,18 @@ function HostedStaticOnboardingState() { return (
- +
{APP_DISPLAY_NAME}
- +
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 7d73a225d5a0..66d9f0caa5da 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,12 +74,6 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; -import { - WorkspacePageContainer, - WorkspacePageHeader, - WorkspacePageHeaderEdgeControl, -} from "../components/WorkspacePageContainer"; -import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -105,6 +99,7 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1186,17 +1181,6 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); - const linkedSelectionMatchesSurface = - linkedSelection !== null && - selectedPullRequestSurface !== null && - linkedSelection.environmentId === selectedPullRequestSurface.environmentId && - linkedSelection.projectId === selectedPullRequestSurface.projectId && - linkedSelection.repository === selectedPullRequestSurface.repository && - linkedSelection.number === selectedPullRequestSurface.number; - // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are - // history, though, not a current selection: without this check they leave the toggle looking - // available after the selected pull request has been cleared. - const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1310,10 +1294,9 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelAvailable} + rightPanelAvailable={rightPanelState.surfaces.length > 0} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} - rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1620,6 +1603,7 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} + chromeVariant="collapse" /> ) : null} @@ -1843,10 +1827,18 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
- {/* A closed right panel leaves this column full-width, so the shared header - reserves native window controls. While the panel is open, the column ends - at the panel and the absolute controls strip owns the top-right corner. */} - +
{condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1888,24 +1880,27 @@ function PullRequestsColumn({ )}
{condensed ? ( -
- { - topbarSearchFocusedRef.current = focused; - }} - /> - -
- ) : null} - {rightPanelControl ? ( - {rightPanelControl} + { + topbarSearchFocusedRef.current = focused; + }} + /> ) : null} - + + {rightPanelControl} +
+
{searchInput} {filtersMenu} - {!condensed ? ( - - ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} - +
); } - -function PullRequestRefreshControl({ - compact = false, - refreshing, - onRefresh, -}: { - compact?: boolean; - refreshing: boolean; - onRefresh: () => void; -}) { - return ( - - ); -} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 431e196de8b1..a4b248c84ed9 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,8 +13,9 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; -import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -71,16 +72,41 @@ function SettingsContentLayout() { return (
- -
- - {showRestoreDefaults ? ( -
- -
- ) : null} + {!isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
+
+ )} + + {isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
- + )}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2eadd1fc5fb5..f5effff6602c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("shows a command from its start event while it is still running", () => { + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "tool-start", - createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command run started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "inProgress", - title: "Command run", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry).toMatchObject({ - id: "tool-start", - command: "vp test run", - toolCallId: "call-1", - toolLifecycleStatus: "inProgress", - sourceActivityKind: "tool.started", - }); - }); - - it("retains the start command when the matching completion omits it", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "tool-start", - createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command run started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "inProgress", - title: "Command run", - data: { input: { command: "vp test run" } }, - }, - }), - makeActivity({ - id: "other-tool-start", - createdAt: "2026-02-23T00:00:02.500Z", - summary: "Other command started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-2", - status: "inProgress", - title: "Other command", - data: { input: { command: "vp lint" } }, - }, - }), makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:03.000Z", - summary: "Command run", - kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "completed", - title: "Command run", - }, - }), - makeActivity({ - id: "other-tool-complete", - createdAt: "2026-02-23T00:00:04.000Z", - summary: "Other command", + summary: "Tool call complete", kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-2", - status: "completed", - title: "Other command", - }, }), - ]; - - const entries = deriveWorkLogEntries(activities); - expect(entries).toHaveLength(2); - expect(entries[0]).toMatchObject({ - id: "tool-complete", - command: "vp test run", - toolCallId: "call-1", - toolLifecycleStatus: "completed", - sourceActivityKind: "tool.completed", - }); - expect(entries[1]).toMatchObject({ - id: "other-tool-complete", - command: "vp lint", - toolCallId: "call-2", - toolLifecycleStatus: "completed", - sourceActivityKind: "tool.completed", - }); - }); - - it("does not merge non-adjacent tool starts without stable call ids", () => { - const activities: OrchestrationThreadActivity[] = [ makeActivity({ - id: "unkeyed-start-1", - createdAt: "2026-02-23T00:00:01.000Z", - summary: "Search started", - kind: "tool.started", - payload: { itemType: "search", title: "Search", status: "inProgress" }, - }), - makeActivity({ - id: "keyed-start", + id: "tool-start", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-between", - title: "Command", - status: "inProgress", - }, - }), - makeActivity({ - id: "unkeyed-start-2", - createdAt: "2026-02-23T00:00:03.000Z", - summary: "Search started", + summary: "Tool call", kind: "tool.started", - payload: { itemType: "search", title: "Search", status: "inProgress" }, }), ]; - expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ - "unkeyed-start-1", - "keyed-start", - "unkeyed-start-2", - ]); + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", - toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index efe1876dfc1c..4d0a76cf133b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,8 +65,6 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; - /** Stable provider identity across in-progress and completed lifecycle updates. */ - toolCallId?: string; label: string; detail?: string; command?: string; @@ -750,6 +748,7 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -758,13 +757,8 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; - // Plan updates have a dedicated task row. Keeping the raw activity here - // duplicates it as a legacy "Work Log / Plan updated" row when history - // is expanded. - if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; - if (isCodexTerminalInteractionActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -775,11 +769,7 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if ( - activity.kind !== "tool.started" && - activity.kind !== "tool.updated" && - activity.kind !== "tool.completed" - ) { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } @@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } -/** - * Codex terminal interactions report bytes written to an already-running PTY. - * Some thread histories contain them as generic tool.updated rows, so filter - * their exact wire shape from the presentation model. This repairs existing - * history without deleting or rewriting persisted activities. - */ -function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated") { - return false; - } - const payload = asRecord(activity.payload); - const data = asRecord(payload?.data); - return ( - payload?.itemType === "command_execution" && - typeof data?.itemId === "string" && - typeof data.processId === "string" && - typeof data.stdin === "string" && - typeof data.threadId === "string" && - typeof data.turnId === "string" - ); -} - function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.started") { - toolLifecycleStatus = "inProgress"; - } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } -function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { - if ( - entry.activityKind !== "tool.started" && - entry.activityKind !== "tool.updated" && - entry.activityKind !== "tool.completed" - ) { - return undefined; - } - return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined; -} - function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); - const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries( }); continue; } - const lifecycleKey = toolLifecycleCollapseMapKey(entry); - if (lifecycleKey !== undefined) { - const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); - if (matchingLifecycleIndex !== undefined) { - const matchingEntry = collapsed[matchingLifecycleIndex]; - if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { - toolLifecycleRowIndex.delete(lifecycleKey); - const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); - collapsed[matchingLifecycleIndex] = merged; - if (merged.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); - } - continue; - } - toolLifecycleRowIndex.delete(lifecycleKey); - } - } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - const previousIndex = collapsed.length - 1; - const previousKey = toolLifecycleCollapseMapKey(previous); - if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); - const merged = mergeDerivedWorkLogEntries(previous, entry); - collapsed[previousIndex] = merged; - const mergedKey = toolLifecycleCollapseMapKey(merged); - if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(mergedKey, previousIndex); - } + collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); continue; } collapsed.push(entry); - if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); - } } return collapsed; } @@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if ( - previous.activityKind !== "tool.started" && - previous.activityKind !== "tool.updated" && - previous.activityKind !== "tool.completed" - ) { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { return false; } - if ( - next.activityKind !== "tool.started" && - next.activityKind !== "tool.updated" && - next.activityKind !== "tool.completed" - ) { + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } if (previous.activityKind === "tool.completed") { @@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if ( - entry.activityKind !== "tool.started" && - entry.activityKind !== "tool.updated" && - entry.activityKind !== "tool.completed" - ) { + if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } if (entry.toolCallId) { @@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); - const dataInput = asRecord(data?.input); - const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, - dataInput?.command, - stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); + return asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index f7a6412d51db..b0b1df96e1fe 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => { it("reconciles terminal ids from an external ordered list", () => { const store = useTerminalUiStateStore.getState(); store.setTerminalOpen(THREAD_REF, true); - store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); - store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); const terminalUiState = selectThreadTerminalUiState( @@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => { { id: "group-term-a", terminalIds: ["term-a"] }, { id: "group-term-b", terminalIds: ["term-b"] }, ]); - expect( - useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ - scopedThreadKey(THREAD_REF) - ], - ).toEqual({ "term-a": "API server" }); }); it("does not import a closed panel terminal from stale metadata", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 545e195a1287..290ca8e5954c 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; terminalStateByThreadKey?: Record; - terminalCustomLabelsByThreadKey?: Record>; } -const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly> = Object.freeze({}); - export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, @@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState( parseScopedThreadKey(threadKey), ), ); - const terminalCustomLabelsByThreadKey = Object.fromEntries( - Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( - ([threadKey, labels]) => { - if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; - const normalizedLabels = Object.fromEntries( - Object.entries(labels).flatMap(([terminalId, label]) => { - const normalizedTerminalId = terminalId.trim(); - const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; - return normalizedTerminalId && normalizedLabel - ? [[normalizedTerminalId, normalizedLabel] as const] - : []; - }), - ); - return Object.keys(normalizedLabels).length > 0 - ? [[threadKey, normalizedLabels] as const] - : []; - }, - ), - ); - return { - terminalUiStateByThreadKey, - ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 - ? { terminalCustomLabelsByThreadKey } - : {}), - }; + return { terminalUiStateByThreadKey }; } function createTerminalUiStateStorage() { @@ -516,18 +489,6 @@ export function selectThreadTerminalUiState( ); } -export function selectThreadTerminalCustomLabels( - terminalCustomLabelsByThreadKey: Record>, - threadRef: ScopedThreadRef | null | undefined, -): Readonly> { - if (!threadRef || threadRef.threadId.length === 0) { - return EMPTY_TERMINAL_CUSTOM_LABELS; - } - return ( - terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS - ); -} - function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record, threadRef: ScopedThreadRef, @@ -601,7 +562,6 @@ function removeRecordEntry(record: Record, key: string): Record; - terminalCustomLabelsByThreadKey: Record>; /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -615,11 +575,6 @@ interface TerminalUiStateStoreState { options?: { open?: boolean; active?: boolean }, ) => void; setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; - setTerminalCustomLabel: ( - threadRef: ScopedThreadRef, - terminalId: string, - label: string | null, - ) => void; closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; clearTerminalUiState: (threadRef: ScopedThreadRef) => void; @@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { - terminalId: string; - suppressed: boolean; - clearCustomLabel?: boolean; - }, - pruneCustomLabels = false, + suppression?: { terminalId: string; suppressed: boolean }, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create()( suppression.suppressed, ) : state.suppressedTerminalIdsByThreadKey; - const terminalIdToClear = suppression?.clearCustomLabel - ? suppression.terminalId.trim() - : ""; - const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; - let nextTerminalCustomLabelsByThreadKey = - terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined - ? Object.keys(currentLabels).length === 1 - ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) - : { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), - } - : state.terminalCustomLabelsByThreadKey; - if (pruneCustomLabels) { - const survivingIds = new Set( - selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, - ); - const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; - const survivingLabels = Object.fromEntries( - Object.entries(labelsForThread).filter(([terminalId]) => - survivingIds.has(terminalId), - ), - ); - if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { - nextTerminalCustomLabelsByThreadKey = - Object.keys(survivingLabels).length > 0 - ? { - ...nextTerminalCustomLabelsByThreadKey, - [threadKey]: survivingLabels, - } - : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); - } - } if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && - nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, - terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create()( ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), - setTerminalCustomLabel: (threadRef, terminalId, label) => - set((state) => { - const normalizedTerminalId = terminalId.trim(); - if (normalizedTerminalId.length === 0) return state; - const threadKey = terminalThreadKey(threadRef); - const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; - const normalizedLabel = label?.trim().slice(0, 80) ?? ""; - if (normalizedLabel.length > 0) { - if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; - return { - terminalCustomLabelsByThreadKey: { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, - }, - }; - } - if (currentLabels[normalizedTerminalId] === undefined) return state; - const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; - return { - terminalCustomLabelsByThreadKey: - Object.keys(remainingLabels).length > 0 - ? { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: remainingLabels, - } - : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), - }; - }), closeTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { terminalId, suppressed: true, - clearCustomLabel: true, }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal( - threadRef, - (state, suppressedTerminalIds) => { - if (suppressedTerminalIds.length === 0) { - return reconcileThreadTerminalSessionIds(state, nextIds); - } - const suppressedIds = new Set(suppressedTerminalIds); - return reconcileThreadTerminalSessionIds( - state, - nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), - ); - }, - undefined, - true, - ), + updateTerminal(threadRef, (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds && - !hadCustomLabels + !hadSuppressedTerminalIds ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { + if (!hadTerminalUiState && !hadSuppressedTerminalIds) { return state; } return { @@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create()( const orphanedIds = new Set( [ ...Object.keys(state.terminalUiStateByThreadKey), - ...Object.keys(state.terminalCustomLabelsByThreadKey), ...Object.keys(state.suppressedTerminalIdsByThreadKey), ].filter((key) => !activeThreadKeys.has(key)), ); @@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create()( const nextSuppressedTerminalIdsByThreadKey = { ...state.suppressedTerminalIdsByThreadKey, }; - const nextTerminalCustomLabelsByThreadKey = { - ...state.terminalCustomLabelsByThreadKey, - }; for (const id of orphanedIds) { delete nextTerminalUiStateByThreadKey[id]; - delete nextTerminalCustomLabelsByThreadKey[id]; delete nextSuppressedTerminalIdsByThreadKey[id]; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), @@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 5, + version: 4, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, }), }, ), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 03451cc7b2ec..09d7d7a4602a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,8 +111,6 @@ export interface ContextMenuItem { header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; - /** Inserts a visual section divider immediately before this item. */ - separatorBefore?: boolean; children?: readonly ContextMenuItem[]; } @@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; - readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), - separatorBefore: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 81270ad320cb..c2fa9e2a86a1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,7 +248,6 @@ describe("mergeUsage", () => { ); expect(merged.sessions).toBe(1); - expect(merged.providers[0]?.sessions).toBe(1); }); it("returns empty totals with no environments", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index f5e54434fd97..886b214183bc 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,7 +25,6 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; - readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { - readonly buckets: readonly UsageBucket[]; - readonly sessionsByProvider: ReadonlyMap; -} { +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { const ownedProviders = new Set(); - const sessionsByProvider = new Map(); + let sessions = 0; for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - const provider = source.fingerprint.provider; - ownedProviders.add(provider); + ownedProviders.add(source.fingerprint.provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessionsByProvider.set( - provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, - ); + sessions += source.distinctSessions; } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessionsByProvider, + sessions, }; } @@ -236,7 +228,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + { costUsd: number; totalTokens: number; records: number } >(); const modelAccumulator = new Map< string, @@ -263,20 +255,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - - for (const [providerKind, providerSessions] of sessionsByProvider) { - sessions += providerSessions; - const provider = providerAccumulator.get(providerKind) ?? { - costUsd: 0, - totalTokens: 0, - records: 0, - sessions: 0, - }; - provider.sessions += providerSessions; - providerAccumulator.set(providerKind, provider); - } + sessions += environmentSessions; for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -296,7 +280,6 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, - sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -358,7 +341,6 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, - sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) From 48ddb3d469d245363dba723f774ba449e4d950c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 22:48:08 -0400 Subject: [PATCH 042/144] feat(web): older chat timestamps show the date, not just the time (#6654) Co-authored-by: Claude Fable 5 --- .../src/components/chat/MessagesTimeline.tsx | 6 +-- apps/web/src/timestampFormat.test.ts | 50 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 38 ++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b2..f9ad57ff3b83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -105,7 +105,7 @@ import { import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; +import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, @@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract }> - {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} @@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract} > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c2fe4b62714f..6678549ccd90 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + formatDayAwareTimestamp, formatElapsedDurationLabel, formatExpiresInLabel, formatRelativeTime, @@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => { }); }); +describe("formatDayAwareTimestamp", () => { + // Instants are built with the local-time Date constructor so the + // calendar-day boundaries hold in any test timezone or locale. + const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) => + new Date(y, monthIndex, d, h, mi).toISOString(); + const now = new Date(2026, 7, 14, 12, 0).getTime(); + const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour"); + + it("shows time only for today", () => { + const messageAt = iso(2026, 7, 14, 9, 30); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt)); + }); + + it("labels the previous calendar day as yesterday even when under 24h old", () => { + const messageAt = iso(2026, 7, 13, 23, 30); + const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime(); + expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe( + `yesterday at ${time(messageAt)}`, + ); + }); + + it("prefixes older same-year messages with the numeric date", () => { + const messageAt = iso(2026, 7, 12, 12, 34); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("includes the year once the calendar year differs", () => { + const messageAt = iso(2025, 11, 31, 18, 0); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("returns an empty string for invalid input", () => { + expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); + }); +}); + describe("invalid timestamp inputs", () => { it("returns an empty timestamp instead of throwing", () => { expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index cce5b141c634..c8f9956ebb3f 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } +const numericDateFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", +}); +const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", +}); + +/** + * Chat timestamp that adds the date once the message is no longer from today: + * today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM` + * (locale digit order), with the year included once the calendar year differs. + * Boundaries are local calendar days, not 24-hour windows. + */ +export function formatDayAwareTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + nowMs: number = Date.now(), +): string { + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const time = getTimestampFormatter(timestampFormat, false).format(date); + + const now = new Date(nowMs); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + // Round so DST-shifted 23/25 hour days still count as whole days. + const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); + + if (dayDiff <= 0) return time; + if (dayDiff === 1) return `yesterday at ${time}`; + const dateFormatter = + date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; + return `${dateFormatter.format(date)} ${time}`; +} + /** * Format a relative time string from an ISO date. * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` From 8c628f14993cb159d467e7a0f8c52578dde77005 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:48:54 +0300 Subject: [PATCH 043/144] fix(web): align pull request action menu rows (#6534) Co-authored-by: Nickolas Kyryliuk Co-authored-by: Claude Opus 5 (1M context) --- .../pullRequest/PullRequestDetailPanel.tsx | 42 +++++++++++++++---- .../pullRequestDetail.logic.test.ts | 7 ++++ .../pullRequest/pullRequestDetail.logic.ts | 9 ++++ apps/web/src/components/ui/menu.test.tsx | 23 ++++++++++ apps/web/src/components/ui/menu.tsx | 2 +- 5 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/ui/menu.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3fd2..015371a86d95 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -103,6 +103,7 @@ import { handoffPrompt, handoffReviewComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -1033,6 +1034,26 @@ export function PullRequestDetailPanel({ : allowedMergeMethods.length > 0 ? "merge" : null; + // What the menu's action group holds. Named once so the separators around it are drawn from + // the same answer as its contents, rather than on the assumption that it has any. + const showsDraftToggle = + detail?.state === "open" && + can(detail.isDraft ? "ready" : "draft") && + !(detail.isDraft && primaryAction === "ready"); + const showsAutoMerge = + detail?.state === "open" && + ((autoMergeArmed && can("disable-auto-merge")) || + (!autoMergeArmed && + !detail.isDraft && + !conflicting && + can("enable-auto-merge") && + allowedMergeMethods.length > 0)); + const showsMergeMethods = + detail?.state === "open" && + can("merge") && + !detail.isDraft && + !conflicting && + allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors // it. Conflicts keep their own row below: an open pull request remains green there. const statePresentation = detail @@ -1191,8 +1212,7 @@ export function PullRequestDetailPanel({ {/* Only where the button row could not take it: "Ready for review" on a draft is the primary header button, so offering it here as well would show the same action twice. */} - {can(detail.isDraft ? "ready" : "draft") && - !(detail.isDraft && primaryAction === "ready") ? ( + {showsDraftToggle ? ( void perform(detail.isDraft ? "ready" : "draft")} @@ -1236,12 +1256,12 @@ export function PullRequestDetailPanel({ Hidden while conflicting: every method would fail. */} {/* Only where merging is on offer at all: a strategy to merge with is not a choice for someone who may not merge. */} - {can("merge") && - !detail.isDraft && - !conflicting && - allowedMergeMethods.length > 1 ? ( + {showsMergeMethods ? ( <> - + {/* Only below the draft control. A host with no draft of its own, or + a draft whose control is already the header button, would leave + this against the separator that opened the group. */} + {showsDraftToggle ? : null} @@ -1261,7 +1281,13 @@ export function PullRequestDetailPanel({ ) : null} - + {pullRequestActionMenuHasGroup( + showsDraftToggle, + showsAutoMerge, + showsMergeMethods, + ) ? ( + + ) : null} ) : null} void readLocalApi()?.shell.openExternal(detail.url)}> diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 9b247002fce7..faab9d847bd5 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -19,6 +19,7 @@ import { isThreadOwnPullRequest, orderPullRequestComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -53,6 +54,12 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request action menu", () => { + it("keeps the group divider when auto-merge is the only action", () => { + expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true); + }); +}); + describe("pull request state description", () => { it("keeps draft and conflicts orthogonal to the terminal states", () => { expect(describePullRequestState("open", true)).toBe("Draft"); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ddb4e813bf4e..26054f6ef690 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -57,6 +57,15 @@ export function pullRequestHandoffLabels(inThisThread: boolean) { }; } +/** Whether the open pull-request action group contains at least one action. */ +export function pullRequestActionMenuHasGroup( + showsDraftToggle: boolean, + showsAutoMerge: boolean, + showsMergeMethods: boolean, +): boolean { + return showsDraftToggle || showsAutoMerge || showsMergeMethods; +} + /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { if (state === "merged") return "Merged"; diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx new file mode 100644 index 000000000000..079d2a1794b8 --- /dev/null +++ b/apps/web/src/components/ui/menu.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; + +describe("menu radio item geometry", () => { + it("keeps radio-item icons on the same text grid as menu items", () => { + const html = renderToStaticMarkup( + + + + + + Merge + + + + , + ); + + expect(html).toContain("-mx-0.5"); + }); +}); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 803d6c1987c1..b66782ebe2d1 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -166,7 +166,7 @@ function MenuRadioItem({ return ( Date: Sat, 15 Aug 2026 09:33:08 +0200 Subject: [PATCH 044/144] fix(web): restore selected themes in dark mode (#6665) --- apps/web/src/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4e636eb4ff0f..b2c914c0b69f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1169,7 +1169,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id] { +html[data-theme-id], +html.dark[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); From f0ebc628c6dd83fd0c7963078ad7778ce6028d0c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:42:35 +0200 Subject: [PATCH 045/144] fix(web): improve Codex usage graph contrast (#6669) --- apps/web/src/components/usage/UsagePage.tsx | 12 ++--- .../components/usage/UsageProviderChart.tsx | 23 +++++---- .../src/components/usage/usageProviders.ts | 47 +++++++++---------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883db2..92e2c5b6fc34 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -209,7 +209,7 @@ export function UsagePage() {
- {PROVIDER_LABEL[provider.provider]} + {PROVIDER_PRESENTATION[provider.provider].label} {metric === "cost" @@ -222,7 +222,7 @@ export function UsagePage() { className="h-full" style={{ width: `${(share * 100).toFixed(1)}%`, - backgroundColor: PROVIDER_COLOR[provider.provider], + backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, }} />
@@ -385,7 +385,7 @@ export function UsagePage() { {isPast24Hours ? "Hour" : "Day"} {PROVIDER_ORDER.map((provider) => ( - {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label} ))} Total @@ -448,7 +448,7 @@ function ProviderMark({ readonly provider: UsageProviderKind; readonly className: string; }) { - const Mark = PROVIDER_MARK[provider]; + const Mark = PROVIDER_PRESENTATION[provider].mark; return ; } @@ -595,7 +595,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" })
- {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label}
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe286..d7582a0e4bdc 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -9,7 +9,7 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; @@ -339,14 +339,19 @@ export function UsageProviderChart({ {/* Fills first, then every stroke, so no series covers another's line. */} {paths.map(({ provider, area }) => ( - + ))} {paths.map(({ provider, line }) => ( @@ -376,12 +381,12 @@ export function UsageProviderChart({ >
{formatTooltipPeriod(hoveredPeriod)}
{PROVIDER_ORDER.map((provider) => { - const Mark = PROVIDER_MARK[provider]; + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return (
- {PROVIDER_LABEL[provider]} + {label} {format( @@ -423,13 +428,13 @@ export function UsageChartLegend() { return (
{PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; + // Brand marks keep monochrome providers identifiable even when their + // chart series use distinct colors. + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return ( - {PROVIDER_LABEL[provider]} + {label} ); })} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf4..00db67e28a84 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -2,32 +2,29 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; -/** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. - */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; - -export const PROVIDER_LABEL: Record = { - claude: "Claude Code", - codex: "Codex", -}; - -/** Claude's brand orange against a neutral white for Codex. */ -export const PROVIDER_COLOR: Record = { - claude: "#d97757", - codex: "#e6e6e6", +type UsageProviderPresentation = { + readonly label: string; + readonly color: string; + readonly mark: Icon; }; /** - * Brand marks, reused from the provider picker. - * - * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), - * which are the same colours as the chart bands, so swapping a colour dot for a - * mark keeps the series association intact rather than trading it away. + * Exhaustive presentation for providers supported by the usage contract. + * Declaration order is reused by every chart, table, legend, and skeleton, so + * adding a provider only requires its contract support and one entry here. */ -export const PROVIDER_MARK: Record = { - claude: ClaudeAI, - codex: OpenAI, -}; +export const PROVIDER_PRESENTATION = { + codex: { + label: "Codex", + color: "var(--foreground)", + mark: OpenAI, + }, + claude: { + label: "Claude Code", + color: "#d97757", + mark: ClaudeAI, + }, +} satisfies Record; + +/** The chart layers every series from zero, so order only controls how it is read. */ +export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; From e9ae134c59bedd39428e1d279df0ada3e86cd500 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 11:30:08 +0200 Subject: [PATCH 046/144] docs: route feature requests to Discussions - Disable feature-request issue templates - Direct contributors to Ideas discussions for proposals --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 102 --------------------- CONTRIBUTING.md | 8 +- README.md | 4 +- 5 files changed, 14 insertions(+), 106 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9bc321dac0de..38a764eab6d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,7 @@ body: attributes: value: | Use this form for broken behavior, regressions, crashes, or reliability problems. + Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Search existing issues first and keep the report focused on one problem. - type: checkboxes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..4f4940ba6655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feature request + url: https://github.com/pingdotgg/t3code/discussions/categories/ideas + about: Suggest an improvement or new capability in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 3c9424fb322c..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Feature request -description: Propose a scoped improvement or new capability. -title: "[Feature]: " -labels: - - enhancement - - needs-triage -body: - - type: markdown - attributes: - value: | - Use this form for new capabilities or meaningful improvements to existing behavior. - This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate. - - - type: checkboxes - id: checks - attributes: - label: Before submitting - options: - - label: I searched existing issues and did not find a duplicate. - required: true - - label: I am describing a concrete problem or use case, not just a vague idea. - required: true - - - type: dropdown - id: area - attributes: - label: Area - description: Which part of the project would this change affect? - options: - - apps/web - - apps/server - - apps/desktop - - apps/mobile - - packages/contracts or packages/shared - - Build, CI, or release tooling - - Docs - - Not sure - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem or use case - description: What are you trying to do? What is hard, slow, or impossible today? - placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: Describe the behavior, API, or UX you want. - placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load. - validations: - required: true - - - type: textarea - id: value - attributes: - label: Why this matters - description: Who benefits, and what outcome does this unlock? - placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions. - validations: - required: true - - - type: textarea - id: scope - attributes: - label: Smallest useful scope - description: What is the narrowest version of this request that would still solve your problem? - placeholder: A first pass only needs to support restoring the active session for the current thread. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Workarounds, prior art, or other approaches you considered. - placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context. - - - type: textarea - id: tradeoffs - attributes: - label: Risks or tradeoffs - description: What costs, complexity, or edge cases should be considered? - placeholder: This may require careful handling when the underlying provider session has already exited. - - - type: textarea - id: references - attributes: - label: Examples or references - description: Links, screenshots, mockups, or comparable tools. - - - type: checkboxes - id: contribution - attributes: - label: Contribution - options: - - label: I would be open to helping implement this. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b734a99bbb0..e8e2f9b11782 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,9 @@ We are not actively accepting contributions right now. -You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. +You can still report a bug or open a PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. + +Feature requests and proposals belong in [Ideas discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas), not issues. If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. @@ -50,9 +52,9 @@ If the change depends on motion, timing, transitions, or interaction details, in If we have to guess what changed, we are much less likely to review it. -## Issues First +## Discuss Changes First -If you are thinking about a non-trivial change, open an issue first. +If you are thinking about a non-trivial change, start a discussion first. Issues are reserved for bug reports. That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time. diff --git a/README.md b/README.md index a7264ef62e97..8ec101387f67 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Checkout their getting started guide for more information: https://viteplus.dev/ vp i ``` -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. +Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR. + +Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). From d8a6dfd31539a86d08bd4fbd030f8252b3c405ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 12:29:26 +0200 Subject: [PATCH 047/144] fix(desktop): app zoom no longer zooms the preview browser (#6649) Co-authored-by: Claude Opus 5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 134 ++++++++++++++++-- apps/desktop/src/preview/Manager.ts | 72 +++++++--- apps/desktop/src/window/DesktopWindow.test.ts | 47 ++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + 4 files changed, 219 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c24dca802c58..5c336eec8da4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -979,7 +979,10 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + // The guest reports whatever zoom level Chromium handed it from the app + // window, so the tab's own zoom is the source of truth in both directions: + // asserted onto every guest, never read back off one. + effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => withManager((manager) => Effect.gen(function* () { let effectiveZoom = 0.9; @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(0.9); - expect(setZoomFactor).not.toHaveBeenCalled(); + expect(states.at(-1)?.zoomFactor).toBe(1); + expect(setZoomFactor).toHaveBeenCalledWith(1); - effectiveZoom = 1.25; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.25); - expect(setZoomFactor).not.toHaveBeenCalled(); - - zoomReadable = false; - url = "https://example.com/after-zoom-read-failed"; + // An app zoom leaves the guest reporting the inherited level. Navigating + // must not adopt it as the preview's zoom. + effectiveZoom = 0.8; + url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { url, title: "Example", }); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1); + + // Only the preview's own zoom controls move it. + yield* manager.zoomIn("tab_zoom"); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + + zoomReadable = false; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.1); const replacementSetZoomFactor = vi.fn(); fromId.mockReturnValue({ @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_zoom", 43); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + }), + ), + ); + + // Zooming the app UI pushes the window's zoom level onto every guest, so the + // preview has to be put back at the zoom the user gave it. + effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_reapply"); + yield* manager.registerWebview("tab_reapply", 42); + yield* manager.zoomIn("tab_reapply"); + setZoomFactor.mockClear(); + + yield* manager.reapplyZoom(); + + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + }), + ), + ); + + // did-attach and dom-ready both re-register the guest that is already + // attached, and a guest that just inherited the app window's zoom needs its + // own back — without that round trip republishing tab state. + effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_reregister_zoom"); + yield* manager.registerWebview("tab_reregister_zoom", 42); + yield* manager.zoomIn("tab_reregister_zoom"); + setZoomFactor.mockClear(); + const publishedBefore = states.length; + + yield* manager.registerWebview("tab_reregister_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.zoomFactor).toBe(1.1); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4799a7dfac26..d48b13037398 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emit(tabId, next.value); }); + /** + * Pushes a tab's zoom factor onto whichever guest it currently owns, reading + * both at call time. Anything that applies zoom after an await goes through + * here: a snapshot taken before the await can be older than a zoom action that + * landed in between, and re-applying it would roll that action back. + */ + const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(tab.zoomFactor), + ).pipe(Effect.ignore); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function confirmedNavigation = false, ) { if (wc.isDestroyed()) return; - const zoomFactor = yield* attempt( - { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, - () => wc.getZoomFactor(), - ).pipe(Effect.option); const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + // zoomFactor is deliberately not read back from the guest: Chromium + // reports the level it inherited from the app window, so mirroring it + // would turn an app zoom into the preview's own zoom. updatedAt, }; return [ @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - const zoomFactor = yield* attempt( - { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, - () => wc.getZoomFactor(), - ); - yield* update(tabId, { zoomFactor }); + // The guest we already own re-announced itself, so nothing about the tab + // changed. Only push its zoom back down — Chromium may have just handed + // this guest the app window's zoom level. + yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - const zoomFactor = - replacedWebContentsId !== null - ? yield* attempt( - { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, - () => { - wc.setZoomFactor(currentTab.zoomFactor); - return currentTab.zoomFactor; - }, - ) - : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => - wc.getZoomFactor(), - ); + // Always assert the tab's own zoom rather than reading the guest's: a guest + // attaching while the app UI is zoomed starts at the embedder's inherited + // zoom level, which is not the preview's zoom. Done before the guest is + // published so it never paints a frame at the inherited zoom. + yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => + wc.setZoomFactor(currentTab.zoomFactor), + ); yield* attachListeners(tabId, wc); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, updatedAt: registeredAt, }; return [ @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + // A zoom action that landed while this attach was in flight addressed the + // guest this one replaced, so settle the new guest on the committed factor. + yield* assertTabZoom(tabId); runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * Chromium hands every guest `` the embedder's zoom level, so zooming + * the app UI drags the previewed page along with it. The preview browser owns + * its own zoom factor, so re-assert it on each attached guest whenever the main + * window's zoom changes (see DesktopWindow.zoomMain). + */ + const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { + const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); + yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); + }); + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; + // Re-applies every attached guest's own zoom factor, undoing the zoom level + // Chromium inherits from the embedder when the app UI zooms. + readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, + reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0e..ed0fbf8b5688 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -61,9 +61,14 @@ const environmentInput = { function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); + let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, + setZoomLevel: webContents.setZoomLevel, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, @@ -186,6 +192,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -264,6 +271,10 @@ function makeTestLayer(input: { setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), }), ), ), @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { }), ); + // Chromium hands the main window's zoom level down to embedded preview + // guests, so every app zoom has to put the preview browser back at its own + // zoom or zooming the UI drags the previewed page with it. + it.effect("restores the preview browser's own zoom after zooming the app", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [-0.5, -1, -0.5, 0], + ); + // Recorded after the window level moved, so the preview is put back at + // its own zoom on every step rather than left on the inherited one. + assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448fe..2ae3d353279b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + // Chromium pushes the new level down to embedded guests, which would zoom + // the previewed page along with the app UI. The preview browser keeps its + // own zoom, so put each guest back where the preview left it. + yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; From afca73d3683c99057ea8af1ad7d77511a0faf680 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 15 Aug 2026 05:43:13 -0500 Subject: [PATCH 048/144] fix(server): keep provider notification consumers alive past startSession (#6538) Co-authored-by: tsouth89 --- .../src/provider/Layers/CodexAdapter.test.ts | 58 ++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 6 +- .../src/provider/Layers/CursorAdapter.test.ts | 68 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 8 ++- .../src/provider/Layers/GrokAdapter.test.ts | 67 ++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 8 ++- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec56660..5358716aabe4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -32,6 +32,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; @@ -1150,6 +1151,63 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the runtime event consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every event the session + // emitted afterwards was dropped. The other tests here start the session from + // the test fiber, which never completes, so the consumer survived and the bug + // stayed invisible. Starting it in a fiber that finishes reproduces + // production. + it.effect("keeps consuming runtime events after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const startSessionFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-outlives-start"), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-after-start-session"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-outlives-start"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_after_start"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-outlives-start", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg_after_start", + text: "emitted after startSession returned", + }, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("10 seconds")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "item.completed"); + // Live clock so the timeout above is real: under the default test clock it + // waits on virtual time that never advances, and a regression would hang + // until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e0..065156d36473 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1715,6 +1715,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); + // Fork into the session scope, not the calling fiber. `forkChild` makes + // this a child of `startSession`, and Effect interrupts a fiber's + // children when it completes, so the consumer died on return and every + // runtime event the session emitted afterwards was dropped. const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); @@ -1730,7 +1734,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a9776..cd5cdb7f01aa 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1429,4 +1429,72 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }).pipe(Effect.provide(customAdapterLayer)); }, ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. The other tests here call startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-consumer-outlives-start-session"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sawContentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "content.delta" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sawContentDelta, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello mock", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sawContentDelta).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c2695..30c173d8fae8 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -874,7 +874,13 @@ export function makeCursorAdapter( Effect.catch((cause) => Effect.logError("Failed to process Cursor runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae81..6cb71660a74c 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,71 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. Every other test here calls startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-consumer-outlives-start-session"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello grok", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caaddb..858d862e6d5f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -876,7 +876,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.catch((cause) => Effect.logError("Failed to process Grok runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; From 75472802bc5ddaba860dc652000223600e529937 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:43:20 +0200 Subject: [PATCH 049/144] fix(server): treat removed Bitbucket permissions endpoint as unknown, not blocking (#6525) --- .../BitbucketPullRequestApi.test.ts | 40 +++++++++++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 19 ++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index f57bb67a4c40..4120cf55e622 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -867,6 +867,46 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect( + "reads a removed permissions endpoint as granted rather than failing the merge on it", + () => + Effect.gen(function* () { + // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now + // gets HTTP 410 here, whatever it may do. + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 410, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); + }), + ); + + it.effect("still fails the permission read on a failure that is not the removed endpoint", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 401, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); + + assert.strictEqual(error._tag, "BitbucketResponseError"); + }), + ); + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a20d4aaaa056..a2c57bfc5fdb 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -107,6 +107,16 @@ export type BitbucketPullRequestApiError = | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; +/** + * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a + * permission — Bitbucket sends this for every account now, not only ones it would have refused. + */ +function isRepositoryPermissionRemovedError( + error: BitbucketPullRequestApiError, +): error is BitbucketApi.BitbucketResponseError { + return error._tag === "BitbucketResponseError" && error.status === 410; +} + /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. @@ -553,6 +563,13 @@ export const make = Effect.gen(function* () { // Nothing on the repository, the pull request or the workspace states what the credentials // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked // alongside the reads the detail was already making, so it costs no round trip of its own. + // + // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 + // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a + // permission being refused, so it is read the same way an unreachable read already is + // elsewhere — as a permission that could not be learned, which grants rather than blocks, and + // leaves the actual merge or write to say why if the account may not do it. Any other failure + // (a bad token, a network fault, an unreadable body) still fails as it did before. getRepositoryPermission: (input) => withRepository(input.repository, () => readPage({ @@ -562,7 +579,7 @@ export const make = Effect.gen(function* () { )}`, decode: decodeRepositoryPermissionJson, }), - ), + ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), getPullRequestDiff: (input) => input.commit !== undefined && !isCommitSha(input.commit) From 672216d7e152241213a8757892f281e1f4434e8a Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:43:42 +0200 Subject: [PATCH 050/144] fix(ssh): let cold remote servers finish starting (#6168) --- packages/ssh/src/tunnel.test.ts | 34 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 76b8ecccb304..be17b8ffaf35 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 179d1fcb547d..a1611c5770f4 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), From 1e87029261f9b81061a2a7420849b9eeaf1a2ebe Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:43:50 +0800 Subject: [PATCH 051/144] fix(web): preserve Claude insight line breaks (#4344) --- .../components/chat/MessagesTimeline.logic.test.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.logic.ts | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 2 ++ 3 files changed, 18 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1ca..70a330d46303 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,8 +5,20 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("shouldPreserveAssistantLineBreaks", () => { + it("preserves Claude insight formatting without changing regular markdown", () => { + expect( + shouldPreserveAssistantLineBreaks( + "★ Insight ─────────────────\\nFirst observation\\nSecond observation\\n─────────────────", + ), + ).toBe(true); + expect(shouldPreserveAssistantLineBreaks("A normal\\nmarkdown paragraph")).toBe(false); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203c..c89bbd0557d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -52,6 +52,10 @@ export function resolveTimelineIsAtEnd( return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } +export function shouldPreserveAssistantLineBreaks(text: string): boolean { + return /^★ Insight(?:\s|─)/mu.test(text); +} + export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f9ad57ff3b83..f5c529ff315f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -83,6 +83,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -1113,6 +1114,7 @@ function AssistantTimelineRow({ row }: { row: Extract Date: Sat, 15 Aug 2026 03:43:58 -0700 Subject: [PATCH 052/144] feat(web): accept file drops across the chat workspace (#6636) --- apps/web/src/components/ChatView.tsx | 42 +++++++++- apps/web/src/components/chat/ChatComposer.tsx | 49 ++---------- .../components/chat/workspaceFileDrop.test.ts | 78 +++++++++++++++++++ .../src/components/chat/workspaceFileDrop.ts | 54 +++++++++++++ 4 files changed, 180 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/workspaceFileDrop.test.ts create mode 100644 apps/web/src/components/chat/workspaceFileDrop.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c9..7a5bde6345c0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -164,6 +165,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); + + useEffect(() => { + setIsWorkspaceFileDragActive(false); + }, [draftId, routeThreadKey]); + + useEffect(() => { + if (!isWorkspaceFileDragActive) return; + const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); + window.addEventListener("dragend", clearWorkspaceFileDrag); + return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); + }, [isWorkspaceFileDragActive]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); @@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsWorkspaceFileDragActive, + addFiles: (files) => composerRef.current?.addDroppedFiles(files), + }); + return (
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} @@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
+ {isWorkspaceFileDragActive ? ( +
+
+
+
+ ) : null} {/* Provider status overlays the timeline without changing its content height. */}
void; focusAt: (cursor: number) => void; + addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; @@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); /** @@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); @@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!isDragOverComposer) return; const onWindowDragEnd = () => { - dragDepthRef.current = 0; setIsDragOverComposer(false); }; window.addEventListener("dragend", onWindowDragEnd); @@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + addDroppedFiles: (files: File[]) => { + void addComposerImages(files); + focusComposer(); + }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); @@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, + addComposerImages, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + focusComposer, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "group rounded-[22px] p-px transition-colors duration-200", composerProviderState.composerFrameClassName, )} - onDragEnter={onComposerDragEnter} - onDragOver={onComposerDragOver} - onDragLeave={onComposerDragLeave} - onDrop={onComposerDrop} onDragEnterCapture={composerMentionDragHandlers.onDragEnter} onDragOverCapture={composerMentionDragHandlers.onDragOver} onDragLeaveCapture={onComposerMentionDragLeaveCapture} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts new file mode 100644 index 000000000000..ec5d074a3eb7 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeWorkspaceFileDropHandlers, + type WorkspaceFileDragEvent, + type WorkspaceFileDropHost, +} from "./workspaceFileDrop"; + +function makeDragEvent(options?: { + types?: string[]; + files?: File[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + files: options?.files ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies WorkspaceFileDragEvent; + return { event, preventDefault }; +} + +function makeHost() { + const setDragActive = vi.fn(); + const addFiles = vi.fn(); + const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles }; +} + +describe("makeWorkspaceFileDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeWorkspaceFileDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores non-file drags", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeWorkspaceFileDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeWorkspaceFileDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("forwards dropped files and clears the active state", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, setDragActive, addFiles } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addFiles).toHaveBeenCalledWith([file]); + }); +}); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts new file mode 100644 index 000000000000..132a8051e159 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -0,0 +1,54 @@ +export interface WorkspaceFileDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly files: Iterable; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +export interface WorkspaceFileDropHost { + setDragActive(active: boolean): void; + addFiles(files: File[]): void; +} + +function isFileDrag(event: WorkspaceFileDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { + return { + onDragEnter(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + onDrop(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + host.addFiles(Array.from(event.dataTransfer.files)); + }, + }; +} From eaa6c4712fe11f0396e549b1873f163dc202d229 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:01 +0200 Subject: [PATCH 053/144] fix(web): widen ordered-list marker gutter for 3+ digit item numbers (#6527) --- apps/web/src/components/ChatMarkdown.test.tsx | 36 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 29 +++++++++++++++ apps/web/src/index.css | 14 ++++++-- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 000000000000..9499ee5a6915 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { orderedListGutterStyle } from "./ChatMarkdown"; + +describe("orderedListGutterStyle", () => { + it("leaves the default gutter alone for single-digit lists", () => { + expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + // start=50 + 49 items => last marker is "98", still two digits. + expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + }); + + it("widens the gutter once the last marker reaches three digits", () => { + // item 100 is the bug from #6512: a 100-item list starting at 1. + expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("accounts for a non-default start attribute", () => { + // start=95 + 9 items => last marker is "103", three digits. + expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("scales further for four-digit markers", () => { + expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); + }); + + it("treats a missing/zero item count as a single item", () => { + expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 53b043f3a8ff..294a9e22ad75 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -146,6 +146,26 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } + +/** + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit + * decimal markers. Once a list's last item reaches three digits (item 100+), + * `list-style-position: outside` paints the marker wider than that gutter and + * the leading digit gets clipped by the item's own overflow. Rather than + * widening the gutter for every list, only lists whose last marker is 3+ + * digits get a wider `--list-gutter`, sized to that marker's digit count. + */ +export function orderedListGutterStyle( + itemCount: number, + start: number | undefined, +): { "--list-gutter": string } | undefined { + const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const digits = String(Math.abs(lastNumber)).length; + if (digits <= 2) return undefined; + return { "--list-gutter": `${digits + 1}ch` }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1506,6 +1526,15 @@ function ChatMarkdown({
); }, + ol({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
    + ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b2c914c0b69f..299506c30ad2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1630,12 +1630,22 @@ code { } .chat-markdown ul { + /* Reset for nested uls under a widened ol — --list-gutter is an inherited + custom property, so without this a task-list under a 3+ digit ordered + list would inherit the outer gutter instead of its own default. */ + --list-gutter: 1.25rem; padding-left: 1.25rem; list-style-type: disc; } +/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but + ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose + last marker is 3+ digits, so item 100+ isn't clipped by list-style-position: + outside painting the marker past the padding box. Reset it here too so a + nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { - padding-left: 1.25rem; + --list-gutter: 1.25rem; + padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1665,7 +1675,7 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em -1.25rem; + margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); vertical-align: middle; } From 71c6f8248775066ebaf4bfc6680d3e2acb4bb2d1 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:03 +0200 Subject: [PATCH 054/144] fix(server): bound thread activity hydration (#6153) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../Layers/ProjectionSnapshotQuery.test.ts | 124 +++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 203 +++++++++++++++--- 2 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index be596b36b850..83ae3cfe049a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2281,6 +2281,130 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("bounds activity hydration and preserves unresolved requests", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 1 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 500); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("activity-0002")); + assert.equal(fullDetail.value.activities.at(-1)?.id, asEventId("activity-0501")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 500); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("activity-0002")); + assert.equal(windowedDetail.value.thread.activities.at(-1)?.id, asEventId("activity-0501")); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'approval-old', 'thread-w', NULL, 'approval', 'approval.requested', + 'Approve old command', '{"requestId":"approval-1"}', NULL, + '2026-03-01T00:00:01.000Z' + ), + ( + 'user-input-old', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Answer old question', '{"requestId":"input-1"}', NULL, + '2026-03-01T00:00:02.000Z' + ), + ( + 'user-input-closed', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:03.000Z' + ), + ( + 'user-input-closed-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:04.000Z' + ), + ( + 'user-input-tied-z-request', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ), + ( + 'user-input-tied-a-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) + VALUES ( + 'approval-1', 'thread-w', NULL, 'pending', NULL, + '2026-03-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + UPDATE projection_threads + SET pending_approval_count = 1, pending_user_input_count = 1 + WHERE thread_id = 'thread-w' + `; + + const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detailWithPinnedRequests._tag, "Some"); + if (detailWithPinnedRequests._tag === "Some") { + const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + assert.equal(detailWithPinnedRequests.value.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + + const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowWithPinnedRequests._tag, "Some"); + if (windowWithPinnedRequests._tag === "Some") { + const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875a..c6c5ad1d7e8c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -69,6 +69,10 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +// Keep detail reads consistent with the in-memory projector's retained +// activity window. Applying the limit in SQL avoids decoding an unbounded +// payload_json set before the projector can enforce that invariant. +const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -1015,8 +1019,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1232,6 +1253,95 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH pending_approval_requests AS ( + SELECT request_id, thread_id + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ), + pending_approval_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + PARTITION BY pending.request_id + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_approval_requests AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + ), + pending_user_input_thread AS ( + SELECT thread_id + FROM projection_threads + WHERE thread_id = ${threadId} + AND pending_user_input_count > 0 + ), + user_input_lifecycle AS ( + SELECT + activity.activity_id, + activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_user_input_thread AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved') + OR ( + activity.kind = 'provider.user-input.respond.failed' + AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + ) + ) + ) + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + ), + pinned_activity_ids AS ( + SELECT activity_id + FROM pending_approval_activities + WHERE request_order = 1 + UNION ALL + SELECT activity_id + FROM user_input_lifecycle + WHERE request_order = 1 + AND kind = 'user-input.requested' + ) + SELECT + activity.activity_id AS "activityId", + activity.thread_id AS "threadId", + activity.turn_id AS "turnId", + activity.tone, + activity.kind, + activity.summary, + activity.payload_json AS "payload", + activity.sequence, + activity.created_at AS "createdAt" + FROM pinned_activity_ids AS pinned + INNER JOIN projection_thread_activities AS activity + ON activity.activity_id = pinned.activity_id + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1247,34 +1357,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND ( - turn_id IN ( - SELECT turn_id FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NOT NULL - AND ( - requested_at > ${minAnchorAt} - OR ( - requested_at = ${minAnchorAt} - AND turn_id >= ${minTurnKey} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) ) - ) - AND ( - requested_at < ${beforeAnchorAt} - OR ( - requested_at = ${beforeAnchorAt} - AND turn_id < ${beforeTurnKey} + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) ) - ) - ) - OR ( - turn_id IS NULL - AND created_at >= ${minAnchorAt} - AND created_at < ${beforeAnchorAt} + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) ) - ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -2374,6 +2501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messageRows, proposedPlanRows, activityRows, + pinnedActivityRows, checkpointRows, latestTurnRow, sessionRow, @@ -2416,6 +2544,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ), listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2446,6 +2582,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } + const selectedActivityRows = [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), + ).values(), + ].toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2483,7 +2630,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { + activities: selectedActivityRows.map((row) => { const activity = { id: row.activityId, tone: row.tone, From 48cba7d93c8c63508f31cce2544d480ace86f929 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:19 +0200 Subject: [PATCH 055/144] fix(web): restore the Archive action in the default sidebar thread menu (#6526) --- apps/web/src/components/Sidebar.tsx | 34 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 27 ++++++++++++++- .../src/components/threadActionMenu.logic.ts | 9 +++++ apps/web/src/hooks/useThreadActionMenu.ts | 26 ++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a221405..a7a5b638c0eb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1622,6 +1622,7 @@ export default function Sidebar() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1633,6 +1634,7 @@ export default function Sidebar() { pinThread, unpinThread, reorderPinnedThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -2990,6 +2992,8 @@ export default function Sidebar() { isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, + isRunning: + thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3093,6 +3097,34 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3126,12 +3158,14 @@ export default function Sidebar() { })(); }, [ + archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0a..c839ddc3be75 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdacd..44c2e907ca55 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -30,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -102,6 +105,12 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca2305163f..4a25df47b027 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -82,6 +83,7 @@ export function useThreadActionMenu(input: { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -139,6 +141,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -253,6 +256,27 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -285,9 +309,11 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, autoSettleOnMerge, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, From 9f26656cb958853f90f7215387d604c098937db8 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:44:22 +0200 Subject: [PATCH 056/144] fix(web): open diff files from nested projects (#6174) --- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/diffFileActions.test.ts | 75 ++++++++++++++++++++++++- apps/web/src/diffFileActions.ts | 79 ++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b929d05a719b..66f0a4e111b2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -134,6 +134,9 @@ export default function DiffPanel({ : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const activeRepositoryRoot = activeThread?.worktreePath + ? undefined + : activeProject?.repositoryIdentity?.rootPath; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -443,6 +446,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, + repositoryRoot: activeRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -462,7 +466,7 @@ export default function DiffPanel({ }, }); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 9c358ab1d294..c5d3571a9c1e 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; const THREAD_REF = scopeThreadRef( @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { "/repo/project/apps/web/src/components/DiffPanel.tsx", ); }); + + it("opens repository-relative diff files from a nested project", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "frontend/Dockerfile", + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:Dockerfile", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("preserves repository-relative paths in a separate worktree", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/Dockerfile", + workspaceRoot: "/worktrees/feature", + repositoryRoot: "/repo", + }), + ).toBe("frontend/Dockerfile"); + }); + + it("handles Windows roots and mixed diff separators", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + }), + ).toBe("src/index.ts"); + }); + + it.each([ + { workspaceRoot: "/frontend", repositoryRoot: "/" }, + { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" }, + ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/index.ts", + workspaceRoot, + repositoryRoot, + }), + ).toBe("index.ts"); + }); + + it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( + "does not open an out-of-project diff path: %s", + (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 335ad21fccf9..3ac22c28cf25 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "./terminal-links"; @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { readonly threadRef: ScopedThreadRef | null; readonly filePath: string; readonly activeCwd: string | undefined; + readonly repositoryRoot?: string | undefined; readonly openInEditor: (targetPath: string) => void; } +function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { + if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { + return null; + } + + const segments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function repositoryRelativeWorkspaceSegments( + workspaceRoot: string | undefined, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!workspaceRoot || !repositoryRoot) return null; + + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot); + const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); + if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; + + const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) + ? normalizedRepositoryRoot + : `${normalizedRepositoryRoot}${separator}`; + if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; + + return normalizedWorkspaceRoot + .slice(repositoryPrefix.length) + .split(/[\\/]+/) + .filter(Boolean); +} + +export function resolveDiffPathForWorkspace(input: { + readonly filePath: string; + readonly workspaceRoot: string | undefined; + readonly repositoryRoot: string | undefined; +}): string | null { + const fileSegments = normalizedRelativePathSegments(input.filePath); + if (!fileSegments) return null; + + const workspaceSegments = repositoryRelativeWorkspaceSegments( + input.workspaceRoot, + input.repositoryRoot, + ); + if (!workspaceSegments || workspaceSegments.length === 0) { + return fileSegments.join("/"); + } + + const caseInsensitive = input.repositoryRoot + ? isWindowsAbsolutePath(input.repositoryRoot) + : false; + const belongsToWorkspace = workspaceSegments.every((segment, index) => { + const candidate = fileSegments[index]; + if (candidate === undefined) return false; + return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment; + }); + if (!belongsToWorkspace) return null; + + const relativeSegments = fileSegments.slice(workspaceSegments.length); + return relativeSegments.length > 0 ? relativeSegments.join("/") : null; +} + export function openDiffFilePrimaryAction({ threadRef, filePath, activeCwd, + repositoryRoot, openInEditor, }: OpenDiffFilePrimaryActionInput): void { + const workspaceFilePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot, + }); + if (!workspaceFilePath) return; + if (threadRef) { - useRightPanelStore.getState().openFile(threadRef, filePath); + useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); return; } - openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); + openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); } From b277cc65e045899e7aa941e92d04f2b4996f27bb Mon Sep 17 00:00:00 2001 From: mohamedmastouri-hue Date: Sat, 15 Aug 2026 11:44:30 +0100 Subject: [PATCH 057/144] fix(mobile): use tryOpenExternalUrl for markdown links in ThreadFeed (#5872) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d138bb0c99dd..c5edb822ae5b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,7 +28,6 @@ import { import { ActivityIndicator, Image, - Linking, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -51,6 +50,7 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, @@ -283,7 +283,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { { - void Linking.openURL(props.href); + void tryOpenExternalUrl(props.href, "markdown-link"); }} style={{ color: props.color, @@ -613,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe onPress={ linkHref ? () => { - void Linking.openURL(linkHref); + void tryOpenExternalUrl(linkHref, "markdown-link"); } : undefined } @@ -1436,7 +1436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { - void Linking.openURL(presentation.href); + void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], From 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Sat, 15 Aug 2026 12:44:51 +0200 Subject: [PATCH 058/144] fix(web): open the file a bare filename reference names (#6297) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/ChatMarkdown.tsx | 53 ++++++++++- apps/web/src/workspaceBasenameLookup.test.ts | 93 ++++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 48 ++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/workspaceBasenameLookup.test.ts create mode 100644 apps/web/src/workspaceBasenameLookup.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 294a9e22ad75..ec88bc912f00 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { projectEnvironment } from "../state/projects"; +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; @@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + onOpenInPanel(workspaceRelativePath, line); + }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1355,6 +1365,9 @@ function ChatMarkdown({ const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -1457,6 +1470,40 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + // A bare filename resolves to the workspace root, which is rarely where the + // file is, so ask the index before opening. + const openFileInPanel = useCallback( + (workspaceRelativePath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + openAt(workspaceRelativePath); + return; + } + void (async () => { + const result = await searchProjectEntries({ + environmentId: threadRef.environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + if (!isLatestLookup()) return; + openAt(match ?? workspaceRelativePath); + })(); + }, + [cwd, searchProjectEntries, threadRef], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1490,6 +1537,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1718,6 +1766,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts new file mode 100644 index 000000000000..e96e5f18b4f7 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, +} from "./workspaceBasenameLookup"; + +describe("needsWorkspaceBasenameLookup", () => { + it("flags bare filenames", () => { + expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); + expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); + }); + + it("leaves anything with a directory alone", () => { + expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(" ")).toBe(false); + }); +}); + +describe("pickWorkspaceBasenameMatch", () => { + const entries = [ + { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, + { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const }, + ]; + + it("takes the first exact filename match, not the closest fuzzy one", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceBasenameMatch("components", [ + { path: "apps/web/src/components", kind: "directory" }, + { path: "apps/web/src/components/components", kind: "file" }, + ]), + ).toBe("apps/web/src/components/components"); + }); + + it("prefers the exactly-cased file over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("foo.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBe("src/foo.ts"); + }); + + it("falls back to case-insensitive when only the casing differs", () => { + expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("returns null when the case-insensitive fallback is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("FOO.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("returns null when nothing matches the name", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull(); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" }, + ]), + ).toBeNull(); + }); +}); + +describe("claimWorkspaceBasenameLookup", () => { + it("keeps only the newest claim, whatever order the lookups settle in", () => { + const first = claimWorkspaceBasenameLookup(); + const second = claimWorkspaceBasenameLookup(); + + // The older lookup answering last must not reopen the panel behind the + // newer one. + expect(second()).toBe(true); + expect(first()).toBe(false); + }); + + it("stays valid while it is the only claim", () => { + const only = claimWorkspaceBasenameLookup(); + expect(only()).toBe(true); + expect(only()).toBe(true); + }); +}); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts new file mode 100644 index 000000000000..b99d3ba4ded9 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -0,0 +1,48 @@ +// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without +// asking for a full listing on a single click. +export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; + +// One counter for every caller: they all open the same panel, so the newest +// click wins regardless of which one started the lookup. +let latestLookupSequence = 0; + +/** Call the returned predicate when the search settles; false means a later click superseded it. */ +export function claimWorkspaceBasenameLookup(): () => boolean { + latestLookupSequence += 1; + const claimed = latestLookupSequence; + return () => claimed === latestLookupSequence; +} + +export interface WorkspaceEntryCandidate { + readonly path: string; + readonly kind: "file" | "directory"; +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function needsWorkspaceBasenameLookup(relativePath: string): boolean { + const trimmed = relativePath.trim(); + return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +} + +export function pickWorkspaceBasenameMatch( + basename: string, + entries: ReadonlyArray, +): string | null { + const target = basename.trim(); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const exact = files.find((entry) => basenameOfPath(entry.path) === target); + if (exact) return exact.path; + // Folded matching covers casing that drifted from disk, but `FOO.ts` against + // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing + // rather than opening whichever the index ranked first. + const folded = target.toLowerCase(); + const foldedMatches = files.filter( + (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + ); + return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; +} From ddee418a8d6d3e242ca26a8053a886ecc3b56b53 Mon Sep 17 00:00:00 2001 From: Ulises Britos <45952970+repparw@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:31:36 -0300 Subject: [PATCH 059/144] fix(server): stop the provider title mirror from overwriting real thread titles (#5941) --- .../Layers/ProviderCommandReactor.ts | 15 +---- .../Layers/ProviderRuntimeIngestion.test.ts | 57 ++++++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 15 +++-- apps/server/src/orchestration/threadTitles.ts | 13 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 67 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 20 +++++- packages/contracts/src/provider.ts | 1 + 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/orchestration/threadTitles.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..cfc95f2613fb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -39,6 +39,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { forkParked, ServerActivation } from "../../serverActivation.ts"; +import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -626,6 +614,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..449b1fbf5136 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial }) { + async function createHarness(options?: { + serverSettings?: Partial; + threadTitle?: string; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: options?.threadTitle ?? "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread( harness.readModel, (entry) => - entry.title === "Renamed by provider" && + entry.title === "Thread" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", ) && @@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => { ), ); - expect(thread.title).toBe("Renamed by provider"); + expect(thread.title).toBe("Thread"); const planActivity = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", @@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("mirrors a provider title only while the thread still has the default title", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-default"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Renamed by provider", + ); + expect(thread.title).toBe("Renamed by provider"); + }); + + it("rejects a provider title once the thread has a real title", async () => { + const harness = await createHarness({ threadTitle: "User-set title" }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-real"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("User-set title"); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..c942960f3c68 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -43,6 +43,7 @@ import { } from "../Services/ProviderRuntimeIngestion.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; @@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); + if (canReplaceThreadTitle(thread.title)) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } } if (event.type === "turn.diff.updated") { diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts new file mode 100644 index 000000000000..c9a9c4f72830 --- /dev/null +++ b/apps/server/src/orchestration/threadTitles.ts @@ -0,0 +1,13 @@ +export const DEFAULT_THREAD_TITLE = "New thread"; + +export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabec..eea328e05d1e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes the thread title to session.create when provided", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-provided"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + title: "Investigate reconnect failures", + }); + + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal( + runtimeMock.state.sessionCreateInputs[0]?.title, + "Investigate reconnect failures", + ); + }), + ); + + it.effect("does not mirror OpenCode's default placeholder session titles", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-placeholder-title"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "New session - 2026-08-09T10:20:30.456Z", + }, + }, + }, + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate reconnect failures", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); + NodeAssert.equal(metadataUpdated.length, 1); + if (metadataUpdated[0]?.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e686..8f7e42c11d7c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return undefined; } - return trimText(event.properties.info.title); + const title = trimText(event.properties.info.title); + // OpenCode mints a placeholder title at session.create when no title was + // provided, and re-emits it on every `session.updated`. Mirroring it would + // overwrite the thread's real title (openCodeEventSessionTitle feeds the + // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated + // placeholders so the thread isn't locked onto them. + if (!title || isOpenCodeDefaultTitle(title)) { + return undefined; + } + + return title; +} + +const OPENCODE_DEFAULT_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function isOpenCodeDefaultTitle(title: string): boolean { + return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } interface OpenCodeSessionContext { @@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter( } const createdSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ + ...(input.title ? { title: input.title } : {}), permission: buildOpenCodePermissionRules(input.runtimeMode), }), ); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc2..c84ad43c4e78 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 178da6bc3210b82c4a83f33c8b149f623a3375e1 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 060/144] fix(shared): match source-control providers by DNS label (#6175) --- packages/shared/src/sourceControl.test.ts | 29 +++++++++++++++++++++++ packages/shared/src/sourceControl.ts | 10 +++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index bfee883dd9f5..3842fa84b5a7 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -91,4 +91,33 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("matches self-hosted providers by complete DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") + ?.kind, + ).toBe("bitbucket"); + }); + + it("does not match provider names embedded in unrelated DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://notbitbucket.example.com/workspace/repo.git", + )?.kind, + ).toBe("unknown"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index a29fe968e44d..ad6fa890bf25 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -167,12 +167,16 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +function hasDnsLabel(host: string, label: string): boolean { + return host.split(".").includes(label); +} + function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); + return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { @@ -188,7 +192,7 @@ function isAzureDevOpsHost(host: string): boolean { } function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || host.includes("bitbucket"); + return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( From b7dbbbaf6c394621cba57cf58dfcc1845f445ef6 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:33:29 +0300 Subject: [PATCH 061/144] feat(desktop): Chrome-style hold-to-quit (#5508) --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/wsl.test.ts | 12 ++ apps/desktop/src/preload.ts | 11 + .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 14 ++ apps/desktop/src/window/DesktopWindow.ts | 40 +++- apps/desktop/src/window/QuitHold.test.ts | 201 ++++++++++++++++++ apps/desktop/src/window/QuitHold.ts | 148 +++++++++++++ apps/web/src/AppRoot.test.tsx | 4 +- apps/web/src/AppRoot.tsx | 2 + apps/web/src/components/QuitHoldOverlay.tsx | 47 ++++ .../components/settings/SettingsPanels.tsx | 29 +++ .../settings/settingsSearch.test.ts | 5 + .../src/components/settings/settingsSearch.ts | 17 +- packages/contracts/src/ipc.ts | 6 + packages/contracts/src/settings.ts | 4 + 16 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/window/QuitHold.test.ts create mode 100644 apps/desktop/src/window/QuitHold.ts create mode 100644 apps/web/src/components/QuitHoldOverlay.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e31082afb5f..ac1ee8792806 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39bf..38435e286fa7 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 61e345b90848..cbbadb708ab7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onQuitShortcut: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (state !== "down" && state !== "up") return; + listener(state); + }; + + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 44c12cc554ad..23a75eb3f79d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ed0fbf8b5688..42ba818acf5f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { }; } +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeed(Option.none()), +}); + +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -253,8 +263,10 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, + desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), + desktopClientSettingsLayer, desktopServerExposureLayer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2ae3d353279b..9018b9b92c2a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + QUIT_SHORTCUT_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopClientSettings.DesktopClientSettings + | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. + // Chrome-style hold-to-quit: intercept the quit accelerator before the + // native menu sees it and only quit after the shortcut is held. The + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. + const quitHoldHandler = makeQuitHoldHandler({ + platform: environment.platform, + isEnabled: () => + runPromise( + Effect.map( + clientSettings.get, + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + ), + notify: (state) => { + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + } + }, + quit: () => { + void runPromise(electronApp.quit); + }, + }); window.webContents.on("before-input-event", (event, input) => { + quitHoldHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts new file mode 100644 index 000000000000..c900a865439e --- /dev/null +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeQuitHoldHandler, + QUIT_DOUBLE_TAP_MS, + QUIT_HOLD_DURATION_MS, + QUIT_HOLD_RELEASE_GRACE_MS, +} from "./QuitHold.ts"; +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; + +function makeInput(overrides: Partial): QuitHoldKeyInput { + return { + type: "keyDown", + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + ...overrides, + }; +} + +function makeHarness(options?: { + enabled?: boolean; + platform?: NodeJS.Platform; + isEnabled?: () => Promise; +}) { + const notifications: Array = []; + const quit = vi.fn(); + const handler = makeQuitHoldHandler({ + platform: options?.platform ?? "darwin", + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), + notify: (state) => notifications.push(state), + quit, + }); + const preventDefault = vi.fn(); + const send = async (input: QuitHoldKeyInput) => { + handler({ preventDefault }, input); + // Let the isEnabled promise settle. + await Promise.resolve(); + await Promise.resolve(); + }; + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. + const holdFor = async ( + durationMs: number, + repeatOverrides: Partial = {}, + intervalMs = 100, + ) => { + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { + vi.advanceTimersByTime(intervalMs); + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); + } + }; + return { notifications, quit, preventDefault, send, holdFor }; +} + +describe("makeQuitHoldHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may + // produce no keyUp at all. Quit must still not fire. + const harness = makeHarness(); + await harness.send(makeInput({})); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual(["down"]); + + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).not.toHaveBeenCalled(); + // The watchdog dismisses the hint once the press is clearly over. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("quits once the shortcut auto-repeats past the hold duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.holdFor(400); + expect(harness.quit).toHaveBeenCalledTimes(1); + // Exactly one hint cycle for the whole hold. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("does not quit when the hold stops before the duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("cancels the hold when the modifier is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("quits immediately on a single press when disabled", async () => { + const harness = makeHarness({ enabled: false }); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + // The hint is dismissed in case the quit gets cancelled downstream. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("discards a stale isEnabled resolution from a superseded press", async () => { + // Press #1's isEnabled is still pending when the user releases and + // presses again; its late resolution must not act for press #2. + const resolvers: Array<(enabled: boolean) => void> = []; + const harness = makeHarness({ + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + // Outside the double-tap window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(resolvers).toHaveLength(2); + + // Press #1 resolves late with "disabled" — it must not quit press #2. + resolvers[0]?.(false); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.quit).not.toHaveBeenCalled(); + + // Press #2 resolves enabled and completes a full hold. + resolvers[1]?.(true); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("quits on a quick double tap, even when the first release was never seen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("treats two slow taps as separate presses", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("cancels the hold when another key interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual(["down", "up"]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("does not count an interrupted press toward a double tap", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new hold, not a + // double-tap quit. + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts new file mode 100644 index 000000000000..ea2fc7854ac5 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. + +// Chrome-style hold-to-quit. The quit accelerator is intercepted in +// before-input-event (which runs before the native menu accelerator), and the +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application +// menu itself is untouched and quits immediately. +export const QUIT_HOLD_DURATION_MS = 1200; +// A second quick tap of the shortcut is the user insisting: quit immediately. +export const QUIT_DOUBLE_TAP_MS = 500; +// "Still held" is proven by auto-repeat keydowns, not by the absence of a +// release: macOS suppresses a letter's keyUp while the command key is down, so +// a tap's release can go completely unseen and a release-based timer would +// quit anyway. The press is treated as released once no key event has arrived +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; + +export type QuitHoldState = "down" | "up"; + +export interface QuitHoldKeyInput { + readonly type: string; + readonly key: string; + readonly meta: boolean; + readonly control: boolean; + readonly alt: boolean; + readonly shift: boolean; + readonly isAutoRepeat: boolean; +} + +export interface QuitHoldOptions { + readonly platform: NodeJS.Platform; + readonly isEnabled: () => Promise; + readonly notify: (state: QuitHoldState) => void; + readonly quit: () => void; +} + +export function makeQuitHoldHandler( + options: QuitHoldOptions, +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { + const modifierKey = options.platform === "darwin" ? "meta" : "control"; + let watchdog: NodeJS.Timeout | undefined; + let holding = false; + // Set once isEnabled resolves true; auto-repeats may only quit when armed. + let armed = false; + let heldSince = 0; + let lastPressAt = 0; + // Incremented on every new press and every release/quit so a pending + // isEnabled() resolution from a superseded press cannot arm (or quit for) + // the current one. + let generation = 0; + + const clearWatchdog = () => { + if (watchdog !== undefined) { + clearTimeout(watchdog); + watchdog = undefined; + } + }; + + const release = () => { + if (!holding) return; + generation += 1; + holding = false; + armed = false; + clearWatchdog(); + options.notify("up"); + }; + + // Dismisses the overlay first: if the quit is cancelled downstream the + // renderer must not be left with a stuck "Hold to Quit" hint. + const quitNow = () => { + release(); + options.quit(); + }; + + return (event, input) => { + const key = input.key.toLowerCase(); + if (input.type === "keyUp") { + if (key === "q" || key === modifierKey) release(); + return; + } + if (input.type !== "keyDown") return; + + const modifierDown = options.platform === "darwin" ? input.meta : input.control; + if (!modifierDown || input.alt || input.shift || key !== "q") { + // Any other key (or an extra modifier) pressed mid-hold breaks the + // gesture; without this the hold timer keeps running through the + // interruption and the next qualifying repeat would quit early. The + // interrupted press also stops counting toward a double tap — but only + // here, not in release(), which runs mid-restart on an unseen-release + // re-press and must not wipe that press's own tap timestamp. + if (holding && !input.isAutoRepeat) { + lastPressAt = 0; + release(); + } + return; + } + + event.preventDefault(); + + if (input.isAutoRepeat) { + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + quitNow(); + } + return; + } + + const now = Date.now(); + const previousPressAt = lastPressAt; + lastPressAt = now; + // A fresh keydown while "holding" means the key came back down after a + // release macOS never delivered — so both branches below see real taps. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + quitNow(); + return; + } + if (holding) release(); + + generation += 1; + const pressGeneration = generation; + holding = true; + heldSince = now; + options.notify("down"); + void options.isEnabled().then( + (enabled) => { + if (generation !== pressGeneration) return; + if (!enabled) { + // Hold-to-quit disabled: a single press quits immediately. + quitNow(); + return; + } + armed = true; + // No auto-repeat by then means the key was released (possibly with a + // suppressed keyUp) or repeat is disabled; either way, don't quit. + watchdog = setTimeout(() => { + watchdog = undefined; + release(); + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + }, + // A failed settings read must never strand the quit request. + () => { + if (generation !== pressGeneration) return; + quitNow(); + }, + ); + }; +} diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e4..791004b74fad 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); + expect(children).toHaveLength(4); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa9..857125c9fdaf 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx new file mode 100644 index 000000000000..29c044015212 --- /dev/null +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +import { isMacPlatform } from "../lib/utils"; + +// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint +// from a quick tap lingers for as long as a full hold would have taken. +const HIDE_AFTER_RELEASE_MS = 1200; + +/** + * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts + * the quit accelerator and pushes press/release states; a quick tap shows + * this pill while a full hold quits the app. + */ +export function QuitHoldOverlay() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const subscribe = window.desktopBridge?.onQuitShortcut; + if (!subscribe) return; + let hideTimer: number | undefined; + const unsubscribe = subscribe((state) => { + window.clearTimeout(hideTimer); + if (state === "down") { + setVisible(true); + return; + } + hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + }); + return () => { + window.clearTimeout(hideTimer); + unsubscribe(); + }; + }, []); + + if (!visible) return null; + const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + return ( +
    +
    + Hold {shortcut} to Quit +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9df7f88ab1dd..d57a4da1c2f0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Hold to quit" + /> + } + /> + ) : null} + { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e0fc3d2f07e9..e3aef6705665 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -12,6 +14,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Delete confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Hold to quit", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -236,5 +247,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602a..3341c0bb062f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1080,6 +1080,12 @@ export interface DesktopBridge { */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + /** + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, + * "up" when it is released before the hold completes. Optional: older + * desktop builds never emit it. + */ + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ee1970639adf..22ce210ed898 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the + // app quits; a quick tap only shows a hint. Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From d94fbda344398bd266b9f6645e531eb17d3c9e4e Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 15 Aug 2026 14:37:02 +0300 Subject: [PATCH 062/144] fix(gitlab): submit review comments on context lines (#6348) --- .../BitbucketPullRequestApi.test.ts | 8 +- .../pullRequest/BitbucketPullRequestApi.ts | 16 +- .../pullRequest/GitHubPullRequestCli.test.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 9 +- .../src/pullRequest/GitLabPullRequestCli.ts | 21 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../pullRequest/gitHubPullRequestJson.test.ts | 12 +- .../src/pullRequest/gitHubPullRequestJson.ts | 20 +- .../pullRequest/PullRequestCodeTab.tsx | 45 +++- .../pullRequestReviewStore.test.ts | 2 +- .../pullRequest/pullRequestReviewStore.ts | 11 +- apps/web/src/reviewCommentContext.ts | 221 ++++++++++++++++-- packages/contracts/src/pullRequest.ts | 23 +- 13 files changed, 338 insertions(+), 54 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 4120cf55e622..8945ecc5e1e2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => { number: 7, verdict: "request-changes", body: "Two things.", - comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + comments: [ + { + path: "src/a.ts", + position: { kind: "deleted", oldLine: 12 }, + body: "why remove?", + }, + ], }); expect(callAt(0).url).toContain("/pullrequests/7/comments"); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a2c57bfc5fdb..5b3149b0d75c 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -12,6 +12,7 @@ import type { PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { } } +function bitbucketReviewPosition( + position: PullRequestReviewPosition, +): { readonly from: number } | { readonly to: number } { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; @@ -794,7 +808,7 @@ export const make = Effect.gen(function* () { content: { raw: comment.body }, inline: { path: comment.path, - ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + ...bitbucketReviewPosition(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 848c4cd5ebc3..d1af03db9d7e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, verdict: "approve", body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); expect(callAt(0).args).toEqual([ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index c33e01c2d721..014d91a02740 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => { verdict: "approve", body: "Looks right.", comments: [ - { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + { + path: "src/b.ts", + oldPath: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + body: "why remove?", + }, ], }); @@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17c23bf86f48..9f968dddbbc8 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -14,6 +14,7 @@ import type { PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -400,6 +401,22 @@ function projectPath(repository: string): string { return encodeURIComponent(repository.trim()); } +function gitLabReviewPositionLines( + position: PullRequestReviewPosition, +): + | { readonly new_line: number } + | { readonly old_line: number } + | { readonly old_line: number; readonly new_line: number } { + switch (position.kind) { + case "added": + return { new_line: position.newLine }; + case "deleted": + return { old_line: position.oldLine }; + case "context": + return { old_line: position.oldLine, new_line: position.newLine }; + } +} + function stateParam(state: PullRequestListState): string { // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, // and it spans every state under `all`. @@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () { // draft carries the name the file had before the change. old_path: comment.oldPath ?? comment.path, new_path: comment.path, - ...(comment.side === "left" - ? { old_line: comment.line } - : { new_line: comment.line }), + ...gitLabReviewPositionLines(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 243cfe06c21d..456a5023b16d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1534,7 +1534,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => number: 1, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index a3c3524a6d38..946394dcda8d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1147,8 +1147,16 @@ describe("review submission payload", () => { verdict: "request-changes", body: "Two things.", comments: [ - { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, - { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, ], }), ) as Record; diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e113b87d81da..7b9ff9d41fe6 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -17,6 +17,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, @@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St } }`; +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ export function buildReviewSubmissionJson(input: { readonly verdict: PullRequestReviewVerdict; @@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: { body: input.body, comments: input.comments.map((comment) => ({ path: comment.path, - line: comment.line, - side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + ...gitHubReviewPosition(comment.position), body: comment.body, })), }); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 71f3ffc7f378..776a4d671368 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, } from "@t3tools/contracts"; import { @@ -43,7 +44,11 @@ import { } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; -import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -129,8 +134,7 @@ interface DraftAnchor { readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; - readonly line: number; - readonly side: PullRequestDiffSide; + readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } -function fromViewerSide(side: string | undefined): PullRequestDiffSide { - return side === "deletions" ? "left" : "right"; +function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } } /** @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; - groupAt(comment.side, comment.line).pending.push(comment); + const anchor = getReviewPositionAnchor(comment.position); + groupAt(anchor.side, anchor.line).pending.push(comment); } } - if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + if (draft?.fileKey === fileKey) { + const anchor = getReviewPositionAnchor(draft.position); + groupAt(anchor.side, anchor.line).draft = true; + } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - line: range.end, - side: fromViewerSide(range.endSide ?? range.side), + position, range, }); }, @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ {annotation.metadata.draft && draft ? ( { diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 8e207c2529b8..41906a710fc8 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,17 +6,10 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; +import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; -export interface PendingReviewComment { - readonly id: string; - readonly path: string; - /** The line in the file the comment's side names: the new file on the right, the old on the left. */ - readonly line: number; - readonly side: PullRequestDiffSide; - readonly body: string; -} +export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; /** * A counter rather than anything derived from the comment: two remarks on one line can be the diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 7ce319973511..41f75eb384f1 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -1,6 +1,15 @@ import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; +import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +const ReviewCommentSelectionSchema = Schema.Struct({ + start: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + end: Schema.Number, + endSide: Schema.Literals(["additions", "deletions"]), +}); +type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; + export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String), + selection: Schema.optional(ReviewCommentSelectionSchema), }); export interface ReviewCommentContext { @@ -25,6 +35,7 @@ export interface ReviewCommentContext { readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined; + readonly selection?: ReviewCommentSelection | undefined; } interface DiffReviewLine { @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } -function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray { +function buildDiffReviewLines( + fileDiff: FileDiffMetadata, + includeExpandedContext: boolean, + slice?: { readonly startIndex: number; readonly endIndex: number }, +): ReadonlyArray { const rows: DiffReviewLine[] = []; + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const pushRow = (row: DiffReviewLine) => { + if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { + rows.push(row); + } + rowIndex += 1; + }; + const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; + const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; + for (let offset = firstOffset; offset <= lastOffset; offset += 1) { + rows.push({ + change: "context", + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), + }); + } + rowIndex += count; + }; for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); + pushContextGap(oldContextStart, newContextStart, contextLines); + } + let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray, + fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, + includeExpandedContext = !fileDiff.isPartial, ): number { - const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; - const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); - if (preferredIndex >= 0) return preferredIndex; - const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; - return lines.findIndex((line) => line[fallbackKey] === lineNumber); + const findOnSide = (selectedSide: "left" | "right") => { + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const selectedStart = selectedSide === "left" ? oldStart : newStart; + const offset = lineNumber - selectedStart; + return offset >= 0 && offset < count ? rowIndex + offset : -1; + }; + + for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min( + oldContextEnd - oldContextStart, + newContextEnd - newContextStart, + ); + const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); + if (contextIndex >= 0) return contextIndex; + rowIndex += Math.max(0, contextLines); + } + + let oldLineNumber = hunk.deletionStart; + let newLineNumber = hunk.additionStart; + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); + if (contextIndex >= 0) return contextIndex; + rowIndex += segment.lines; + oldLineNumber += segment.lines; + newLineNumber += segment.lines; + continue; + } + + if ( + selectedSide === "left" && + lineNumber >= oldLineNumber && + lineNumber < oldLineNumber + segment.deletions + ) { + return rowIndex + lineNumber - oldLineNumber; + } + rowIndex += segment.deletions; + oldLineNumber += segment.deletions; + + if ( + selectedSide === "right" && + lineNumber >= newLineNumber && + lineNumber < newLineNumber + segment.additions + ) { + return rowIndex + lineNumber - newLineNumber; + } + rowIndex += segment.additions; + newLineNumber += segment.additions; + } + + oldContextStart = hunk.deletionStart + hunk.deletionCount; + newContextStart = hunk.additionStart + hunk.additionCount; + if (hunk.deletionCount === 0) oldContextStart += 1; + if (hunk.additionCount === 0) newContextStart += 1; + } + + if (!includeExpandedContext) return -1; + const trailingLines = Math.min( + fileDiff.deletionLines.length - oldContextStart + 1, + fileDiff.additionLines.length - newContextStart + 1, + ); + return findContextIndex(oldContextStart, newContextStart, trailingLines); + }; + + const selectedSide = side === "deletions" ? "left" : "right"; + const preferredIndex = findOnSide(selectedSide); + return preferredIndex >= 0 + ? preferredIndex + : findOnSide(selectedSide === "left" ? "right" : "left"); +} + +/** Resolve the host-facing coordinates of a line selected in the diff viewer. */ +export function resolveDiffReviewPosition( + fileDiff: FileDiffMetadata, + lineNumber: number, + side: SelectionSide | undefined, +): PullRequestReviewPosition | null { + const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side); + if (lineIndex < 0) return null; + const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, { + startIndex: lineIndex, + endIndex: lineIndex, + })[0]; + if (line === undefined) return null; + + switch (line.change) { + case "add": + return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber }; + case "delete": + return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber }; + case "context": + return line.oldLineNumber === null || line.newLineNumber === null + ? null + : { + kind: "context", + oldLine: line.oldLineNumber, + newLine: line.newLineNumber, + side: side === "deletions" ? "left" : "right", + }; + } } function getDiffRange( @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { range: SelectedLineRange; text: string; }): ReviewCommentContext | null { - const lines = buildDiffReviewLines(input.fileDiff); - const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); + const includeExpandedContext = !input.fileDiff.isPartial; + const startIndex = findDiffReviewLineIndex( + input.fileDiff, + input.range.start, + input.range.side, + includeExpandedContext, + ); const endIndex = findDiffReviewLineIndex( - lines, + input.fileDiff, input.range.end, input.range.endSide ?? input.range.side, + includeExpandedContext, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); - const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); + const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, { + startIndex: normalizedStartIndex, + endIndex: normalizedEndIndex, + }); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", + selection: { + start: input.range.start, + side: input.range.side ?? "additions", + end: input.range.end, + endSide: input.range.endSide ?? input.range.side ?? "additions", + }, }; } diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d1b2ba705f5e..dea49ea8fa59 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; +/** The coordinates of one line in a pull request diff. */ +export const PullRequestReviewPosition = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("added"), + newLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("deleted"), + oldLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("context"), + oldLine: PositiveInt, + newLine: PositiveInt, + /** Which copy of an unchanged line the reviewer selected in a split diff. */ + side: PullRequestDiffSide, + }), +]); +export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; + /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ * the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), - line: PositiveInt, - side: PullRequestDiffSide, + position: PullRequestReviewPosition, body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; From db3278f97721f89b8b11a28bf59e59ce1fb68598 Mon Sep 17 00:00:00 2001 From: Nicolas Layne <49288482+NicL9923@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:39:05 -0500 Subject: [PATCH 063/144] fix(marketing): keep Grok mark clear of mobile hero copy (#4542) --- apps/marketing/src/pages/index.astro | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 20fae288279c..4d43fc595c0b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -704,7 +704,7 @@ const mobileEndorsementRows = [ height: 44px; } - /* Three stacked on the left, two on the right — keeps the center CTA clear. */ + /* Three above the headline, two beside the CTA — keeps the copy clear. */ .hero-float-mark.hf-claude { top: 44px; left: 10px; @@ -712,8 +712,8 @@ const mobileEndorsementRows = [ } .hero-float-mark.hf-grok { - top: 240px; - left: 4px; + top: 44px; + left: calc(50% - 39px); right: auto; transform: rotate(-4deg); } @@ -741,8 +741,8 @@ const mobileEndorsementRows = [ @media (max-width: 340px) { .hero-float-mark.hf-grok { - top: 220px; - left: 0; + top: 57px; + left: calc(50% - 26px); width: 52px; height: 52px; border-radius: 14px; From 3bc4fdf05b6b748a7b506c81dc125f3504f35278 Mon Sep 17 00:00:00 2001 From: JJ <93147993+hey-jj@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:41:51 -0600 Subject: [PATCH 064/144] fix(mobile): recover the QR pairing scanner when camera access is denied (#6487) --- .../connection/ConnectionsNewRouteScreen.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 37d53cbd8eea..7fa3c691b447 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -95,9 +95,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); From a38cac81d82b82a6967eaf8cb90ed2770c514f3c Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Sat, 15 Aug 2026 13:42:08 +0200 Subject: [PATCH 065/144] fix(web): keep a long path from running under the folder picker button (#4823) Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge Co-authored-by: codex --- .../components/CommandPalette.logic.test.ts | 30 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 13 ++++++++ apps/web/src/components/CommandPalette.tsx | 12 +++++--- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 06dabc5e8490..17949b7c97cb 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -10,6 +11,35 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("browseInputEndPaddingClass", () => { + it("reserves the widest space for the create action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: true, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-38"); + }); + + it("reserves space for the wider highlighted-item shortcut", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: true, + }), + ).toContain("pe-30"); + }); + + it("keeps the compact reserve for the normal add action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-24"); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d84e..95d7a91b7805 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +export function browseInputEndPaddingClass(input: { + readonly willCreateProjectPath: boolean; + readonly hasHighlightedBrowseItem: boolean; +}): string { + if (input.willCreateProjectPath) { + return "*:data-[slot=autocomplete-input]:pe-38!"; + } + if (input.hasHighlightedBrowseItem) { + return "*:data-[slot=autocomplete-input]:pe-30!"; + } + return "*:data-[slot=autocomplete-input]:pe-24!"; +} + /** * The global search overlay hosts three mutually exclusive surfaces: the * command palette (⌘K), the project file picker (⌘P), and project content diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48471accb995..413ebca305f9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -98,6 +98,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -2345,13 +2346,16 @@ function OpenCommandPaletteDialog(props: { footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ + // The submit button is absolutely positioned over the field, so the + // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "pe-32" + ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing - ? willCreateProjectPath - ? "pe-36" - : "pe-16" + ? browseInputEndPaddingClass({ + willCreateProjectPath, + hasHighlightedBrowseItem, + }) : undefined, placeholder: inputPlaceholder, wrapperClassName: isSubmenu From 270489b887420db3319898ab4046516e4c457711 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:44:37 +0200 Subject: [PATCH 066/144] fix(terminal): right-click paste works in the terminal (#5240) --- .../src/components/ThreadTerminalDrawer.tsx | 189 +++++++++++++++--- apps/web/src/hooks/useCopyToClipboard.ts | 44 ++++ apps/web/src/terminal/ghostty/surface.ts | 30 +++ 3 files changed, 230 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e94..cf2adaca2cf4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; -import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: { }; } +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +/** Post-selection popup: just the two selection actions, always enabled. */ +export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { + return [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ]; +} + +/** + * Right-click menu for the terminal canvas: the selection actions (disabled + * until a selection exists) plus Paste. Paste is always offered: the browser + * (and Electron's default editing menu) can only paste into an editable + * element, so a canvas terminal never gets a usable entry from them. + */ +export function terminalContextMenuItems(options: { + hasSelection: boolean; +}): ContextMenuItem[] { + return [ + ...terminalSelectionMenuItems().map((item) => ({ + ...item, + disabled: !options.hasSelection, + })), + { id: "paste", label: "Paste" }, + ]; +} + +/** + * An empty selection change may only cancel a selection-action flow that is + * still current: a pending popup timer, or an open popup whose request id has + * not been superseded. A popup already superseded by a right-click keeps its + * menu promise unsettled for a moment; treating it as active would cancel the + * newer context-menu flow instead. + */ +export function shouldClearTerminalSelectionAction(options: { + timerPending: boolean; + openMenuRequestId: number | null; + currentRequestId: number; +}): boolean { + return options.timerPending || options.openMenuRequestId === options.currentRequestId; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -328,7 +372,10 @@ export function TerminalViewport({ const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); - const selectionActionMenuOpenRef = useRef(false); + // Holds the request id of the selection popup currently on screen, so a + // popup that was superseded (but whose menu promise has not settled yet) + // cannot be mistaken for the active flow. + const openSelectionMenuRequestIdRef = useRef(null); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); @@ -443,6 +490,12 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + // The surface listens from construction, so a right-click can land + // while `create` is still awaiting WASM — before the handler below it + // exists. The ref is only assigned once that setup has run. + onContextMenu: (event) => { + if (terminalRef.current) void showTerminalContextMenu(event); + }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); if (cancelled) { @@ -518,12 +571,98 @@ export function TerminalViewport({ }; }; + const addSelectionToChat = (selection: TerminalContextSelection) => { + handleAddTerminalContext(selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + }; + + // A selection-action flow that was superseded while its async work ran + // must go silent: no error message, no focus steal. + const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { + if (requestId !== selectionActionRequestIdRef.current) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback); + } + }; + + const focusIfCurrent = (requestId: number) => { + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); + } + }; + + const copySelection = async (text: string, requestId: number) => { + try { + await writeTextToClipboard(text, "terminal selection"); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to copy terminal selection"); + } + focusIfCurrent(requestId); + }; + + const pasteFromClipboard = async (requestId: number) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + try { + // The surface owns the read so it can claim the paste race before it + // starts: a paste shortcut fired while the menu read is in flight + // supersedes this paste instead of landing alongside it. + await activeTerminal.pasteFromClipboard( + () => readTextFromClipboard("terminal input"), + () => requestId === selectionActionRequestIdRef.current, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to read the clipboard"); + return; + } + focusIfCurrent(requestId); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi || !terminalRef.current) return; + // Own the gesture before anything async: leaving the default alive lets + // the browser (or Electron's editing menu) answer with a Paste entry + // that is permanently disabled over the terminal canvas. + event.preventDefault(); + // A right-click supersedes a selection popup that is pending or open. + clearSelectionAction(); + const selectionAction = readSelectionAction(); + const requestId = selectionActionRequestIdRef.current; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu.show( + terminalContextMenuItems({ hasSelection: selectionAction !== null }), + { x: event.clientX, y: event.clientY }, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to open the terminal context menu"); + focusIfCurrent(requestId); + return; + } + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + if (selectionAction) addSelectionToChat(selectionAction.selection); + return; + case "copy": + if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); + return; + case "paste": + await pasteFromClipboard(requestId); + return; + } + }; + const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { return; } const nextAction = readSelectionAction(); @@ -532,45 +671,23 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; + openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) + .show(terminalSelectionMenuItems(), nextAction.position) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + addSelectionToChat(nextAction.selection); return; case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } + await copySelection(nextAction.clipboardText, requestId); return; } }; @@ -684,11 +801,17 @@ export function TerminalViewport({ if (terminalRef.current?.hasSelection()) { return; } + const shouldClear = shouldClearTerminalSelectionAction({ + timerPending: selectionActionTimerRef.current !== null, + openMenuRequestId: openSelectionMenuRequestIdRef.current, + currentRequestId: selectionActionRequestIdRef.current, + }); + if (!shouldClear) return; clearSelectionAction(); // A copy shortcut that clears the selection (Ctrl+C) must also close // the context menu that appears with the selection, but a clear that // never opened a menu must not dismiss an unrelated one. - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { void localApi?.contextMenu.close(); } } diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593d..ef66410f7db4 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0bb33875568f..2ac3c68d1586 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -801,6 +807,28 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { From 4db50757c0b618293997a7f81bcfe30b68356969 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Sat, 15 Aug 2026 13:00:18 +0100 Subject: [PATCH 067/144] fix(mobile): explain iOS-only settings on Android (#4981) --- .../SettingsRouteScreen.logic.test.ts | 19 +++++++++++++++++++ .../settings/SettingsRouteScreen.logic.ts | 8 ++++++++ .../features/settings/SettingsRouteScreen.tsx | 6 ++++++ .../settings/components/SettingsSwitchRow.tsx | 8 +++++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts new file mode 100644 index 000000000000..aec583d67f73 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; + +describe("resolveAgentAwarenessPlatformPresentation", () => { + it("explains that agent awareness settings are unavailable on Android", () => { + expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({ + supported: false, + subtitle: "iOS only", + }); + }); + + it("leaves supported iOS settings unchanged", () => { + expect(resolveAgentAwarenessPlatformPresentation("ios")).toEqual({ + supported: true, + subtitle: undefined, + }); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts new file mode 100644 index 000000000000..94fa5965e994 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -0,0 +1,8 @@ +export function resolveAgentAwarenessPlatformPresentation(platform: string): { + readonly supported: boolean; + readonly subtitle: string | undefined; +} { + return platform === "ios" + ? { supported: true, subtitle: undefined } + : { supported: false, subtitle: "iOS only" }; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index bcf2ce386d9c..b0e851b59d88 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -144,6 +145,7 @@ function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); + const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -473,10 +475,12 @@ function ConfiguredSettingsRouteScreen() { icon="bell.badge" label="Device Notifications" disabled={ + !agentAwarenessPlatform.supported || !agentAwarenessPushAvailable || notificationStatus === "checking" || notificationStatus === "unsupported" } + subtitle={agentAwarenessPlatform.subtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -487,6 +491,7 @@ function ConfiguredSettingsRouteScreen() { /> void; }) { @@ -27,7 +28,12 @@ export function SettingsSwitchRow(props: { } > - {props.label} + + {props.label} + {props.subtitle ? ( + {props.subtitle} + ) : null} + Date: Sat, 15 Aug 2026 17:30:51 +0530 Subject: [PATCH 068/144] fix(web): stop counting a workflow coordinator as a working agent (#6672) --- .../src/state/subagentRuntime.test.ts | 42 +++++++++++++++++-- .../src/state/subagentRuntime.ts | 11 ++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ceb40517550e..ff0aea7c8a51 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -383,13 +383,49 @@ describe("deriveAgentPanelModel", () => { it("counts idle deliberately and waiting as active", () => { const model = deriveAgentPanelModel({ agents: roster }); expect(model.idleCount).toBe(1); - // wf-1 coordinator + member 1 running. - expect(model.runningCount).toBeGreaterThanOrEqual(1); + // Member 1 is running; the wf-1 coordinator is a container, not a worker. + expect(model.runningCount).toBe(1); + // Every agent lands in exactly one bucket, except coordinators that stand + // in for their members. expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( - roster.length, + roster.length - 1, ); }); + it("omits a workflow coordinator from the working-agent count", () => { + const model = deriveAgentPanelModel({ agents: roster }); + // One member still running plus one idle direct spawn. The coordinator + // reports running for the whole workflow and must not inflate the banner. + expect(model.liveCount).toBe(1); + }); + + it("omits a finished workflow coordinator from the settled count", () => { + const finished = fold([ + activity("task.started", { taskId: "wf-2", taskType: "local_workflow", title: "sweep" }), + activity("task.progress", { + taskId: "wf-2:wf:0", + title: "sweep:a", + status: "completed", + parentAgentId: "wf-2", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { + taskId: "wf-2:wf:0", + status: "completed", + parentAgentId: "wf-2", + }), + activity("task.completed", { taskId: "wf-2", status: "completed" }), + ]); + + const model = deriveAgentPanelModel({ agents: finished }); + + // Only the member settled. The coordinator stands in for it, so counting + // both would report two finished agents where one ran. + expect(model.settledCount).toBe(1); + expect(model.liveCount).toBe(0); + }); + it("keeps direct spawns in first-seen order as their activity changes", () => { const directRoster = fold([ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e5f2b586b8c4..c1ea1cc2b15d 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -826,15 +826,16 @@ export function deriveAgentPanelModel({ let settledCount = 0; let totalTokens = 0; for (const agent of source) { + // A workflow coordinator with members is a container for those members, not + // work of its own: it reports running for the whole run and aggregates their + // usage upstream in some providers. Counting it would report one more agent + // working than there are, and double count tokens. + if (agent.kind === "workflow" && (members.get(agent.id) ?? []).length > 0) continue; if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; else if (agent.status === "idle") idleCount += 1; else settledCount += 1; - // Workflow coordinators aggregate member usage upstream in some providers; - // avoid double counting by only summing leaf agents when members exist. - if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { - totalTokens += agent.usage?.totalTokens ?? 0; - } + totalTokens += agent.usage?.totalTokens ?? 0; } return { From 6e6d1b49412d064ccbde7daae2e287f9b62efd7d Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:00:53 -0400 Subject: [PATCH 069/144] fix(web): keep floating preview anchored after panel closes (#6547) --- .../preview/ThreadPreviewMiniPlayer.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e7c46ef0e0a..2bdba1afe9e3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -17,6 +17,7 @@ import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, + PREVIEW_MINI_PLAYER_EDGE_GAP, } from "./previewMiniPlayerLayout"; interface DragState { @@ -31,6 +32,8 @@ interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; readonly width: number; readonly height: number; } @@ -45,6 +48,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); + const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -91,8 +95,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + if (!position) { + setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); + return; + } const next = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + position, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -159,11 +167,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; - if (!root) return; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, width: root.offsetWidth, height: root.offsetHeight, }; @@ -194,7 +207,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + { x: resize.playerX, y: resize.playerY }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -222,8 +235,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props position ? { left: position.x, top: position.y, width: size.width, height: size.height } : { - right: 16, - top: 16, + right: PREVIEW_MINI_PLAYER_EDGE_GAP, + top: PREVIEW_MINI_PLAYER_EDGE_GAP, width: size.width, height: size.height, } @@ -290,7 +303,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props visible={Boolean(desktopOverlay?.hasWebContents)} cornerRadius={12} fitSourceContent - layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} + layoutVersion={ + position + ? `${position.x}:${position.y}` + : `initial:${bottomInset}:${defaultLayoutVersion}` + } className="absolute inset-0" />
    From a7c5ad5db167b3a172ccb26408b0638c99b2a459 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:01:08 +0200 Subject: [PATCH 070/144] fix(web): unstick /connect after in-modal sign-in by redirecting to the authorize endpoint (#5133) --- apps/web/src/cloud/connectCliAuth.test.ts | 24 +++++++++++++++ apps/web/src/cloud/connectCliAuth.ts | 17 +++++++++++ .../src/components/clerk/authRedirect.test.ts | 5 +++- apps/web/src/components/clerk/authRedirect.ts | 4 ++- .../cloud/ConnectCliAuthSurface.tsx | 29 +++++++++++++------ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 59b443a49d93..3d41c4166332 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, hasConnectCliAuthConfig, readConnectCliCallbackResult, } from "./connectCliAuth"; @@ -69,6 +70,29 @@ describe("connectCliAuth", () => { ).toBeNull(); }); + it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const redirectUrl = connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1" }, + connectUrl, + ); + + expect(redirectUrl).not.toBe(connectUrl); + expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); + }); + + it("falls back to the current URL when the authorize URL cannot be built", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + expect( + connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), + ).toBe(connectUrl); + }); + it("reads the code and state Clerk echoes back to the callback", () => { expect( readConnectCliCallbackResult( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 969215d97ad3..815715da2499 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -60,6 +60,23 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques }); } +/** + * Where Clerk sends the browser once the sign-in modal on /connect completes. + * It has to be the authorize endpoint rather than this page: /connect carries + * the CLI request in its fragment, so navigating back to the same URL is a + * same-document fragment navigation the browser never reloads — and Clerk + * treats any post-sign-in navigation as a page unload and skips the state emit + * that would otherwise re-render the surface, so the session never arrives + * either. Falls back to the current URL when the authorize URL cannot be + * built, which only happens on a deployment without the CLI OAuth config. + */ +export function connectCliSignInRedirectUrl( + request: ConnectAuthorizeRequest, + currentHref: string, +): string { + return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; +} + export function rememberConnectCliAuthState(state: string): void { try { window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120cca..e948d1d9c049 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee36502..e0b07241c068 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index e47d8ddf7f7c..5d5c280bb81c 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
    -
    From 7afa184a99b266d466cc9517c147a75c3d839ad7 Mon Sep 17 00:00:00 2001 From: BootesVoid <78485654+AMohamedAakhil@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:31:11 +0530 Subject: [PATCH 071/144] fix(web): keep send reachable while a turn is running on mobile (#4781) Co-authored-by: AMohamedAakhil Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 3 ++ ...est.ts => ComposerPrimaryActions.test.tsx} | 45 +++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 27 ++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) rename apps/web/src/components/chat/{ComposerPrimaryActions.test.ts => ComposerPrimaryActions.test.tsx} (80%) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5072c5870a73..afba1e086b84 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx similarity index 80% rename from apps/web/src/components/chat/ComposerPrimaryActions.test.ts rename to apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 3dbcd39e9d13..c48f029f7f9b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -65,6 +65,28 @@ function renderStandaloneStop() { ); } +function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: hasSendableContent, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent, + showSendWhileRunning, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + function renderSendButton() { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -215,4 +237,27 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); expect(markup).toContain("bg-message-action text-message-action-foreground"); }); + + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderRunningActions(false, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderRunningActions(true, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("size-9 sm:size-8"); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderRunningActions(true, false); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d8626496ae7d..2a27796d92a5 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", - insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", + insidePendingAction + ? "size-8 sm:size-7" + : showSendWhileRunning && hasSendableContent + ? "size-9 sm:size-8" + : "size-8 sm:h-8 sm:w-8", )} {...pointerFocusProps} onClick={onInterrupt} @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return renderStopGenerationButton(false); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = (
    ) : null} {terminalStatus ? ( @@ -867,6 +885,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -884,7 +905,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} @@ -1481,11 +1503,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - + ) : null} @@ -1542,7 +1572,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -1600,7 +1632,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 05b44dcb7327..df35cbd90e54 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,10 +1,14 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -65,14 +69,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); - const duplicateDriverCounts = useMemo(() => { - const counts = new Map(); - for (const entry of props.instanceEntries) { - counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); - } - return counts; - }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { @@ -143,8 +139,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries); const tooltip = isUnavailable ? describeUnavailableInstance(entry) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index a9b3a398115b..bd374a0fd6f5 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -16,7 +16,7 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -67,10 +67,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; - const duplicateDriverCount = props.instanceEntries.filter( - (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, - ).length; - const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; + const showInstanceBadge = + activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0a..fd4ca7da92da 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -109,6 +109,23 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); } +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar rows. + */ +export function shouldShowInstanceBadge( + entry: ProviderInstanceEntry, + entries: Iterable, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} + export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; From c0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf Mon Sep 17 00:00:00 2001 From: Ostap <33957189+ostapondo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:06:53 +0200 Subject: [PATCH 093/144] fix(server): wait for concurrent SQLite writers instead of failing with SQLITE_BUSY (#5134) --- .../src/persistence/Layers/Sqlite.test.ts | 66 +++++++++++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 2 + 2 files changed, 68 insertions(+) create mode 100644 apps/server/src/persistence/Layers/Sqlite.test.ts diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts new file mode 100644 index 000000000000..0b64e4f7fdcb --- /dev/null +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; + +const lockHolderSource = ` +const { DatabaseSync } = require("node:sqlite"); +const db = new DatabaseSync(process.argv[1]); +db.exec("BEGIN IMMEDIATE"); +process.stdout.write("locked\\n"); +setTimeout(() => { + db.exec("COMMIT"); + db.close(); +}, Number(process.argv[2])); +`; + +const spawnWriteLockHolder = (dbPath: string, holdMs: number) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const holder = NodeChildProcess.spawn( + process.execPath, + ["-e", lockHolderSource, dbPath, String(holdMs)], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + holder.stdout.once("data", () => resolve()); + holder.on("error", reject); + holder.on("exit", () => + reject(new Error("lock holder exited before acquiring the write lock")), + ); + }), + ); + +it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; + yield* spawnWriteLockHolder(dbPath, 300); + yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; + const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; + assert.deepEqual([...rows], [{ id: 1 }]); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("applies busy_timeout in the shared persistence setup", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; + assert.equal(rows[0]?.timeout, 5000); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index d1e002501263..ec1ffdefac0f 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; yield* runMigrations(); From 7c55e86320aac9c68ae53a7bc15682b7e14f98bf Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 15 Aug 2026 17:07:07 +0500 Subject: [PATCH 094/144] fix(web): reject oversized prompts before provider turn start (#6602) --- apps/web/src/components/ChatView.tsx | 57 +++--- apps/web/src/components/chat/ChatComposer.tsx | 67 ++++++- .../ComposerPromptLengthValidation.test.tsx | 23 +++ .../chat/ComposerPromptLengthValidation.tsx | 13 ++ .../chat/composerSubmission.test.ts | 170 ++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 44 +++++ docs/user/composer.md | 5 + 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.tsx create mode 100644 apps/web/src/components/chat/composerSubmission.test.ts create mode 100644 apps/web/src/components/chat/composerSubmission.ts create mode 100644 docs/user/composer.md diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5bde6345c0..cb79f1f7e215 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5a01c5c76435..293767a7390d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { + getComposerPromptLengthValidationMessage, + getComposerSubmissionValidationMessage, + submitComposerDraft, +} from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; type ComposerCommandMenuPosition = { bottom: number; @@ -488,6 +494,8 @@ export interface ChatComposerHandle { selectedModel: string; selectedProviderModels: ReadonlyArray; }; + /** Validate the fully composed text immediately before a provider turn starts. */ + validateProviderInput: (providerInput: string) => boolean; } // -------------------------------------------------------------------------- @@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSubmissionError, setComposerSubmissionError] = useState(null); + const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( + null, + ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); + const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); + useEffect(() => { + if (composerSubmissionError === null) return; + const nextError = getComposerPromptLengthValidationMessage(prompt); + if (nextError !== composerSubmissionError) { + setComposerSubmissionError(nextError); + } + }, [composerSubmissionError, prompt]); + + useEffect(() => { + setProviderInputSubmissionError(null); + }, [ + composerElementContexts, + composerPreviewAnnotations, + composerReviewComments, + composerTerminalContexts, + prompt, + selectedModel, + selectedPromptEffort, + selectedProvider, + ]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerSubmissionError(null); + setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); @@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - onSend(event); + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend(sendEvent); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, [ activeThreadId, + activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, noProviderAvailable, onSend, + promptRef, shouldBlurMobileComposerOnSubmit, ], ); @@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProviderModels, }), + validateProviderInput: (providerInput: string) => { + const validationMessage = getComposerSubmissionValidationMessage({ + prompt: promptRef.current, + providerInput, + submissionTarget: "provider-turn", + }); + providerInputRejectedRef.current = validationMessage !== null; + setProviderInputSubmissionError(validationMessage); + return validationMessage === null; + }, }), [ activeThread, @@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
+ + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx new file mode 100644 index 000000000000..3ffb4fa9c20a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx @@ -0,0 +1,23 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; + +describe("ComposerPromptLengthValidation", () => { + it("renders oversized prompt feedback as an actionable composer alert", () => { + const message = getComposerPromptLengthValidationMessage( + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + ); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('data-chat-composer-validation="prompt-length"'); + expect(markup).toContain( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(markup).not.toContain("ProviderValidationError"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx new file mode 100644 index 000000000000..88e4c3b813eb --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx @@ -0,0 +1,13 @@ +export function ComposerPromptLengthValidation({ message }: { message: string | null }) { + if (!message) return null; + + return ( +

+ {message} +

+ ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 000000000000..239db28a6002 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,170 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => { + dispatchedDrafts.push(draft); + }, + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("blocks when appended context pushes the provider input over the shared limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 000000000000..528ac75bcabe --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,44 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`; +} + +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => boolean | void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } + return { validationMessage: null, didDispatch: true }; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 000000000000..d2e49db247b0 --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. From 40ab7bf32a81a66b20571ed280dc238c2276dc61 Mon Sep 17 00:00:00 2001 From: Jaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:09 +0200 Subject: [PATCH 095/144] feat(web): collapse the question prompt from its header (#6773) Co-authored-by: Claude Opus 5 --- .../ComposerPendingUserInputPanel.test.tsx | 61 ++++++ .../chat/ComposerPendingUserInputPanel.tsx | 196 +++++++++++------- 2 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx new file mode 100644 index 000000000000..817182190b79 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -0,0 +1,61 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import type { PendingUserInput } from "../../session-logic"; + +const prompt: PendingUserInput = { + requestId: ApprovalRequestId.make("request-1"), + createdAt: "2026-08-15T00:00:00.000Z", + questions: [ + { + id: "question-1", + header: "Approach", + question: "Which approach should the migration take?", + options: [ + { label: "Incremental", description: "Move one module at a time" }, + { label: "Big bang", description: "Move everything in one release" }, + ], + multiSelect: false, + }, + ], +}; + +function renderPanel() { + return renderToStaticMarkup( + {}} + onAdvance={() => {}} + />, + ); +} + +describe("ComposerPendingUserInputPanel", () => { + it("renders the header as a disclosure control for the question body", () => { + const markup = renderPanel(); + + const toggle = markup.match(/]*data-pending-user-input-toggle="[^"]*"[^>]*>/)?.[0]; + expect(toggle).toBeDefined(); + expect(toggle).toContain('data-pending-user-input-toggle="expanded"'); + expect(toggle).toContain('aria-expanded="true"'); + expect(toggle).toContain('type="button"'); + + const controlledId = toggle?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controlledId).toBeDefined(); + expect(markup).toMatch(new RegExp(`]*\\sid="${controlledId}"`)); + }); + + it("starts expanded so the question and its options are visible", () => { + const markup = renderPanel(); + + expect(markup).toContain("Approach"); + expect(markup).toContain("Which approach should the migration take?"); + expect(markup).toContain("Incremental"); + expect(markup).toContain("Big bang"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index ceac45c9411d..75dc5a6f5472 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -5,7 +5,8 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionId: string; optionLabel: string; } | null>(null); + // Collapsing hides everything but the header so a tall prompt stops covering + // the thread the user is trying to read. Scoped to a single question: the card + // is keyed by request id so the next prompt starts expanded, and storing the + // collapsed question's id (rather than a bare flag) reopens the card when the + // prompt advances to its next question, which can happen without a click — + // sending from the composer advances the active question. + const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); + const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; useEffect(() => { onAdvanceRef.current = onAdvance; @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- - // select prompts keep the existing auto-advance behavior. + // select prompts keep the existing auto-advance behavior. Collapsed prompts opt + // out, since the numbers they refer to are not on screen. useEffect(() => { - if (!activeQuestion || isResponding) return; + if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding]); + }, [activeQuestion, isCollapsed, isResponding]); if (!activeQuestion) { return null; @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const customAnswerActive = progress.customAnswer.trim().length > 0; return ( -
-
- - {activeQuestion.header} - - {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} + { + setCollapsedQuestionId(open ? null : activeQuestion.id); + }} + > + {/* The trigger's wrapper is inset less than the card's text column, and + the trigger pays the difference back as padding: the hover background + and focus ring bleed 10px past that column on both sides, while the + header label and the chevron still line up with the left and right + edges of the question text below. The negative block margin keeps the + taller hit area from pushing the panel down. */} +
+ + + {activeQuestion.header} - ) : null} + {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + {/* Collapsed, the header is otherwise just a section label and a + counter, so the question itself is echoed here as a one-line + reminder of what is being asked. */} + {isCollapsed ? ( + + {activeQuestion.question} + + ) : null} + {/* The chevron points at the body: down while it is open below the + header, up while it is collapsed into it. */} +
-

{activeQuestion.question}

- {activeQuestion.multiSelect ? ( -

Select one or more options.

- ) : null} -
- {activeQuestion.options.map((option, index) => { - const isOptimisticallySelected = - optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; - const isSelected = - isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); - const shortcutKey = index < 9 ? index + 1 : null; - const className = cn( - "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", - isSelected - ? "border-primary/30 bg-primary/8 text-foreground" - : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", - ); - const content = ( - <> -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
- {isSelected ? ( - - ) : shortcutKey !== null ? ( - +
+

{activeQuestion.question}

+ {activeQuestion.multiSelect ? ( +

Select one or more options.

+ ) : null} +
+ {activeQuestion.options.map((option, index) => { + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = + isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + const shortcutKey = index < 9 ? index + 1 : null; + const className = cn( + "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", + isSelected + ? "border-primary/30 bg-primary/8 text-foreground" + : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", + isResponding && "opacity-50 cursor-not-allowed", + !isResponding && "cursor-pointer", + ); + const content = ( + <> +
+ {option.label} + {option.description && option.description !== option.label ? ( + {option.description} + ) : null} +
+ {isSelected ? ( + + ) : shortcutKey !== null ? ( + + {shortcutKey} + + ) : null} + + ); + return ( + - ); - })} -
-
+ {content} + + ); + })} +
+
+ + ); }); From 684d703b0a8a0632a18c8453277f7e5e6312b200 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:37:24 +0530 Subject: [PATCH 096/144] fix(shared): degrade an unknown system time zone to UTC in usage windows (#6670) --- packages/shared/src/usageFormat.test.ts | 18 ++++++++++++++++- packages/shared/src/usageFormat.ts | 26 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index cecc07c6e670..fb231fbacb20 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { enumerateHourStarts, @@ -54,4 +54,20 @@ describe("hourly usage formatting", () => { expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + + it("degrades an unknown resolved zone to UTC instead of crashing", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Etc/Unknown" }); + + try { + const now = new Date("2026-08-11T12:37:42.123Z"); + + expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); + expect(makeWindow(30, now).timeZone).toBe("UTC"); + } finally { + resolvedOptions.mockRestore(); + } + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index ef2b2bcf21a1..bd751829dd87 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -179,13 +179,25 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From ad47d2347c6917f7db33e6e3902e1e8e5d5281ec Mon Sep 17 00:00:00 2001 From: Roshan Mhatre Date: Sat, 15 Aug 2026 17:37:31 +0530 Subject: [PATCH 097/144] fix(claude): discover repo-local .agents/skills in skill discovery (#5488) --- .../src/provider/Drivers/ClaudeSkills.test.ts | 99 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 31 +++--- docs/user/providers-claude.md | 7 ++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 1ad843d7573e..60db1d0c5e26 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("discovers project skills from the workspace .agents directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "review", + ["---", "name: review", "description: Review the changes.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "review", + path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + description: "Review the changes.", + }, + ]); + }), + ); + + it.effect("prefers workspace .claude skills on three-way name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Claude deploy.", + }, + ]); + }), + ); + + it.effect("prefers workspace .agents skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Agents deploy.", + }, + ]); + }), + ); + it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681df..5c33fba0b9e9 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,12 +1,13 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope) and - * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the - * provider snapshot scans the same locations directly, mirroring how the - * Codex app-server reports its skills. + * Claude Code loads skills from `/skills` (user scope), then + * `/.agents/skills` and `/.claude/skills` (project scope), one + * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots + * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * The Agent SDK init handshake surfaces skills only as slash commands without + * their filesystem paths, so the provider snapshot scans the same locations + * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir, workspace + * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery + * is best-effort: unreadable roots and malformed skill entries are skipped so + * a broken skill never degrades the provider snapshot. On name collisions, + * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching + * Claude Code's resolution. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + ] + : []), ]; const skillsByName = new Map(); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40d..f9699388b7db 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Where Claude Skills Are Loaded + +T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then +`/.agents/skills`, then `/.claude/skills`. + +If the same skill name exists in more than one folder, the later folder wins. + ## I Want Work And Personal Claude Accounts Use a different Claude config directory for each account. From d715c2e56bb718d2225cc0f07cc65e6c637dc229 Mon Sep 17 00:00:00 2001 From: Carlos Jimenez Date: Sat, 15 Aug 2026 05:07:38 -0700 Subject: [PATCH 098/144] fix(server): let slow provider CLIs raise their discovery probe budget (#6223) Co-authored-by: Julius Marminge --- .../AzureDevOpsSourceControlProvider.ts | 4 ++++ .../SourceControlProviderDiscovery.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac9829275..2f147452f9ec 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -45,6 +45,10 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb205..b2b9e4513378 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -52,6 +53,14 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; @@ -167,7 +176,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(input.spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -244,7 +253,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +296,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) From d5465aebf2746b8d5f327be3b2424d9412a29075 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:51 +0200 Subject: [PATCH 099/144] fix(web): retain terminal PR badges after checkout switch (#4755) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 81 ++-- .../components/ThreadStatusIndicators.test.ts | 388 +++++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 174 ++++++++ 4 files changed, 618 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb79f1f7e215..d2d3e908c1cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -276,7 +276,10 @@ import { shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); - const activeThreadPr = resolveThreadPr({ + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadShell, autoSettleAfterDays, autoSettleOnMerge, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 016a4c682754..5ae583b66bb2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -143,10 +143,15 @@ import { import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { @@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + changeRequestSnapshot: ThreadChangeRequestSnapshot | null; + onChangeRequestSnapshot: ( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, + ) => void; }) { const { isRenaming, - onChangeRequestState, + changeRequestSnapshot, + onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) : null, ); - const pr = resolveThreadPr({ + const retainTerminalOnBranchMismatch = thread.worktreePath === null; + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prState = pr?.state ?? null; @@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state so the parent can apply the configured merge rule - // and the always-on close rule during partitioning. useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + const nextSnapshot = nextThreadChangeRequestSnapshot({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [ + changeRequestSnapshot, + gitStatus.data, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1858,26 +1889,7 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -1993,7 +2005,11 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const snapshot = changeRequestSnapshotByKey.get(threadKey); + const changeRequestState = + snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + ? snapshot.pr.state + : null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // and so does its pinOrderKey, so on wake the thread reappears at @@ -2051,7 +2067,7 @@ export default function Sidebar() { }, [ autoSettleAfterDays, autoSettleOnMerge, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -3686,7 +3702,8 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f710f1..f77959d9f42b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,19 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +39,25 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function mergedFeaturePr(): NonNullable { + return { + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "merged", + }; +} + +function snapshotFor( + branch: string, + pr: NonNullable, + sourceControlProvider?: VcsStatusResult["sourceControlProvider"], +): ThreadChangeRequestSnapshot { + return { branch, pr, sourceControlProvider }; +} + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( @@ -70,6 +98,362 @@ describe("resolveThreadPr", () => { }); }); +describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { + const featureBranch = "feature/current"; + const mergedPr = mergedFeaturePr(); + const provider = { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }; + + it("returns the live merged PR when the checkout matches the feature branch", () => { + const gitStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); + + const mainStatus = status({ + refName: "main", + isDefaultRef: true, + pr: { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "main", + headRef: "main", + state: "open", + }, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("never attaches a PR reported by main to the feature thread", () => { + const mainPr = { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "develop", + headRef: "main", + state: "merged" as const, + }; + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("does not show a cached open PR across a branch mismatch", () => { + const openSnapshot = snapshotFor(featureBranch, { + ...mergedPr, + state: "open", + title: "Still open", + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("retains a cached closed PR across a branch mismatch", () => { + const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; + const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(closedPr); + }); + + it("does not retain or display a terminal PR when a worktree switches branches", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + const mismatchedStatus = status({ refName: "feature/other", pr: null }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeUndefined(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + }); + + it("retains a local terminal snapshot when thread metadata follows the new branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("clears an open snapshot when a local thread moves to a branch with no PR", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears an open snapshot when a local checkout moves to a different branch", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears a retained snapshot when the thread branch is cleared", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPr({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + }); + + it("does not erase a terminal snapshot when VCS data is missing", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }); + expect(displayed?.state).toBe("merged"); + + const shell = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Feature thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-09T00:00:00.000Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + latestUserMessageAt: "2026-04-09T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; + + expect( + effectiveSettled(shell, { + now: "2026-04-10T00:00:00.000Z", + autoSettleAfterDays: null, + changeRequestState: displayed?.state ?? null, + }), + ).toBe(true); + }); +}); + +describe("threadChangeRequestSnapshotsAtom", () => { + it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + const threadKey = "environment-1:thread-1"; + const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); + + yield* Effect.yieldNow; + + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); +}); + describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index af53d1a78b20..a6ea2e7fd962 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,8 +4,10 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -126,6 +128,178 @@ export function resolveThreadPr(input: { return gitStatus.pr ?? null; } +/** + * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement + * partitions move them, so terminal PR metadata must live above the row. + */ +export interface ThreadChangeRequestSnapshot { + readonly branch: string; + readonly pr: NonNullable; + readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; +} + +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); + +function isTerminalChangeRequestState( + state: NonNullable["state"], +): state is "merged" | "closed" { + return state === "merged" || state === "closed"; +} + +function sourceControlProvidersEqual( + left: VcsStatusResult["sourceControlProvider"] | undefined, + right: VcsStatusResult["sourceControlProvider"] | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return left == null && right == null; + return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; +} + +export function threadChangeRequestSnapshotsEqual( + left: ThreadChangeRequestSnapshot, + right: ThreadChangeRequestSnapshot, +): boolean { + return ( + left.branch === right.branch && + left.pr.number === right.pr.number && + left.pr.title === right.pr.title && + left.pr.url === right.pr.url && + left.pr.baseRef === right.pr.baseRef && + left.pr.headRef === right.pr.headRef && + left.pr.state === right.pr.state && + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + ); +} + +export function setThreadChangeRequestSnapshot( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, +): void { + appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { + const existing = current.get(threadKey); + if (snapshot === null) { + if (existing === undefined) return [false, current]; + const next = new Map(current); + next.delete(threadKey); + return [true, next]; + } + if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { + return [false, current]; + } + const next = new Map(current); + next.set(threadKey, snapshot); + return [true, next]; + }); +} + +/** + * Authoritative snapshot update from live VCS status. + * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone + * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear + * - snapshot: matching branch reports a PR — store/replace + */ +export function nextThreadChangeRequestSnapshot(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if (gitStatus === null) { + return undefined; + } + if (threadBranch === null) { + return null; + } + if (gitStatus.refName !== threadBranch) { + return retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ? undefined + : null; + } + if (gitStatus.pr == null) { + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return undefined; + } + return null; + } + return { + branch: threadBranch, + pr: gitStatus.pr, + sourceControlProvider: gitStatus.sourceControlProvider, + }; +} + +/** + * Live PR when the checkout matches the thread branch; otherwise, for local + * checkouts only, a cached merged/closed PR for the thread. Local thread + * metadata follows the shared checkout, so the cached branch intentionally + * survives that metadata changing to the newly checked-out branch. Open PRs + * are never retained — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.pr; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.pr; + } + + return null; +} + +export function resolveDisplayedThreadPrProvider(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.sourceControlProvider; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { From ca37b19cf8d3882f0b4eee1b9e49050494f30422 Mon Sep 17 00:00:00 2001 From: nqrwhal <81386789+nqrwhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:07:53 -0700 Subject: [PATCH 100/144] fix(web): show selected model in context window tooltip (#4772) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 28 ++++----- .../chat/ContextWindowMeter.logic.test.ts | 58 +++++++++++++++++++ .../chat/ContextWindowMeter.logic.ts | 25 ++++++++ .../components/chat/ContextWindowMeter.tsx | 7 ++- 4 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.test.ts create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 293767a7390d..a92bf439ae13 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -103,6 +103,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -210,7 +211,7 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -225,10 +226,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; -import { - deriveLatestContextWindowSnapshot, - formatProviderDisplayName, -} from "../../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -396,7 +394,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; - activeThreadProviderDisplayName: string | null; + activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -424,7 +422,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( {props.activeContextWindow ? ( ) : null} {props.isPreparingWorktree ? ( @@ -930,16 +928,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); - const activeThreadProviderDisplayName = useMemo(() => { - if (!activeThreadModelSelection) return null; - const entry = providerStatuses.find( - (p) => p.instanceId === activeThreadModelSelection.instanceId, - ); - if (entry) { - return getProviderDisplayName(providerStatuses, entry.driver); - } - return formatProviderDisplayName(activeThreadModelSelection.instanceId); - }, [providerStatuses, activeThreadModelSelection]); + const activeThreadModelDisplayName = useMemo( + () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), + [activeThreadModelSelection, modelOptionsByInstance], + ); // ------------------------------------------------------------------ // Composer-local state @@ -3222,7 +3214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + it("uses the selected model from the exact provider instance", () => { + const primaryInstanceId = ProviderInstanceId.make("codex"); + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + const modelOptionsByInstance = new Map([ + [ + primaryInstanceId, + [{ slug: "gpt-5.6-sol", name: "Primary profile model", shortName: "Primary" }], + ], + [selectedInstanceId, [{ slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", shortName: "5.6 Sol" }]], + ]); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "gpt-5.6-sol", + }, + modelOptionsByInstance, + ), + ).toBe("5.6 Sol"); + }); + + it("falls back to the selected model slug when model metadata is unavailable", () => { + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "custom-model", + }, + new Map(), + ), + ).toBe("custom-model"); + }); +}); + +describe("formatContextWindowCompactionMessage", () => { + it("describes compaction in terms of the selected model", () => { + expect(formatContextWindowCompactionMessage("GPT-5.6 Sol")).toBe( + "Context for GPT-5.6 Sol compacts automatically when needed.", + ); + }); + + it("uses neutral copy when the model is unavailable", () => { + expect(formatContextWindowCompactionMessage(null)).toBe( + "Context compacts automatically when needed.", + ); + }); +}); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts new file mode 100644 index 000000000000..c87170ffe610 --- /dev/null +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -0,0 +1,25 @@ +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; + +export function resolveContextWindowModelDisplayName( + selection: ModelSelection | null | undefined, + modelOptionsByInstance: ReadonlyMap>, +): string | null { + if (!selection) { + return null; + } + + const selectedModel = modelOptionsByInstance + .get(selection.instanceId) + ?.find((model) => model.slug === selection.model); + + return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; +} + +export function formatContextWindowCompactionMessage( + modelDisplayName: string | null | undefined, +): string { + return modelDisplayName + ? `Context for ${modelDisplayName} compacts automatically when needed.` + : "Context compacts automatically when needed."; +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index f377c893ae2c..6e42dcadd8b9 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,7 @@ import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -14,9 +15,9 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; - providerDisplayName?: string | null; + modelDisplayName?: string | null; }) { - const { usage, providerDisplayName } = props; + const { usage, modelDisplayName } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -127,7 +128,7 @@ export function ContextWindowMeter(props: { ) : null} {usage.compactsAutomatically ? (
- {providerDisplayName ?? "It"} automatically compacts its context when needed. + {formatContextWindowCompactionMessage(modelDisplayName)}
) : null}
From 5e147371527154be385f28e57339a71521e528c6 Mon Sep 17 00:00:00 2001 From: CursedApple <36764254+Serendeep@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:08:10 +0200 Subject: [PATCH 101/144] fix(web): scale command details with code font (#6510) --- .github/pr-assets/6424-after.svg | 1 + .github/pr-assets/6424-before.svg | 1 + apps/web/src/components/chat/MessagesTimeline.test.tsx | 8 +++++++- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .github/pr-assets/6424-after.svg create mode 100644 .github/pr-assets/6424-before.svg diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 000000000000..dbeb594a09da --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 000000000000..6b365bad6e69 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5bb..dfdfd1169653 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,6 +134,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let toolCallExpandedBodyClassName: typeof import("./MessagesTimeline").toolCallExpandedBodyClassName; beforeAll(async () => { const classList = { @@ -167,7 +168,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, toolCallExpandedBodyClassName } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +227,11 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("sizes expanded tool details with the configured code font size", () => { + expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); + expect(toolCallExpandedBodyClassName).not.toContain("text-[11px]"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f5c529ff315f..9fe392d76a21 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2059,6 +2059,9 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +export const toolCallExpandedBodyClassName = + "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2369,9 +2372,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
-            {expandedBody}
-          
+
{expandedBody}
) : null}
From cf7bfd1c93974428262ab1419d11c972d01d65fa Mon Sep 17 00:00:00 2001 From: John Surles Date: Sat, 15 Aug 2026 08:08:29 -0400 Subject: [PATCH 102/144] fix(web): preserve XML-like tags in user messages (#4133) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 14 +- .../components/chat/MessagesTimeline.test.tsx | 148 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912f00..c4548540e2ce 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -117,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1360,6 +1362,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1622,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1707,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1777,6 +1783,9 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dfdfd1169653..e51095bb00ad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("sizes expanded tool details with the configured code font size", () => { expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); @@ -470,7 +481,142 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("does not render markdown title attributes in user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('src="https://example.com/image.png"'); + expect(markup).not.toContain('title="link tip"'); + expect(markup).not.toContain('title="image tip"'); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); From 7c8848ebb054c1f4c1279f634633cddc37cb1fac Mon Sep 17 00:00:00 2001 From: Akos Balogh Date: Sat, 15 Aug 2026 14:10:21 +0200 Subject: [PATCH 103/144] fix(desktop): route mouse thumb buttons to the in-app browser (#4459) Co-authored-by: Claude Opus 4.8 --- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 19 +++++++ apps/desktop/src/preview/PickPreload.ts | 35 +++++++++++++ 4 files changed, 118 insertions(+) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a4761..e63597b71efc 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 5c336eec8da4..c4297a69c260 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d48b13037398..e5a08e7da8c1 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400ab5..f315bdcec738 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -22,6 +22,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; From f915320914d1bc446e60cbcfe4cd7d75bad4dc2a Mon Sep 17 00:00:00 2001 From: jorvarea <47249803+jorvarea@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:10:32 +0200 Subject: [PATCH 104/144] fix(web): keep the final segment of directory paths with a trailing separator (#5460) Co-authored-by: jorvarea --- apps/web/src/markdown-links.test.ts | 25 +++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..f7c507c178f9 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -273,3 +273,28 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); }); }); + +describe("directory paths with a trailing separator", () => { + it("keeps the final segment for a POSIX directory path", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ + basename: "favicons", + }); + }); + + it("keeps the final segment for a Windows directory path", () => { + expect( + resolveMarkdownFileLinkMeta("C:\\Users\\kelchm\\.claude\\", "/repo/project"), + ).toMatchObject({ basename: ".claude" }); + }); + + it("matches the label of the same path without a trailing separator", () => { + const withSlash = resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project"); + const withoutSlash = resolveMarkdownFileLinkMeta("/tmp/favicons", "/repo/project"); + expect(withSlash?.basename).toBe(withoutSlash?.basename); + }); + + it("does not produce an empty label for the filesystem root", () => { + const meta = resolveMarkdownFileLinkMeta("/tmp/", "/repo/project"); + expect(meta?.basename).not.toBe(""); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..e74bd170117f 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -359,8 +359,12 @@ export function resolveInlineCodeFileLinkMeta( } function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + // A trailing separator is a valid way to write a directory, so trim it before + // taking the final segment. Without this the segment reads as empty and the + // chip renders with no label at all. + const trimmed = path.replace(/[/\\]+$/, "") || path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; } function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { From 7083bce26aa89fedfc482ad44cf61c5508a58db7 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:15 +0530 Subject: [PATCH 105/144] Keep block code plain when copying from rendered markdown (#4468) --- apps/web/src/markdown-clipboard.test.ts | 95 +++++++++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 22 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/markdown-clipboard.test.ts diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 000000000000..7265e8b60430 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index f56b3a4920e4..069d161a188c 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**"); From 21b6fb528d6b2d3b3e333b2bd4455d6cdf7d7a41 Mon Sep 17 00:00:00 2001 From: Alex Brodsky <122503996+Albro3459@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:12:42 -0500 Subject: [PATCH 106/144] fix(web): add web app manifest so installed app keeps its scope (#4306) --- apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 apps/web/public/manifest.webmanifest diff --git a/apps/web/index.html b/apps/web/index.html index 8f49fd32c829..8aef3a4286f2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ +