authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-14 22:00:02-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 02:00:02+00:00
log804cba4305b15f929937833c93e85db0835d8903
tree311db911020f881694e85d0657d82a851e4ba1d3
parentd7abd7f3bb6f392ecb4ca21a1eeaec5bbc2d9393
signature Signed by PGP key B5690EEEBB952194

revert: refresh workspace layouts and tool activity (#6657)


52 files changed, 1727 insertions(+), 3228 deletions(-)

apps/desktop/src/electron/ElectronMenu.test.ts+1-10
......@@ -98,10 +98,7 @@ describe("ElectronMenu", () => {
9898 const electronMenu = yield* ElectronMenu.ElectronMenu;
9999 const selectedItemId = yield* electronMenu.showContextMenu({
100100 window: makeWindow(2),
101 items: [
102 { id: "copy", label: "Copy" },
103 { id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104 ],
101 items: [{ id: "copy", label: "Copy" }],
105102 position: Option.some({ x: 10.8, y: 20.2 }),
106103 });
107104
......@@ -113,12 +110,6 @@ describe("ElectronMenu", () => {
113110 enabled: true,
114111 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
115112 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
122113 }).pipe(Effect.provide(TestLayer)),
123114 );
124115
apps/desktop/src/electron/ElectronMenu.ts+1-9
......@@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
7878 label: sourceItem.label,
7979 destructive: sourceItem.destructive === true,
8080 disabled: sourceItem.disabled === true,
81 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
8281 };
8382
8483 if (sourceItem.children) {
......@@ -142,17 +141,10 @@ export const make = Effect.gen(function* () {
142141 ): Electron.MenuItemConstructorOptions[] => {
143142 const template: Electron.MenuItemConstructorOptions[] = [];
144143 let hasInsertedDestructiveSeparator = false;
145 const appendSeparator = () => {
146 if (template.length === 0 || template.at(-1)?.type === "separator") return;
147 template.push({ type: "separator" });
148 };
149144
150145 for (const item of entries) {
151 if (item.separatorBefore) {
152 appendSeparator();
153 }
154146 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
155 appendSeparator();
147 template.push({ type: "separator" });
156148 hasInsertedDestructiveSeparator = true;
157149 }
158150
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+1-40
......@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
2020 * If slimming ever moves to an allowlist over the whole payload, these
2121 * assertions are the tripwire.
2222 */
23describe("projectActivityPayload", () => {
23describe("projectActivityPayload agent-field survival", () => {
2424 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
2525 const projected = projectActivityPayload(
2626 activity({
......@@ -44,45 +44,6 @@ describe("projectActivityPayload", () => {
4444 expect(data.somethingClientNeverReads).toBeUndefined();
4545 });
4646
47 it("normalizes Claude and OpenCode command inputs before slimming provider data", () => {
48 const claude = projectActivityPayload(
49 activity({
50 itemType: "command_execution",
51 toolCallId: "claude-call-1",
52 data: {
53 toolName: "Bash",
54 input: { command: "vp test run" },
55 result: { content: "x".repeat(5_000) },
56 },
57 }),
58 );
59 const openCode = projectActivityPayload(
60 activity({
61 itemType: "command_execution",
62 toolCallId: "opencode-call-1",
63 data: {
64 tool: "bash",
65 state: {
66 status: "running",
67 input: { command: "vp lint" },
68 output: "x".repeat(5_000),
69 },
70 },
71 }),
72 );
73
74 expect(claude.payload).toMatchObject({
75 toolCallId: "claude-call-1",
76 data: { command: "vp test run" },
77 });
78 expect(openCode.payload).toMatchObject({
79 toolCallId: "opencode-call-1",
80 data: { command: "vp lint" },
81 });
82 expect(JSON.stringify(claude.payload).length).toBeLessThan(200);
83 expect(JSON.stringify(openCode.payload).length).toBeLessThan(200);
84 });
85
8647 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
8748 const projected = projectActivityPayload(
8849 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+7-27
......@@ -104,24 +104,6 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
104104 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
105105}
106106
107function projectCommandValue(data: Record<string, unknown>): unknown {
108 if (data.command !== undefined) {
109 return data.command;
110 }
111
112 const input = asRecord(data.input);
113 if (input?.command !== undefined) {
114 return input.command;
115 }
116
117 const stateInput = asRecord(asRecord(data.state)?.input);
118 if (stateInput?.command !== undefined) {
119 return stateInput.command;
120 }
121
122 return undefined;
123}
124
125107function summarizeToolTextOutput(value: string): string | null {
126108 const lines: string[] = [];
127109 for (const rawLine of value.split(/\r?\n/u)) {
......@@ -305,9 +287,8 @@ export function projectActivityPayload(
305287 if (item) {
306288 projectedData.item = item;
307289 }
308 const command = projectCommandValue(data);
309 if (command !== undefined) {
310 projectedData.command = command;
290 if ("command" in data) {
291 projectedData.command = data.command;
311292 }
312293
313294 const changedFiles: string[] = [];
......@@ -387,10 +368,10 @@ function dropStaleContextWindowActivities(
387368/**
388369 * Identity both clients use to fold a tool lifecycle row into the call it
389370 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
390 * mobile's `threadActivity`): the runtime item id ingestion stamps as
391 * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple.
392 * Returns null for rows with no identity at all — those never collapse on the
393 * client either, so they must not be dropped here.
371 * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter
372 * emits one, otherwise the itemType/title/detail triple. Returns null for rows
373 * with no identity at all — those never collapse on the client either, so they
374 * must not be dropped here.
394375 */
395376function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
396377 const payload = asRecord(activity.payload);
......@@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |
398379 return null;
399380 }
400381
401 const toolCallId =
402 asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
382 const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);
403383 if (toolCallId) {
404384 return `id:${toolCallId}`;
405385 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-22
......@@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => {
28112811 createdAt: now,
28122812 threadId: asThreadId("thread-1"),
28132813 turnId: asTurnId("turn-9"),
2814 itemId: asItemId("tool-call-9"),
28152814 payload: {
28162815 itemType: "command_execution",
2817 status: "inProgress",
2818 title: "Command run",
2819 detail: "Bash: vp test run",
2820 data: {
2821 toolName: "Bash",
2822 input: { command: "vp test run" },
2823 },
2816 status: "in_progress",
2817 title: "Read file",
2818 detail: "/tmp/file.ts",
28242819 },
28252820 });
28262821
......@@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => {
28352830 );
28362831
28372832 expect(thread.session?.status).toBe("ready");
2838 const activity = thread.activities.find(
2839 (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started",
2840 );
2841 const payload = activity?.payload as Record<string, unknown> | undefined;
2842 expect(payload).toMatchObject({
2843 itemType: "command_execution",
2844 toolCallId: "tool-call-9",
2845 status: "inProgress",
2846 detail: "Bash: vp test run",
2847 data: {
2848 toolName: "Bash",
2849 input: { command: "vp test run" },
2850 },
2851 });
2833 expect(
2834 thread.activities.some(
2835 (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2836 ),
2837 ).toBe(true);
28522838 });
28532839
28542840 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts-6
......@@ -794,7 +794,6 @@ export function runtimeEventToActivities(
794794 summary: event.payload.title ?? "Tool updated",
795795 payload: {
796796 itemType: event.payload.itemType,
797 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
798797 ...(event.payload.status ? { status: event.payload.status } : {}),
799798 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
800799 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
......@@ -822,8 +821,6 @@ export function runtimeEventToActivities(
822821 summary: event.payload.title ?? "Tool",
823822 payload: {
824823 itemType: event.payload.itemType,
825 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
826 ...(event.payload.status ? { status: event.payload.status } : {}),
827824 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
828825 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
829826 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
......@@ -850,10 +847,7 @@ export function runtimeEventToActivities(
850847 summary: `${event.payload.title ?? "Tool"} started`,
851848 payload: {
852849 itemType: event.payload.itemType,
853 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
854 ...(event.payload.status ? { status: event.payload.status } : {}),
855850 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
856 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
857851 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
858852 ...(event.payload.parentToolUseId
859853 ? { parentToolUseId: event.payload.parentToolUseId }
apps/web/src/components/ChatView.tsx+26-32
......@@ -167,6 +167,7 @@ import {
167167 WifiOffIcon,
168168} from "lucide-react";
169169import { cn, randomHex } from "~/lib/utils";
170import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
170171import { stackedThreadToast, toastManager } from "./ui/toast";
171172import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
172173import { type NewProjectScriptInput } from "./ProjectScriptsControl";
......@@ -219,11 +220,7 @@ import {
219220import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
220221import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
221222import { environmentCatalog } from "../connection/catalog";
222import {
223 selectThreadTerminalCustomLabels,
224 selectThreadTerminalUiState,
225 useTerminalUiStateStore,
226} from "../terminalUiStateStore";
223import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
227224import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
228225import { projectEnvironment } from "../state/projects";
229226import { useEnvironmentQuery } from "../state/query";
......@@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader";
259256import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
260257import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
261258import { NoActiveThreadState } from "./NoActiveThreadState";
262import { WorkspacePageHeader } from "./WorkspacePageContainer";
263259import {
264260 resolveEffectiveEnvMode,
265261 resolveLocalCheckoutBranchMismatch,
......@@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps {
657653 newShortcutLabel: string | undefined;
658654 closeShortcutLabel: string | undefined;
659655 keybindings: ResolvedKeybindingsConfig;
660 onHide: () => void;
661656 onAddTerminalContext: (selection: TerminalContextSelection) => void;
662657}
663658
......@@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
672667 newShortcutLabel,
673668 closeShortcutLabel,
674669 keybindings,
675 onHide,
676670 onAddTerminalContext,
677671}: PersistentThreadTerminalDrawerProps) {
678672 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
......@@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
996990 onSplitTerminal={splitTerminal}
997991 onSplitTerminalVertical={splitTerminalVertical}
998992 onNewTerminal={createNewTerminal}
999 onHide={onHide}
1000993 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1001994 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1002995 newShortcutLabel={visible ? newShortcutLabel : undefined}
......@@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) {
15471540 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
15481541 const activeThreadId = activeThread?.id ?? null;
15491542 const activeThreadEnvironmentId = activeThread?.environmentId ?? null;
1550 const activeThreadRef = useMemo(
1551 () =>
1552 activeThreadEnvironmentId && activeThreadId
1553 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1554 : null,
1555 [activeThreadEnvironmentId, activeThreadId],
1556 );
1557 const activeTerminalCustomLabels = useTerminalUiStateStore((state) =>
1558 selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef),
1559 );
15601543 const runningTerminalIds = useThreadRunningTerminalIds({
15611544 environmentId: activeThread?.environmentId ?? null,
15621545 threadId: activeThreadId,
......@@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) {
15861569 for (const session of activeThreadKnownSessions) {
15871570 labels.set(
15881571 session.target.terminalId,
1589 activeTerminalCustomLabels[session.target.terminalId] ??
1590 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1572 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
15911573 );
15921574 }
1593 for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) {
1594 if (!labels.has(terminalId)) labels.set(terminalId, label);
1595 }
15961575 return labels;
1597 }, [activeTerminalCustomLabels, activeThreadKnownSessions]);
1576 }, [activeThreadKnownSessions]);
1577 const activeThreadRef = useMemo(
1578 () =>
1579 activeThreadEnvironmentId && activeThreadId
1580 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1581 : null,
1582 [activeThreadEnvironmentId, activeThreadId],
1583 );
15981584 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
15991585 const [timelineAnchor, setTimelineAnchor] = useState<{
16001586 readonly threadKey: string | null;
......@@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) {
28222808 },
28232809 [activeThreadRef, storeSetTerminalOpen],
28242810 );
2825 const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]);
28262811 const toggleTerminalVisibility = useCallback(() => {
28272812 if (!activeThreadRef) return;
28282813 const nextOpen = !terminalUiState.terminalOpen;
......@@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) {
61296114 ? "thread"
61306115 : "page"
61316116 }
6117 chromeVariant="collapse"
61326118 composerDraftTarget={composerDraftTarget}
61336119 onStateChange={handlePullRequestTabStatusChange}
61346120 />
......@@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) {
61746160 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
61756161 >
61766162 {/* Top bar */}
6177 <WorkspacePageHeader
6163 <header
61786164 data-chat-header
6179 electron={isElectron}
6180 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
6181 className="relative bg-background"
6165 className={cn(
6166 "bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",
6167 isElectron
6168 ? cn(
6169 "drag-region relative flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5",
6170 reserveTitleBarControlInset &&
6171 !inlineRightPanelOwnsTitleBar &&
6172 "wco:pr-[var(--workspace-native-controls-inset)]",
6173 )
6174 : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
6175 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
6176 )}
61826177 >
61836178 {!rightPanelOpen ? panelLayoutControls : null}
61846179 <ChatHeader
......@@ -6209,7 +6204,7 @@ function ChatViewContent(props: ChatViewProps) {
62096204 onUpdateProjectScript={updateProjectScript}
62106205 onDeleteProjectScript={deleteProjectScript}
62116206 />
6212 </WorkspacePageHeader>
6207 </header>
62136208
62146209 <ThreadErrorBanner
62156210 error={visibleThreadError}
......@@ -6547,7 +6542,6 @@ function ChatViewContent(props: ChatViewProps) {
65476542 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
65486543 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
65496544 keybindings={keybindings}
6550 onHide={hideTerminal}
65516545 onAddTerminalContext={addTerminalContextToDraft}
65526546 />
65536547 ))}
apps/web/src/components/NoActiveThreadState.tsx+15-4
......@@ -1,15 +1,26 @@
11import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
22import { SidebarInset } from "./ui/sidebar";
33import { isElectron } from "../env";
4import { WorkspacePageHeader } from "./WorkspacePageContainer";
4import { cn } from "~/lib/utils";
5import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
56
67export function NoActiveThreadState() {
78 return (
89 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
910 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
10 <WorkspacePageHeader electron={isElectron} className="border-b border-border">
11 <header
12 className={cn(
13 "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
14 isElectron
15 ? "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center"
16 : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center",
17 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
18 )}
19 >
1120 {isElectron ? (
12 <span className="text-xs text-muted-foreground/50">No active thread</span>
21 <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]">
22 No active thread
23 </span>
1324 ) : (
1425 <div className="flex items-center gap-2">
1526 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
......@@ -17,7 +28,7 @@ export function NoActiveThreadState() {
1728 </span>
1829 </div>
1930 )}
20 </WorkspacePageHeader>
31 </header>
2132
2233 <Empty className="flex-1">
2334 <div className="w-full max-w-lg px-8 py-12">
apps/web/src/components/Sidebar.logic.ts+1-2
......@@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
299299export function shouldCreateNewThreadInCurrentProject(
300300 shiftKey: boolean,
301301 projectGroupCount: number,
302 hasProjectScope = false,
303302): boolean {
304 return hasProjectScope || shiftKey || projectGroupCount <= 1;
303 return shiftKey || projectGroupCount <= 1;
305304}
306305
307306export function orderItemsByPreferredIds<TItem, TId>(input: {
apps/web/src/components/Sidebar.tsx+157-159
......@@ -184,7 +184,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25;
184184// Keep the v2 key so existing preferences survive the v2-to-default rename.
185185const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded";
186186const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded";
187const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0";
188187
189188function compactSidebarTimeLabel(label: string): string {
190189 if (label === "just now") return "now";
......@@ -367,20 +366,26 @@ function SnoozePopoverButton(props: {
367366 );
368367 return (
369368 <Popover open={open} onOpenChange={onOpenChange}>
370 <PopoverTrigger
371 render={
372 <Button
373 size="icon-micro"
374 variant="ghost-muted"
375 aria-label="Snooze thread"
376 title="Snooze thread"
377 onClick={(event) => event.stopPropagation()}
378 onDoubleClick={(event) => event.stopPropagation()}
379 />
380 }
381 >
382 <ClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
383 </PopoverTrigger>
369 <Tooltip>
370 <TooltipTrigger
371 render={
372 <PopoverTrigger
373 render={
374 <button
375 type="button"
376 aria-label="Snooze thread"
377 onClick={(event) => event.stopPropagation()}
378 onDoubleClick={(event) => event.stopPropagation()}
379 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"
380 />
381 }
382 />
383 }
384 >
385 <ClockIcon className="size-3" />
386 </TooltipTrigger>
387 <TooltipPopup>Snooze thread</TooltipPopup>
388 </Tooltip>
384389 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
385390 {presets.map((preset) => (
386391 <button
......@@ -1194,9 +1199,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11941199 aria-label="Dismiss Woke notification"
11951200 title="Dismiss Woke notification"
11961201 onClick={handleAcknowledgeWokeClick}
1197 className="inline-flex h-6 cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
1202 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
11981203 >
1199 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1204 <AlarmClockIcon aria-hidden className="size-3" />
12001205 <span role="status">Woke</span>
12011206 </button>
12021207 ) : (
......@@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12181223 isWoke && "group-hover/sidebar-row:static",
12191224 )}
12201225 >
1221 <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1226 <AlarmClockOffIcon className="mb-px size-3" />
12221227 </button>
12231228 )
12241229 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
......@@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12311236 isWoke && "group-hover/sidebar-row:static",
12321237 )}
12331238 >
1234 <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1239 <Undo2Icon className="mb-px size-3.5" />
12351240 </button>
12361241 ) : (
12371242 <button
......@@ -1243,7 +1248,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12431248 isWoke && "group-hover/sidebar-row:static",
12441249 )}
12451250 >
1246 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1251 <CheckIcon className="size-3" />
12471252 </button>
12481253 )}
12491254 </span>
......@@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13121317 ) : (
13131318 <span className="flex-1" />
13141319 )}
1315 <span className="ml-auto flex h-5 shrink-0 items-center">
1316 {props.isPinned ? (
1317 props.pinningSupported ? (
1318 <Button
1319 size="icon-micro"
1320 variant="ghost-muted"
1321 aria-label="Unpin thread"
1322 title="Unpin thread"
1323 onClick={handleUnpinClick}
1320 {props.isPinned ? (
1321 props.pinningSupported ? (
1322 <Tooltip>
1323 <TooltipTrigger
1324 render={
1325 <button
1326 type="button"
1327 aria-label="Unpin thread"
1328 onClick={handleUnpinClick}
1329 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1330 />
1331 }
13241332 >
1325 <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1326 </Button>
1327 ) : (
1328 <span className="inline-flex size-5 shrink-0 items-center justify-center">
1329 <PinIcon
1330 aria-label="Pinned"
1331 role="img"
1332 className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")}
1333 />
1334 </span>
1335 )
1336 ) : null}
1337 {/* Only the visible state owns this slot's width: the pin stays
1338 directly beside the idle status and beside the first action
1339 when the hover controls replace it. */}
1333 <PinIcon aria-hidden className="size-3 shrink-0" />
1334 </TooltipTrigger>
1335 <TooltipPopup>Unpin thread</TooltipPopup>
1336 </Tooltip>
1337 ) : (
1338 <PinIcon
1339 aria-label="Pinned"
1340 role="img"
1341 className="size-3 shrink-0 text-muted-foreground/65"
1342 />
1343 )
1344 ) : null}
1345 {/* The visible state owns this slot's width: status at rest,
1346 actions on hover/keyboard focus or while the popover is open. Keeping
1347 the hidden state out of flow lets the project label reclaim
1348 space without either state overlapping it. */}
1349 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1350 {/* Read-only status labels yield to the hover actions. Woke is
1351 itself an action, so it stays pointer-enabled and visible
1352 while the other controls appear beside it. */}
13401353 <span
13411354 className={cn(
1342 "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs",
1343 props.isPinned ? "min-w-0" : "min-w-8",
1355 isWokeStatus
1356 ? "pointer-events-auto"
1357 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
1358 "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1359 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
13441360 )}
13451361 >
1346 {/* Read-only status labels yield to the hover actions. Woke is
1347 itself an action, so it stays pointer-enabled and visible
1348 while the other controls appear beside it. */}
1362 {topStatus ? (
1363 isWokeStatus ? (
1364 <button
1365 type="button"
1366 aria-label="Dismiss Woke notification"
1367 title="Dismiss Woke notification"
1368 onClick={handleAcknowledgeWokeClick}
1369 className={cn(
1370 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1371 topStatus.className,
1372 )}
1373 >
1374 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1375 <span role="status">{topStatus.label}</span>
1376 </button>
1377 ) : (
1378 <span
1379 className={cn(
1380 "inline-flex items-center gap-1 font-medium",
1381 topStatus.className,
1382 )}
1383 >
1384 {topStatus.icon === "working" ? (
1385 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1386 ) : topStatus.icon === "done" ? (
1387 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1388 ) : null}
1389 {/* The label alone is the live region: a role="status"
1390 wrapper around the ticking duration would make
1391 screen readers announce every second. */}
1392 <span role="status">{topStatus.label}</span>
1393 {status === "working" ? (
1394 <span aria-hidden>
1395 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1396 </span>
1397 ) : null}
1398 </span>
1399 )
1400 ) : (
1401 threadTimeLabel(thread)
1402 )}
1403 </span>
1404 {props.settlementSupported || showSnoozeButton ? (
13491405 <span
13501406 className={cn(
1351 isWokeStatus
1352 ? "pointer-events-auto"
1353 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
1354 "self-center tabular-nums text-secondary-label transition-opacity",
1355 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1407 // focus-visible, not focus-within: a mouse click leaves
1408 // the Settle button focused, and a plain focus-within
1409 // would keep the controls pinned over the status label
1410 // once the pointer moves away (e.g. after a failed
1411 // settle) instead of cross-fading back.
1412 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
1413 snoozeMenuOpen && "pointer-events-auto static opacity-100",
13561414 )}
13571415 >
1358 {topStatus ? (
1359 isWokeStatus ? (
1360 <button
1361 type="button"
1362 aria-label="Dismiss Woke notification"
1363 title="Dismiss Woke notification"
1364 onClick={handleAcknowledgeWokeClick}
1365 className={cn(
1366 "inline-flex h-5 cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1367 topStatus.className,
1368 )}
1369 >
1370 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1371 <span role="status">{topStatus.label}</span>
1372 </button>
1373 ) : (
1374 <span
1375 className={cn(
1376 "inline-flex items-center gap-1 font-medium",
1377 topStatus.className,
1378 )}
1379 >
1380 {topStatus.icon === "working" ? (
1381 <CircleDashedIcon
1382 aria-hidden
1383 className={SIDEBAR_LIFECYCLE_ICON_CLASS}
1416 {showSnoozeButton ? (
1417 <SnoozePopoverButton
1418 open={snoozeMenuOpen}
1419 onOpenChange={setSnoozeMenuOpen}
1420 onSnooze={handleSnoozePreset}
1421 timestampFormat={props.timestampFormat}
1422 />
1423 ) : null}
1424 {props.settlementSupported ? (
1425 <Tooltip>
1426 <TooltipTrigger
1427 render={
1428 <button
1429 type="button"
1430 aria-label="Settle thread"
1431 onClick={handleSettleClick}
1432 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
13841433 />
1385 ) : topStatus.icon === "done" ? (
1386 <CircleCheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1387 ) : null}
1388 {/* The label alone is the live region: a role="status"
1389 wrapper around the ticking duration would make
1390 screen readers announce every second. */}
1391 <span role="status">{topStatus.label}</span>
1392 {status === "working" ? (
1393 <span aria-hidden>
1394 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1395 </span>
1396 ) : null}
1397 </span>
1398 )
1399 ) : (
1400 threadTimeLabel(thread)
1401 )}
1402 </span>
1403 {props.settlementSupported || showSnoozeButton ? (
1404 <span
1405 className={cn(
1406 // focus-visible, not focus-within: a mouse click leaves
1407 // the Settle button focused, and a plain focus-within
1408 // would keep the controls pinned over the status label
1409 // once the pointer moves away (e.g. after a failed
1410 // settle) instead of cross-fading back.
1411 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
1412 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1413 )}
1414 >
1415 {showSnoozeButton ? (
1416 <SnoozePopoverButton
1417 open={snoozeMenuOpen}
1418 onOpenChange={setSnoozeMenuOpen}
1419 onSnooze={handleSnoozePreset}
1420 timestampFormat={props.timestampFormat}
1421 />
1422 ) : null}
1423 {props.settlementSupported ? (
1424 <button
1425 type="button"
1426 aria-label="Settle thread"
1427 onClick={handleSettleClick}
1428 className="-mr-1 inline-flex h-full cursor-pointer items-center gap-1 rounded-md bg-transparent ps-1 pe-1.5 text-xs text-muted-foreground hover:text-foreground"
1434 }
14291435 >
1430 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1436 <CheckIcon className="size-3.5" />
14311437 Settle
1432 </button>
1433 ) : null}
1434 </span>
1435 ) : null}
1436 </span>
1438 </TooltipTrigger>
1439 <TooltipPopup>Settle thread</TooltipPopup>
1440 </Tooltip>
1441 ) : null}
1442 </span>
1443 ) : null}
14371444 </span>
14381445 </div>
14391446 <div className="mt-1 flex min-w-0">
......@@ -3211,25 +3218,17 @@ export default function Sidebar() {
32113218 autoAnimate(node, { duration: 150, easing: "ease-out" });
32123219 }, []);
32133220
3214 // A selected project scope owns creation: users should not have to choose
3215 // the same project twice. "All projects" keeps the picker in multi-project
3216 // setups, while Shift+click retains the direct-create shortcut.
3221 // New thread defaults to the project you're in (active thread's project,
3222 // falling back to the top project) — same resolution the command palette
3223 // uses. The command palette already offers a "New thread in..." submenu
3224 // for multi-project setups.
32173225 const handleNewThreadClick = useCallback(
32183226 (event?: ReactMouseEvent) => {
3219 if (
3220 shouldCreateNewThreadInCurrentProject(
3221 event?.shiftKey ?? false,
3222 projectGroups.length,
3223 scopedProjectGroup !== null,
3224 )
3225 ) {
3227 // One project: nothing to pick, create immediately. Shift+click creates
3228 // directly in the current project even with several projects, skipping
3229 // the palette picker.
3230 if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {
32263231 if (isMobile) setOpenMobile(false);
3227 if (scopedProjectGroup) {
3228 void newThreadContext.handleNewThread(
3229 scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id),
3230 );
3231 return;
3232 }
32333232 void startNewThreadFromContext({
32343233 activeDraftThread: newThreadContext.activeDraftThread,
32353234 activeThread: newThreadContext.activeThread ?? undefined,
......@@ -3241,19 +3240,20 @@ export default function Sidebar() {
32413240 if (isMobile) setOpenMobile(false);
32423241 openCommandPalette({ open: "new-thread-in" });
32433242 },
3244 [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile],
3243 [isMobile, newThreadContext, projectGroups.length, setOpenMobile],
32453244 );
32463245
3247 // With no explicit scope the button mirrors chat.new. A scoped button has
3248 // intentionally more specific behavior, so it does not advertise the
3249 // broader command's shortcut.
3246 // The button mirrors chat.new: in multi-project setups both route through
3247 // the command palette's "New thread in..." picker, and in single-project
3248 // setups both create immediately. In multi-project setups the label is only
3249 // the picker's shortcut: falling back to chat.newLocal would advertise the
3250 // same shortcut for both the picker and direct create. In single-project
3251 // setups both commands create directly, so chat.newLocal is a valid
3252 // fallback. The second tooltip line (multi-project only) advertises
3253 // shift+click and its keyboard twin chat.newLocal for direct create.
32503254 const newThreadShortcutLabel =
3251 scopedProjectGroup === null
3252 ? (shortcutLabelForCommand(keybindings, "chat.new") ??
3253 (projectGroups.length <= 1
3254 ? shortcutLabelForCommand(keybindings, "chat.newLocal")
3255 : undefined))
3256 : undefined;
3255 shortcutLabelForCommand(keybindings, "chat.new") ??
3256 (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
32573257 const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
32583258 return (
32593259 <>
......@@ -3332,9 +3332,7 @@ export default function Sidebar() {
33323332 />
33333333 </TooltipTrigger>
33343334 <TooltipPopup side="right">
3335 {scopedProjectGroup ? (
3336 `New thread in ${scopedProjectGroup.displayName}`
3337 ) : projectGroups.length > 1 ? (
3335 {projectGroups.length > 1 ? (
33383336 <span className="flex flex-col gap-0.5">
33393337 <span>
33403338 {newThreadShortcutLabel
apps/web/src/components/ThreadTerminalDrawer.tsx+308-356
......@@ -5,11 +5,11 @@ import {
55} from "@t3tools/client-runtime/state/runtime";
66import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
77import {
8 PanelBottomCloseIcon,
98 Plus,
109 SquareSplitHorizontal,
1110 SquareSplitVertical,
1211 TerminalSquare,
12 Trash2,
1313 XIcon,
1414} from "lucide-react";
1515import {
......@@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
2121import * as Schema from "effect/Schema";
2222import {
2323 type PointerEvent as ReactPointerEvent,
24 type ReactNode,
2425 type SetStateAction,
2526 useCallback,
2627 useEffect,
......@@ -29,9 +30,9 @@ import {
2930 useRef,
3031 useState,
3132} from "react";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3234import { Button } from "~/components/ui/button";
3335import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
34import { useResizableWidth } from "~/hooks/useResizableWidth";
3536import { cn } from "~/lib/utils";
3637import { type TerminalContextSelection } from "~/lib/terminalContext";
3738import {
......@@ -59,7 +60,6 @@ import {
5960import { readLocalApi } from "~/localApi";
6061import { useClientSettings } from "../hooks/useSettings";
6162import { useLocalStorage } from "../hooks/useLocalStorage";
62import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore";
6363import { useAttachedTerminalSession } from "../state/terminalSessions";
6464import { serverEnvironment } from "../state/server";
6565import { previewEnvironment } from "../state/preview";
......@@ -72,15 +72,10 @@ import {
7272 resolveTerminalFontSizePreference,
7373 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
7474} from "../appearanceFonts";
75import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle";
7675
7776const MIN_DRAWER_HEIGHT = 180;
7877const MAX_DRAWER_HEIGHT_RATIO = 0.75;
7978const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
80const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144;
81const TERMINAL_SIDEBAR_MIN_WIDTH = 144;
82const TERMINAL_SIDEBAR_MAX_WIDTH = 320;
83const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width";
8479
8580function maxDrawerHeight(): number {
8681 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
......@@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp(
249244 return selectionGestureActive && button === 0;
250245}
251246
252export function shouldShowTerminalSidebar(terminalCount: number): boolean {
253 return terminalCount > 1;
254}
255
256247export function terminalSelectionLineRange(position: {
257248 start: { y: number };
258249 end: { y: number };
......@@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps {
885876 onSplitTerminal: () => void;
886877 onSplitTerminalVertical: () => void;
887878 onNewTerminal: () => void;
888 onHide?: () => void;
889879 splitShortcutLabel?: string | undefined;
890880 splitVerticalShortcutLabel?: string | undefined;
891881 newShortcutLabel?: string | undefined;
......@@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps {
901891 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
902892}
903893
894interface TerminalActionButtonProps {
895 label: string;
896 className: string;
897 onClick: () => void;
898 children: ReactNode;
899}
900
901function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
902 return (
903 <Popover>
904 <PopoverTrigger
905 openOnHover
906 render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
907 >
908 {children}
909 </PopoverTrigger>
910 <PopoverPopup
911 tooltipStyle
912 side="bottom"
913 sideOffset={6}
914 align="center"
915 className="pointer-events-none select-none"
916 >
917 {label}
918 </PopoverPopup>
919 </Popover>
920 );
921}
922
904923export default function ThreadTerminalDrawer({
905924 mode = "drawer",
906925 threadRef,
......@@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({
918937 onSplitTerminal,
919938 onSplitTerminalVertical,
920939 onNewTerminal,
921 onHide,
922940 splitShortcutLabel,
923941 splitVerticalShortcutLabel,
924942 newShortcutLabel,
......@@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({
932950 terminalLaunchLocationsById,
933951}: ThreadTerminalDrawerProps) {
934952 const isPanel = mode === "panel";
935 const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } =
936 useResizableWidth({
937 storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY,
938 defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH,
939 minWidth: TERMINAL_SIDEBAR_MIN_WIDTH,
940 maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH,
941 edge: "left",
942 });
943 const terminalCustomLabels = useTerminalUiStateStore((state) =>
944 selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef),
945 );
946 const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel);
947 const [renamingTerminalId, setRenamingTerminalId] = useState<string | null>(null);
948 const [terminalRenameDraft, setTerminalRenameDraft] = useState("");
949 const cancelTerminalRenameRef = useRef(false);
950953 const [advancedTypography] = useLocalStorage(
951954 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
952955 false,
......@@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({
10951098 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
10961099 const splitDirection =
10971100 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
1098 const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length);
1101 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
10991102 const isSplitView = visibleTerminalIds.length > 1;
1103 const showGroupHeaders =
1104 resolvedTerminalGroups.length > 1 ||
1105 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
11001106 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
1101 const automaticTerminalLabelById = useMemo(() => {
1107 const terminalLabelById = useMemo(() => {
11021108 const next = new Map<string, string>();
11031109 for (const terminalId of normalizedTerminalIds) {
11041110 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
11051111 }
11061112 return next;
11071113 }, [normalizedTerminalIds, terminalLabelsById]);
1108 const terminalLabelById = useMemo(() => {
1109 const next = new Map<string, string>();
1110 for (const terminalId of normalizedTerminalIds) {
1111 next.set(
1112 terminalId,
1113 terminalCustomLabels[terminalId]?.trim() ||
1114 automaticTerminalLabelById.get(terminalId) ||
1115 getTerminalLabel(terminalId),
1116 );
1117 }
1118 return next;
1119 }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]);
11201114 const resolveTerminalLaunchLocation = useCallback(
11211115 (terminalId: string): TerminalLaunchLocation => {
11221116 return (
......@@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({
11291123 },
11301124 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
11311125 );
1132 const newTerminalActionLabel = newShortcutLabel
1133 ? `New Terminal (${newShortcutLabel})`
1134 : "New Terminal";
11351126 const splitTerminalActionLabel = hasReachedSplitLimit
11361127 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
11371128 : splitShortcutLabel
......@@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({
11421133 : splitVerticalShortcutLabel
11431134 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
11441135 : "Split Terminal Vertically";
1136 const newTerminalActionLabel = newShortcutLabel
1137 ? `New Terminal (${newShortcutLabel})`
1138 : "New Terminal";
11451139 const closeTerminalActionLabel = closeShortcutLabel
11461140 ? `Close Terminal (${closeShortcutLabel})`
11471141 : "Close Terminal";
......@@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({
11531147 if (hasReachedSplitLimit) return;
11541148 onSplitTerminalVertical();
11551149 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1156 const startTerminalRename = useCallback(
1157 (terminalId: string) => {
1158 cancelTerminalRenameRef.current = false;
1159 setRenamingTerminalId(terminalId);
1160 setTerminalRenameDraft(
1161 terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "",
1162 );
1163 },
1164 [terminalCustomLabels, terminalLabelById],
1165 );
1166 const finishTerminalRename = useCallback(() => {
1167 if (!renamingTerminalId) return;
1168 const nextLabel = terminalRenameDraft.trim();
1169 const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? "";
1170 setTerminalCustomLabel(
1171 threadRef,
1172 renamingTerminalId,
1173 nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel,
1174 );
1175 setRenamingTerminalId(null);
1176 }, [
1177 automaticTerminalLabelById,
1178 renamingTerminalId,
1179 setTerminalCustomLabel,
1180 terminalRenameDraft,
1181 threadRef,
1182 ]);
1183 const cancelTerminalRename = useCallback(() => {
1184 cancelTerminalRenameRef.current = true;
1185 setRenamingTerminalId(null);
1186 }, []);
1187
1188 useEffect(() => {
1189 cancelTerminalRenameRef.current = false;
1190 setRenamingTerminalId(null);
1191 setTerminalRenameDraft("");
1192 }, [threadRef.environmentId, threadRef.threadId]);
1150 const onNewTerminalAction = useCallback(() => {
1151 onNewTerminal();
1152 }, [onNewTerminal]);
11931153
11941154 useEffect(() => {
11951155 onHeightChangeRef.current = onHeightChange;
......@@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({
13141274 ) : null}
13151275 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground">
13161276 <p>No terminal sessions for this thread yet.</p>
1317 <Button size="xs" variant="outline" onClick={onNewTerminal}>
1277 <Button size="xs" variant="outline" onClick={onNewTerminalAction}>
13181278 {newTerminalActionLabel}
13191279 </Button>
13201280 </div>
......@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({
13231283 }
13241284
13251285 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1326 const compactTerminalToolbar = (
1327 <>
1328 <Button
1329 type="button"
1330 size="icon-xs"
1331 variant="ghost"
1332 className="rounded-md"
1333 disabled={hasReachedSplitLimit}
1334 onClick={onSplitTerminalAction}
1335 aria-label={splitTerminalActionLabel}
1336 title={splitTerminalActionLabel}
1337 >
1338 <SquareSplitHorizontal className="size-3.5" />
1339 </Button>
1340 <Button
1341 type="button"
1342 size="icon-xs"
1343 variant="ghost"
1344 className="rounded-md"
1345 disabled={hasReachedSplitLimit}
1346 onClick={onSplitTerminalVerticalAction}
1347 aria-label={splitTerminalVerticalActionLabel}
1348 title={splitTerminalVerticalActionLabel}
1349 >
1350 <SquareSplitVertical className="size-3.5" />
1351 </Button>
1352 <Button
1353 type="button"
1354 size="icon-xs"
1355 variant="ghost"
1356 className="rounded-md"
1357 onClick={onNewTerminal}
1358 aria-label={newTerminalActionLabel}
1359 title={newTerminalActionLabel}
1360 >
1361 <Plus className="size-3.5" />
1362 </Button>
1363 <Button
1364 type="button"
1365 size="icon-xs"
1366 variant="ghost"
1367 className="rounded-md"
1368 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1369 aria-label={closeTerminalActionLabel}
1370 title={closeTerminalActionLabel}
1371 >
1372 <XIcon className="size-3.5" />
1373 </Button>
1374 {!isPanel && onHide ? (
1375 <>
1376 <span aria-hidden className="mx-0.5 h-4 w-px bg-border/70" />
1377 <Button
1378 type="button"
1379 size="icon-xs"
1380 variant="ghost"
1381 className="rounded-md"
1382 onClick={onHide}
1383 aria-label="Hide terminal drawer"
1384 title="Hide terminal drawer"
1385 >
1386 <PanelBottomCloseIcon className="size-3.5" />
1387 </Button>
1388 </>
1389 ) : null}
1390 </>
1391 );
1286
13921287 return (
13931288 <aside
13941289 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
......@@ -1408,220 +1303,277 @@ export default function ThreadTerminalDrawer({
14081303 />
14091304 ) : null}
14101305
1411 {!hasTerminalSidebar ? (
1412 <div className="pointer-events-none absolute top-2 right-2 z-20">
1413 <div className="pointer-events-auto flex h-8 items-center rounded-lg border border-border/70 bg-background p-0.5 shadow-xs">
1414 {compactTerminalToolbar}
1306 {!hasTerminalSidebar && (
1307 <div className="pointer-events-none absolute right-2 top-2 z-20">
1308 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
1309 <TerminalActionButton
1310 className={`p-1 text-foreground/90 transition-colors ${
1311 hasReachedSplitLimit
1312 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1313 : "hover:bg-accent"
1314 }`}
1315 onClick={onSplitTerminalAction}
1316 label={splitTerminalActionLabel}
1317 >
1318 <SquareSplitHorizontal className="size-3.25" />
1319 </TerminalActionButton>
1320 <div className="h-4 w-px bg-border/80" />
1321 <TerminalActionButton
1322 className={`p-1 text-foreground/90 transition-colors ${
1323 hasReachedSplitLimit
1324 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1325 : "hover:bg-accent"
1326 }`}
1327 onClick={onSplitTerminalVerticalAction}
1328 label={splitTerminalVerticalActionLabel}
1329 >
1330 <SquareSplitVertical className="size-3.25" />
1331 </TerminalActionButton>
1332 <div className="h-4 w-px bg-border/80" />
1333 <TerminalActionButton
1334 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1335 onClick={onNewTerminalAction}
1336 label={newTerminalActionLabel}
1337 >
1338 <Plus className="size-3.25" />
1339 </TerminalActionButton>
1340 <div className="h-4 w-px bg-border/80" />
1341 <TerminalActionButton
1342 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1343 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1344 label={closeTerminalActionLabel}
1345 >
1346 <Trash2 className="size-3.25" />
1347 </TerminalActionButton>
14151348 </div>
14161349 </div>
1417 ) : null}
1350 )}
14181351
1419 <div className="flex min-h-0 w-full flex-1">
1420 <div className="min-w-0 flex-1">
1421 {isSplitView ? (
1422 <div
1423 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1424 style={
1425 splitDirection === "vertical"
1426 ? {
1427 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1428 }
1429 : {
1430 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1431 }
1432 }
1433 >
1434 {visibleTerminalIds.map((terminalId) => {
1435 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1436 const isActive = terminalId === resolvedActiveTerminalId;
1437 return (
1438 <div
1439 key={terminalId}
1440 className={cn(
1441 "min-h-0 min-w-0",
1442 splitDirection === "vertical"
1443 ? "border-t first:border-t-0"
1444 : "border-l first:border-l-0",
1445 isActive ? "border-border" : "border-border/70",
1446 )}
1447 onMouseDown={() => {
1448 if (!isActive) onActiveTerminalChange(terminalId);
1449 }}
1450 >
1451 <div className="h-full p-1">
1452 <TerminalViewport
1453 advancedTypography={advancedTypography}
1454 threadRef={threadRef}
1455 threadId={threadId}
1456 terminalId={terminalId}
1457 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1458 cwd={terminalLaunchLocation.cwd}
1459 {...(terminalLaunchLocation.worktreePath !== undefined
1460 ? { worktreePath: terminalLaunchLocation.worktreePath }
1461 : {})}
1462 {...(terminalLaunchLocation.runtimeEnv
1463 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1464 : {})}
1465 onSessionExited={() => onCloseTerminal(terminalId)}
1466 onAddTerminalContext={onAddTerminalContext}
1467 focusRequestId={focusRequestId}
1468 autoFocus={isActive}
1469 resizeEpoch={resizeEpoch}
1470 drawerHeight={drawerHeight}
1471 keybindings={keybindings}
1472 />
1352 <div className="min-h-0 w-full flex-1">
1353 <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}>
1354 <div className="min-w-0 flex-1">
1355 {isSplitView ? (
1356 <div
1357 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1358 style={
1359 splitDirection === "vertical"
1360 ? {
1361 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1362 }
1363 : {
1364 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1365 }
1366 }
1367 >
1368 {visibleTerminalIds.map((terminalId) => {
1369 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1370 return (
1371 <div
1372 key={terminalId}
1373 className={`min-h-0 min-w-0 ${
1374 splitDirection === "vertical"
1375 ? "border-t first:border-t-0"
1376 : "border-l first:border-l-0"
1377 } ${
1378 terminalId === resolvedActiveTerminalId
1379 ? "border-border"
1380 : "border-border/70"
1381 }`}
1382 onMouseDown={() => {
1383 if (terminalId !== resolvedActiveTerminalId) {
1384 onActiveTerminalChange(terminalId);
1385 }
1386 }}
1387 >
1388 <div className="h-full p-1">
1389 <TerminalViewport
1390 advancedTypography={advancedTypography}
1391 threadRef={threadRef}
1392 threadId={threadId}
1393 terminalId={terminalId}
1394 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1395 cwd={terminalLaunchLocation.cwd}
1396 {...(terminalLaunchLocation.worktreePath !== undefined
1397 ? { worktreePath: terminalLaunchLocation.worktreePath }
1398 : {})}
1399 {...(terminalLaunchLocation.runtimeEnv
1400 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1401 : {})}
1402 onSessionExited={() => onCloseTerminal(terminalId)}
1403 onAddTerminalContext={onAddTerminalContext}
1404 focusRequestId={focusRequestId}
1405 autoFocus={terminalId === resolvedActiveTerminalId}
1406 resizeEpoch={resizeEpoch}
1407 drawerHeight={drawerHeight}
1408 keybindings={keybindings}
1409 />
1410 </div>
14731411 </div>
1474 </div>
1475 );
1476 })}
1477 </div>
1478 ) : (
1479 <div className="h-full p-1">
1480 <TerminalViewport
1481 advancedTypography={advancedTypography}
1482 key={resolvedActiveTerminalId}
1483 threadRef={threadRef}
1484 threadId={threadId}
1485 terminalId={resolvedActiveTerminalId}
1486 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1487 cwd={activeTerminalLaunchLocation.cwd}
1488 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1489 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1490 : {})}
1491 {...(activeTerminalLaunchLocation.runtimeEnv
1492 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1493 : {})}
1494 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1495 onAddTerminalContext={onAddTerminalContext}
1496 focusRequestId={focusRequestId}
1497 autoFocus
1498 resizeEpoch={resizeEpoch}
1499 drawerHeight={drawerHeight}
1500 keybindings={keybindings}
1501 />
1502 </div>
1503 )}
1504 </div>
1505
1506 <aside
1507 className={cn(
1508 "relative flex shrink-0 flex-col border-l border-border/70 bg-muted/10",
1509 !hasTerminalSidebar && "hidden",
1510 )}
1511 style={{ width: `${terminalSidebarWidth}px` }}
1512 >
1513 <RightPanelResizeHandle handlers={terminalSidebarResizeHandlers} />
1514 <div className="flex h-9 shrink-0 items-center justify-center border-b border-border/70 px-2">
1515 {hasTerminalSidebar ? compactTerminalToolbar : null}
1412 );
1413 })}
1414 </div>
1415 ) : (
1416 <div className="h-full p-1">
1417 <TerminalViewport
1418 advancedTypography={advancedTypography}
1419 key={resolvedActiveTerminalId}
1420 threadRef={threadRef}
1421 threadId={threadId}
1422 terminalId={resolvedActiveTerminalId}
1423 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1424 cwd={activeTerminalLaunchLocation.cwd}
1425 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1426 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1427 : {})}
1428 {...(activeTerminalLaunchLocation.runtimeEnv
1429 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1430 : {})}
1431 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1432 onAddTerminalContext={onAddTerminalContext}
1433 focusRequestId={focusRequestId}
1434 autoFocus
1435 resizeEpoch={resizeEpoch}
1436 drawerHeight={drawerHeight}
1437 keybindings={keybindings}
1438 />
1439 </div>
1440 )}
15161441 </div>
15171442
1518 <div className="min-h-0 flex-1 overflow-y-auto p-1">
1519 {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {
1520 const isSplitGroup = terminalGroup.terminalIds.length > 1;
1521 const splitVertically = terminalGroup.splitDirection === "vertical";
1522 const SplitIcon = splitVertically ? SquareSplitVertical : SquareSplitHorizontal;
1523 return (
1524 <div
1525 key={terminalGroup.id}
1526 className={cn(
1527 groupIndex > 0 && "mt-1",
1528 isSplitGroup && "rounded-lg border border-border/80 bg-muted/20 p-1",
1529 )}
1530 >
1531 {isSplitGroup ? (
1532 <div className="flex h-5 items-center gap-1 px-1 text-[10px] font-medium text-muted-foreground">
1533 <SplitIcon className="size-3" />
1534 <span>{splitVertically ? "Split down" : "Split right"}</span>
1535 </div>
1536 ) : null}
1537 <div className={cn(isSplitGroup && "space-y-0.5")}>
1538 {terminalGroup.terminalIds.map((terminalId) => {
1539 const isActive = terminalId === resolvedActiveTerminalId;
1540 const automaticLabel =
1541 automaticTerminalLabelById.get(terminalId) ?? "Terminal";
1542 const customLabel = terminalCustomLabels[terminalId]?.trim() ?? "";
1543 const displayLabel = terminalLabelById.get(terminalId) ?? automaticLabel;
1544 const isRenaming = renamingTerminalId === terminalId;
1545 return (
1546 <div
1547 key={terminalId}
1548 className={cn(
1549 "group/tab flex h-6 items-center gap-0.5 rounded-md pr-2 text-xs",
1550 isSplitGroup ? "pl-0.5" : "pl-1.5",
1551 isActive
1552 ? "bg-accent text-foreground"
1553 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
1554 )}
1443 {hasTerminalSidebar && (
1444 <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">
1445 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
1446 <div className="inline-flex h-full items-stretch">
1447 <TerminalActionButton
1448 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
1449 hasReachedSplitLimit
1450 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1451 : "hover:bg-accent/70"
1452 }`}
1453 onClick={onSplitTerminalAction}
1454 label={splitTerminalActionLabel}
1455 >
1456 <SquareSplitHorizontal className="size-3.25" />
1457 </TerminalActionButton>
1458 <TerminalActionButton
1459 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
1460 hasReachedSplitLimit
1461 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1462 : "hover:bg-accent/70"
1463 }`}
1464 onClick={onSplitTerminalVerticalAction}
1465 label={splitTerminalVerticalActionLabel}
1466 >
1467 <SquareSplitVertical className="size-3.25" />
1468 </TerminalActionButton>
1469 <TerminalActionButton
1470 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1471 onClick={onNewTerminalAction}
1472 label={newTerminalActionLabel}
1473 >
1474 <Plus className="size-3.25" />
1475 </TerminalActionButton>
1476 <TerminalActionButton
1477 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1478 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1479 label={closeTerminalActionLabel}
1480 >
1481 <Trash2 className="size-3.25" />
1482 </TerminalActionButton>
1483 </div>
1484 </div>
1485
1486 <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
1487 {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {
1488 const isGroupActive =
1489 terminalGroup.terminalIds.includes(resolvedActiveTerminalId);
1490 const groupActiveTerminalId = isGroupActive
1491 ? resolvedActiveTerminalId
1492 : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId);
1493
1494 return (
1495 <div key={terminalGroup.id} className="pb-0.5">
1496 {showGroupHeaders && (
1497 <button
1498 type="button"
1499 className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${
1500 isGroupActive
1501 ? "bg-accent/70 text-foreground"
1502 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1503 }`}
1504 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
15551505 >
1556 <button
1557 type="button"
1558 className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
1559 aria-label={`Close ${displayLabel}`}
1560 onClick={() => onCloseTerminal(terminalId)}
1561 >
1562 <TerminalSquare className="size-3 shrink-0 group-hover/tab:hidden group-focus-visible/close:hidden pointer-coarse:hidden" />
1563 <XIcon className="hidden size-3 group-hover/tab:block group-focus-visible/close:block pointer-coarse:block" />
1564 </button>
1565 {isRenaming ? (
1566 <input
1567 autoFocus
1568 value={terminalRenameDraft}
1569 aria-label="Rename terminal"
1570 className="h-6 min-w-0 flex-1 rounded border border-input bg-background px-1.5 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
1571 onFocus={(event) => event.currentTarget.select()}
1572 onChange={(event) =>
1573 setTerminalRenameDraft(event.currentTarget.value)
1574 }
1575 onBlur={() => {
1576 if (cancelTerminalRenameRef.current) {
1577 cancelTerminalRenameRef.current = false;
1578 return;
1579 }
1580 finishTerminalRename();
1581 }}
1582 onKeyDown={(event) => {
1583 event.stopPropagation();
1584 if (event.nativeEvent.isComposing) return;
1585 if (event.key === "Enter") {
1586 event.preventDefault();
1587 finishTerminalRename();
1588 } else if (event.key === "Escape") {
1589 event.preventDefault();
1590 cancelTerminalRename();
1591 }
1592 }}
1593 />
1594 ) : (
1595 <button
1596 type="button"
1597 className="flex min-w-0 flex-1 items-center text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
1598 title={`${displayLabel}${customLabel && customLabel !== automaticLabel ? ` · ${automaticLabel}` : ""} — ${resolveTerminalLaunchLocation(terminalId).cwd}`}
1599 onClick={() => onActiveTerminalChange(terminalId)}
1600 onDoubleClick={(event) => {
1601 event.preventDefault();
1602 onActiveTerminalChange(terminalId);
1603 startTerminalRename(terminalId);
1604 }}
1506 Group {groupIndex + 1}
1507 </button>
1508 )}
1509
1510 <div
1511 className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""}
1512 >
1513 {terminalGroup.terminalIds.map((terminalId) => {
1514 const isActive = terminalId === resolvedActiveTerminalId;
1515 const closeTerminalLabel = `Close ${
1516 terminalLabelById.get(terminalId) ?? "terminal"
1517 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
1518 return (
1519 <div
1520 key={terminalId}
1521 className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${
1522 isActive
1523 ? "bg-accent text-foreground"
1524 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1525 }`}
16051526 >
1606 <span className="min-w-0 flex-1 truncate">
1607 {displayLabel}
1608 {customLabel && customLabel !== automaticLabel ? (
1609 <span className="font-normal text-muted-foreground/80">
1610 {` · ${automaticLabel}`}
1611 </span>
1612 ) : null}
1613 </span>
1614 </button>
1615 )}
1616 </div>
1617 );
1618 })}
1619 </div>
1620 </div>
1621 );
1622 })}
1623 </div>
1624 </aside>
1527 {showGroupHeaders && (
1528 <span className="text-[10px] text-muted-foreground/80">└</span>
1529 )}
1530 <button
1531 type="button"
1532 className="flex min-w-0 flex-1 items-center gap-1 text-left"
1533 onClick={() => onActiveTerminalChange(terminalId)}
1534 >
1535 <TerminalSquare className="size-3 shrink-0" />
1536 <span className="truncate">
1537 {terminalLabelById.get(terminalId) ?? "Terminal"}
1538 </span>
1539 </button>
1540 {normalizedTerminalIds.length > 1 && (
1541 <Popover>
1542 <PopoverTrigger
1543 openOnHover
1544 render={
1545 <button
1546 type="button"
1547 className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
1548 onClick={() => onCloseTerminal(terminalId)}
1549 aria-label={closeTerminalLabel}
1550 />
1551 }
1552 >
1553 <XIcon className="size-2.5" />
1554 </PopoverTrigger>
1555 <PopoverPopup
1556 tooltipStyle
1557 side="bottom"
1558 sideOffset={6}
1559 align="center"
1560 className="pointer-events-none select-none"
1561 >
1562 {closeTerminalLabel}
1563 </PopoverPopup>
1564 </Popover>
1565 )}
1566 </div>
1567 );
1568 })}
1569 </div>
1570 </div>
1571 );
1572 })}
1573 </div>
1574 </aside>
1575 )}
1576 </div>
16251577 </div>
16261578 </aside>
16271579 );
apps/web/src/components/WorkspacePageContainer.tsx deleted-62
......@@ -1,62 +0,0 @@
1import type { ComponentPropsWithoutRef } from "react";
2
3import { cn } from "../lib/utils";
4import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
5
6export type WorkspacePageWidth = "readable" | "wide" | "expanded";
7
8const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
9 readable: "max-w-4xl",
10 wide: "max-w-5xl",
11 expanded: "max-w-6xl",
12};
13
14/** Shared full-page frame for workspace routes beneath their top bar. */
15export function WorkspacePageContainer({
16 width = "readable",
17 className,
18 ...props
19}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
20 return (
21 <div
22 className={cn(
23 "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",
24 WIDTH_CLASS[width],
25 className,
26 )}
27 {...props}
28 />
29 );
30}
31
32/** Shared top-bar geometry for every full-width workspace surface. */
33export function WorkspacePageHeader({
34 electron = false,
35 reserveNativeControls = electron,
36 className,
37 ...props
38}: ComponentPropsWithoutRef<"header"> & {
39 readonly electron?: boolean;
40 readonly reserveNativeControls?: boolean;
41}) {
42 return (
43 <header
44 className={cn(
45 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-3 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
46 electron && "drag-region",
47 reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",
48 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
49 className,
50 )}
51 {...props}
52 />
53 );
54}
55
56/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
57export function WorkspacePageHeaderEdgeControl({
58 className,
59 ...props
60}: ComponentPropsWithoutRef<"div">) {
61 return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;
62}
apps/web/src/components/chat/ChangedFilesTree.test.tsx+12-11
......@@ -23,9 +23,13 @@ describe("ChangedFilesCard", () => {
2323 expect(markup).toContain('data-changed-files-state="expanded"');
2424 expect(markup).toContain('aria-expanded="true"');
2525 expect(markup).toContain("whitespace-nowrap");
26 expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1');
26 expect(markup).toContain(
27 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden',
28 );
2729 expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap');
28 expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>');
30 expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate');
31 expect(markup).toContain("@[24rem]/changed-files:inline");
32 expect(markup).not.toContain("sm:inline");
2933 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');
3034 expect(markup).toContain("!size-[22px]");
3135 expect(markup).toContain("size-3");
......@@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => {
3438 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
3539 expect(markup).toContain("1 changed file");
3640 expect(markup).not.toContain("1 changed files");
37 expect(markup).not.toContain("Hide files");
38 expect(markup).not.toContain("ml-auto");
3941 });
4042
41 it("renders a clean representative-file preview for a large latest change", () => {
43 it("renders a scope and representative-file preview for a large latest change", () => {
4244 const markup = renderToStaticMarkup(
4345 <ChangedFilesCard
4446 turnId={TurnId.make("turn-1")}
......@@ -65,15 +67,14 @@ describe("ChangedFilesCard", () => {
6567
6668 expect(markup).toContain('data-changed-files-state="preview"');
6769 expect(markup).toContain('aria-expanded="false"');
68 expect(markup).toContain("apps/web/src/");
69 expect(markup).toContain("packages/shared/src/");
70 expect(markup).toContain("apps");
71 expect(markup).toContain("2 files");
72 expect(markup).toContain("packages");
73 expect(markup).toContain("root");
7074 expect(markup).toContain("App.tsx");
7175 expect(markup).toContain("git.ts");
7276 expect(markup).toContain("README.md");
73 expect(markup).not.toContain("basis-0");
74 expect(markup).not.toContain("+1 more");
75 expect(markup).not.toContain("Show files");
76 expect(markup).toContain('aria-label="120 additions, 20 deletions"');
77 expect(markup).toContain("Show all 4 files");
7778 expect(markup).not.toContain("App.test.tsx");
7879 });
7980
apps/web/src/components/chat/ChangedFilesTree.tsx+39-23
......@@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
1919import { PierreEntryIcon } from "./PierreEntryIcon";
2020import { Button } from "../ui/button";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation";
22import {
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
2327
2428const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2529
......@@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
4650 onOpenTurnDiff,
4751 } = props;
4852 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
4954 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
5055 const compactPreviewVisible = showCompactPreview && !expanded;
5156
5257 return (
5358 <div
54 className="@container/changed-files mt-4 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32"
59 className="@container/changed-files mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
5560 data-changed-files-state={
5661 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
5762 }
......@@ -59,7 +64,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
5964 <div
6065 data-changed-files-header=""
6166 className={cn(
62 "flex items-center justify-between gap-2 rounded-lg px-1",
67 "flex items-center justify-between gap-2 rounded-xl px-1",
6368 expanded &&
6469 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
6570 )}
......@@ -68,7 +73,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
6873 type="button"
6974 aria-expanded={expanded}
7075 data-scroll-anchor-ignore
71 className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
76 className="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
7277 onClick={() => onExpandedChange(!expanded)}
7378 >
7479 <ChevronRightIcon
......@@ -91,6 +96,9 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
9196 />
9297 )}
9398 </span>
99 <span className="ml-1 hidden min-w-0 flex-1 truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 @[24rem]/changed-files:inline">
100 {expanded ? "Hide files" : "Show files"}
101 </span>
94102 </button>
95103 <div className="flex shrink-0 items-center gap-1.5">
96104 {expanded ? (
......@@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
150158 onOpenTurnDiff={onOpenTurnDiff}
151159 />
152160 ) : compactPreviewVisible ? (
153 <div className="px-1.5 pb-1 pt-0.5">
154 <div className="flex min-w-0 items-center gap-x-3 overflow-hidden">
161 <div className="px-2 pb-1.5 pt-1">
162 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
163 {scopeSummary.map((scope, index) => (
164 <span key={scope.label} className="inline-flex items-center gap-1">
165 {index > 0 ? <span aria-hidden="true">·</span> : null}
166 <span className="font-mono text-foreground/75">{scope.label}</span>
167 <span>
168 {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"}
169 </span>
170 </span>
171 ))}
172 </p>
173 <div className="mt-2 flex flex-wrap items-center gap-1.5">
155174 {previewFiles.map((file) => (
156175 <button
157176 key={file.path}
158177 type="button"
159178 title={file.path}
160 className="group flex min-h-6 min-w-0 max-w-72 shrink items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
179 className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
161180 onClick={() => onOpenTurnDiff(turnId, file.path)}
162181 >
163182 <PierreEntryIcon
164183 pathValue={file.path}
165184 kind="file"
166185 theme={resolvedTheme}
167 className="size-3.5 shrink-0 text-muted-foreground/70"
186 className="size-3 shrink-0 text-muted-foreground/70"
168187 />
169 <span className="min-w-0 truncate font-mono text-xs text-foreground/75 group-hover:text-foreground">
170 {changedFileName(file.path)}
171 </span>
172 {hasNonZeroStat(file) ? (
173 <DiffStatLabel
174 additions={file.additions}
175 deletions={file.deletions}
176 layout="inline"
177 className="shrink-0 text-[10px]"
178 />
179 ) : null}
188 <span className="truncate">{changedFileName(file.path)}</span>
180189 </button>
181190 ))}
191 <button
192 type="button"
193 className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
194 onClick={() => onExpandedChange(true)}
195 >
196 Show all {files.length} files
197 </button>
182198 </div>
183199 </div>
184200 ) : null}
......@@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
254270 ) : (
255271 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
256272 )}
257 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
273 <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
258274 {node.name}
259275 </span>
260276 {hasNonZeroStat(node.stat) && (
261 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">
277 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
262278 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
263279 </span>
264280 )}
......@@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
289305 theme={resolvedTheme}
290306 className="size-3.5 text-muted-foreground/70"
291307 />
292 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
308 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
293309 {node.name}
294310 </span>
295311 {node.stat && (
296 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">
312 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
297313 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
298314 </span>
299315 )}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+7-209
......@@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => {
531531
532532 expect(expandedRows.map((row) => row.id)).toEqual([
533533 "user-entry",
534 "assistant-thought-entry",
535 "work-toggle:work-entry-1",
536534 "turn-fold:turn-1",
535 "assistant-thought-entry",
536 "work-entry-1",
537537 "assistant-final-entry",
538538 ]);
539539 expect(
......@@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => {
638638 expect(foldRow?.label).toBe("Worked for 12s");
639639 });
640640
641 it("keeps a superseded turn fold beside the final response after a steer", () => {
642 const rows = deriveMessagesTimelineRows({
643 timelineEntries: [
644 {
645 id: "initial-user-entry",
646 kind: "message",
647 createdAt: "2026-01-01T00:00:00Z",
648 message: {
649 id: "initial-user" as never,
650 role: "user",
651 text: "Start the work",
652 turnId: null,
653 createdAt: "2026-01-01T00:00:00Z",
654 updatedAt: "2026-01-01T00:00:00Z",
655 streaming: false,
656 },
657 },
658 {
659 id: "superseded-work-entry",
660 kind: "work",
661 createdAt: "2026-01-01T00:00:10Z",
662 entry: {
663 id: "superseded-work",
664 createdAt: "2026-01-01T00:00:10Z",
665 turnId: "turn-1" as never,
666 label: "Ran command",
667 tone: "tool",
668 },
669 },
670 {
671 id: "steer-user-entry",
672 kind: "message",
673 createdAt: "2026-01-01T00:00:12Z",
674 message: {
675 id: "steer-user" as never,
676 role: "user",
677 text: "Change the approach",
678 turnId: null,
679 createdAt: "2026-01-01T00:00:12Z",
680 updatedAt: "2026-01-01T00:00:12Z",
681 streaming: false,
682 },
683 },
684 {
685 id: "assistant-final-entry",
686 kind: "message",
687 createdAt: "2026-01-01T00:00:20Z",
688 message: {
689 id: "assistant-final" as never,
690 role: "assistant",
691 text: "Implemented locally, uncommitted.",
692 turnId: "turn-2" as never,
693 createdAt: "2026-01-01T00:00:20Z",
694 updatedAt: "2026-01-01T00:00:21Z",
695 streaming: false,
696 },
697 },
698 ],
699 latestTurn: {
700 turnId: "turn-2" as never,
701 state: "completed",
702 startedAt: "2026-01-01T00:00:12Z",
703 completedAt: "2026-01-01T00:00:21Z",
704 },
705 isWorking: false,
706 activeTurnStartedAt: null,
707 turnDiffSummaryByAssistantMessageId: new Map(),
708 revertTurnCountByUserMessageId: new Map(),
709 });
710
711 expect(rows.map((row) => row.id)).toEqual([
712 "initial-user-entry",
713 "steer-user-entry",
714 "turn-fold:turn-1",
715 "assistant-final-entry",
716 ]);
717 });
718
719641 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
720642 const rows = deriveMessagesTimelineRows({
721643 timelineEntries: [
......@@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => {
849771 turnId: "turn-1" as never,
850772 label: "Ran command",
851773 tone: "tool" as const,
852 toolLifecycleStatus: "inProgress" as const,
853774 },
854775 },
855776 ],
......@@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => {
867788
868789 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
869790 expect(rows.map((row) => row.id)).toEqual([
870 "working-indicator-row",
871791 "assistant-thought-entry",
872 "work-live:work-entry-1",
873 ]);
874 });
875
876 it("keeps the current tool batch expandable while live entries append", () => {
877 const timelineEntries = [
878 {
879 id: "work-entry-1",
880 kind: "work" as const,
881 createdAt: "2026-01-01T00:00:01Z",
882 entry: {
883 id: "work-1",
884 createdAt: "2026-01-01T00:00:01Z",
885 turnId: "turn-1" as never,
886 toolCallId: "call-1",
887 label: "Read file",
888 tone: "tool" as const,
889 },
890 },
891 {
892 id: "work-entry-2",
893 kind: "work" as const,
894 createdAt: "2026-01-01T00:00:02Z",
895 entry: {
896 id: "work-2",
897 createdAt: "2026-01-01T00:00:02Z",
898 turnId: "turn-1" as never,
899 toolCallId: "call-2",
900 label: "Run command",
901 command: "vp test run",
902 tone: "tool" as const,
903 },
904 },
905 ];
906 const baseInput = {
907 timelineEntries,
908 latestTurn: {
909 turnId: "turn-1" as never,
910 state: "running" as const,
911 startedAt: "2026-01-01T00:00:00Z",
912 completedAt: null,
913 },
914 isWorking: true,
915 activeTurnStartedAt: "2026-01-01T00:00:00Z",
916 turnDiffSummaryByAssistantMessageId: new Map(),
917 revertTurnCountByUserMessageId: new Map(),
918 };
919
920 const collapsedRows = deriveMessagesTimelineRows(baseInput);
921 const expandedRows = deriveMessagesTimelineRows({
922 ...baseInput,
923 expandedWorkGroupIds: new Set(["work-group:tool:call-1"]),
924 });
925
926 expect(collapsedRows.map((row) => row.id)).toEqual([
792 "work-entry-1",
927793 "working-indicator-row",
928 "work-live:tool:call-1",
929794 ]);
930 expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({
931 groupId: "work-group:tool:call-1",
932 expanded: false,
933 groupedEntries: [{ id: "work-1" }, { id: "work-2" }],
934 });
935 expect(expandedRows.map((row) => row.id)).toEqual([
936 "working-indicator-row",
937 "work-live:tool:call-1",
938 "work-1",
939 "work-2",
940 ]);
941 expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({
942 groupId: "work-group:tool:call-1",
943 expanded: true,
944 });
945
946 const appendedRows = deriveMessagesTimelineRows({
947 ...baseInput,
948 timelineEntries: [
949 ...timelineEntries,
950 {
951 id: "work-entry-3",
952 kind: "work" as const,
953 createdAt: "2026-01-01T00:00:03Z",
954 entry: {
955 id: "work-3",
956 createdAt: "2026-01-01T00:00:03Z",
957 turnId: "turn-1" as never,
958 toolCallId: "call-3",
959 label: "Changed file",
960 tone: "tool" as const,
961 },
962 },
963 ],
964 expandedWorkGroupIds: new Set(["work-group:tool:call-1"]),
965 });
966
967 expect(appendedRows.map((row) => row.id)).toEqual([
968 "working-indicator-row",
969 "work-live:tool:call-1",
970 "work-1",
971 "work-2",
972 "work-3",
973 ]);
974
975 const rowsWithLaterPlan = deriveMessagesTimelineRows({
976 ...baseInput,
977 timelineEntries: [
978 ...timelineEntries,
979 {
980 id: "plan:thread-1:turn:turn-1",
981 kind: "proposed-plan" as const,
982 createdAt: "2026-01-01T00:00:03Z",
983 proposedPlan: {
984 id: "plan:thread-1:turn:turn-1",
985 turnId: "turn-1" as never,
986 planMarkdown: "# Next steps",
987 implementedAt: null,
988 implementationThreadId: null,
989 createdAt: "2026-01-01T00:00:03Z",
990 updatedAt: "2026-01-01T00:00:03Z",
991 },
992 },
993 ],
994 });
995 expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false);
996 expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true);
997795 });
998796
999797 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => {
1054852 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
1055853 "turn-1",
1056854 ]);
1057 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
855 expect(rows.map((row) => row.id)).toContain("running-work-entry");
1058856 });
1059857
1060858 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => {
1196994 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
1197995 });
1198996
1199 expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]);
997 expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
1200998 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1201999 groupId: "work-group:work-entry-1",
1202 hiddenCount: 3,
1000 hiddenCount: 2,
12031001 expanded: false,
12041002 onlyToolEntries: true,
12051003 });
12061004 expect(expandedRows.map((row) => row.id)).toEqual([
1207 "work-toggle:work-entry-1",
12081005 "work-1",
12091006 "work-2",
12101007 "work-3",
1008 "work-toggle:work-entry-1",
12111009 ]);
12121010 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
12131011 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+30-382
......@@ -1,7 +1,6 @@
11import * as Equal from "effect/Equal";
22import {
33 formatDuration,
4 workEntryIndicatesToolFailure,
54 workEntryIndicatesToolNeutralStatus,
65 workLogEntryIsToolLike,
76 type TimelineEntry,
......@@ -167,17 +166,6 @@ export type MessagesTimelineRow =
167166 id: string;
168167 createdAt: string;
169168 groupedEntries: WorkLogEntry[];
170 isExpandedToolGroupEntry: boolean;
171 isLastExpandedToolGroupEntry: boolean;
172 }
173 | {
174 kind: "work-live";
175 id: string;
176 createdAt: string;
177 entry: WorkLogEntry;
178 groupedEntries: WorkLogEntry[];
179 groupId: string;
180 expanded: boolean;
181169 }
182170 | {
183171 kind: "work-toggle";
......@@ -187,9 +175,6 @@ export type MessagesTimelineRow =
187175 hiddenCount: number;
188176 expanded: boolean;
189177 onlyToolEntries: boolean;
190 summary: string | null;
191 summaryKind: ToolGroupAction | "mixed" | null;
192 hasFailure: boolean;
193178 }
194179 | {
195180 kind: "turn-fold";
......@@ -223,12 +208,7 @@ export type MessagesTimelineRow =
223208 createdAt: string;
224209 turnPlan: TurnPlanEntry;
225210 }
226 | {
227 kind: "working";
228 id: string;
229 createdAt: string | null;
230 showThinking: boolean;
231 };
211 | { kind: "working"; id: string; createdAt: string | null };
232212
233213export interface StableMessagesTimelineRowsState {
234214 byId: Map<string, MessagesTimelineRow>;
......@@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string {
258238 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
259239}
260240
261type ToolGroupAction = "read" | "edit" | "command" | "search" | "other";
262
263function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
264 if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read";
265 if (
266 entry.requestKind === "file-change" ||
267 entry.itemType === "file_change" ||
268 (entry.changedFiles?.length ?? 0) > 0
269 ) {
270 return "edit";
271 }
272 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
273 return "command";
274 }
275 if (entry.itemType === "web_search") return "search";
276 return "other";
277}
278
279function toolGroupActionCount(
280 action: ToolGroupAction,
281 entries: ReadonlyArray<WorkLogEntry>,
282): number {
283 if (action !== "edit") return entries.length;
284
285 const changedFiles = new Set<string>();
286 let editsWithoutFileDetails = 0;
287 for (const entry of entries) {
288 if (!entry.changedFiles || entry.changedFiles.length === 0) {
289 editsWithoutFileDetails += 1;
290 continue;
291 }
292 for (const file of entry.changedFiles) changedFiles.add(file);
293 }
294 return changedFiles.size + editsWithoutFileDetails;
295}
296
297function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
298 switch (action) {
299 case "read":
300 return `Read ${count} ${count === 1 ? "file" : "files"}`;
301 case "edit":
302 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
303 case "command":
304 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
305 case "search":
306 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
307 case "other":
308 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
309 }
310}
311
312/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
313export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
314 const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
315 for (const entry of entries) {
316 const action = toolGroupAction(entry);
317 const group = groupedEntries.get(action);
318 if (group) group.push(entry);
319 else groupedEntries.set(action, [entry]);
320 }
321 const labels = [...groupedEntries].map(([action, actionEntries]) =>
322 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
323 );
324 const sentenceLabels = labels.map((label, index) =>
325 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
326 );
327 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
328 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
329 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
330}
331
332function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupAction | "mixed" {
333 const actions = new Set(entries.map(toolGroupAction));
334 return actions.size === 1 ? actions.values().next().value! : "mixed";
335}
336
337function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
338 return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId;
339}
340
341function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {
342 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;
343}
344
345241export function resolveAssistantMessageCopyState({
346242 text,
347243 showCopyButton,
......@@ -414,34 +310,17 @@ function deriveUnsettledTurnId(
414310 return isSettled ? null : latestTurn.turnId;
415311}
416312
417function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number {
418 return timelineEntries.findLastIndex(
419 (entry) => entry.kind === "message" && entry.message.role === "user",
420 );
421}
422
423function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
424 if (entry.kind === "message") {
425 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;
426 }
427 if (entry.kind === "turn-plan") {
428 return entry.turnPlan.turnId;
429 }
430 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
431}
432
433313/**
434314 * Settled turns fold their commentary and tool activity behind a
435 * "Worked for ..." row placed immediately before the next terminal assistant
436 * response. A steer can split one visible response across turn ids, so tying
437 * the disclosure to the first hidden entry would strand it above the steer.
315 * "Worked for ..." row anchored at the turn's first foldable entry; the
316 * terminal assistant message stays visible below the fold.
438317 */
439318function deriveTurnFolds(input: {
440319 timelineEntries: ReadonlyArray<TimelineEntry>;
441320 terminalAssistantMessageIds: ReadonlySet<string>;
442321 latestTurn: TimelineLatestTurn | null;
443322 unsettledTurnId: TurnId | null;
444}): ReadonlyMap<string, ReadonlyArray<TurnFold>> {
323}): ReadonlyMap<string, TurnFold> {
445324 interface TurnGroup {
446325 entries: Array<TimelineEntry>;
447326 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
......@@ -496,7 +375,7 @@ function deriveTurnFolds(input: {
496375 }
497376 }
498377
499 const foldsByAnchorEntryId = new Map<string, TurnFold[]>();
378 const foldsByAnchorEntryId = new Map<string, TurnFold>();
500379 for (const [turnId, group] of groupsByTurnId) {
501380 if (turnId === input.unsettledTurnId) {
502381 continue;
......@@ -526,24 +405,6 @@ function deriveTurnFolds(input: {
526405 if (!firstEntry || !lastEntry) {
527406 continue;
528407 }
529 const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) =>
530 hiddenEntryIds.has(entry.id),
531 );
532 if (lastHiddenEntryIndex < 0) {
533 continue;
534 }
535 const nextTerminalAssistantEntry = input.timelineEntries
536 .slice(lastHiddenEntryIndex + 1)
537 .find(
538 (entry) =>
539 entry.kind === "message" &&
540 entry.message.role === "assistant" &&
541 input.terminalAssistantMessageIds.has(entry.message.id),
542 );
543 const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex];
544 if (!anchorEntry) {
545 continue;
546 }
547408
548409 const isLatestInterruptedTurn =
549410 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
......@@ -570,16 +431,13 @@ function deriveTurnFolds(input: {
570431 ? `Worked for ${duration}`
571432 : "Worked";
572433
573 const fold = {
434 foldsByAnchorEntryId.set(firstEntry.id, {
574435 turnId,
575 anchorEntryId: anchorEntry.id,
576 createdAt: anchorEntry.createdAt,
436 anchorEntryId: firstEntry.id,
437 createdAt: firstEntry.createdAt,
577438 hiddenEntryIds,
578439 label,
579 };
580 const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id);
581 if (anchoredFolds) anchoredFolds.push(fold);
582 else foldsByAnchorEntryId.set(anchorEntry.id, [fold]);
440 });
583441 }
584442 return foldsByAnchorEntryId;
585443}
......@@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: {
611469 unsettledTurnId,
612470 });
613471 const collapsedEntryIds = new Set<string>();
614 for (const folds of foldsByAnchorEntryId.values()) {
615 for (const fold of folds) {
616 if (!input.expandedTurnIds?.has(fold.turnId)) {
617 for (const entryId of fold.hiddenEntryIds) {
618 collapsedEntryIds.add(entryId);
619 }
472 for (const fold of foldsByAnchorEntryId.values()) {
473 if (!input.expandedTurnIds?.has(fold.turnId)) {
474 for (const entryId of fold.hiddenEntryIds) {
475 collapsedEntryIds.add(entryId);
620476 }
621477 }
622478 }
623479
624 let activeTurnHeaderIndex = input.timelineEntries.length;
625 if (input.isWorking) {
626 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
627 const firstOwnedAfterUser =
628 unsettledTurnId === null
629 ? -1
630 : input.timelineEntries.findIndex(
631 (entry, index) =>
632 index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
633 );
634 activeTurnHeaderIndex =
635 firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
636 }
637 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
638 input.isWorking &&
639 index >= activeTurnHeaderIndex &&
640 (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
641 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
642 workLogEntryIsToolLike(entry) &&
643 (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry));
644 const activeEntries = input.isWorking
645 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
646 : [];
647 const activeTurnHasVisibleContent =
648 activeEntries.some((entry) => {
649 if (entry.kind === "message") {
650 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
651 }
652 if (entry.kind === "work") {
653 return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry);
654 }
655 if (entry.kind === "turn-plan") return true;
656 return false;
657 }) ||
658 input.timelineEntries
659 .slice(activeTurnHeaderIndex)
660 .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan");
661
662 const activeWorkEntryIds = new Set<string>();
663 const activeWorkRowsByAnchorId = new Map<
664 string,
665 Extract<MessagesTimelineRow, { kind: "work-live" }>
666 >();
667 const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false);
668 for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) {
669 const entry = input.timelineEntries[index];
670 if (!entry) continue;
671 const isVisibleTurnContent =
672 (entry.kind === "message" && entry.message.role === "user") ||
673 entry.kind === "proposed-plan" ||
674 (entryBelongsToActiveTurn(entry, index) &&
675 ((entry.kind === "message" && entry.message.role === "assistant") ||
676 entry.kind === "turn-plan" ||
677 (entry.kind === "work" &&
678 entry.entry.agentSpawn === undefined &&
679 isVisibleActiveToolEntry(entry.entry))));
680 hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true;
681 }
682
683 for (let index = 0; index < input.timelineEntries.length; index += 1) {
684 const entry = input.timelineEntries[index];
685 if (
686 !entry ||
687 entry.kind !== "work" ||
688 entry.entry.agentSpawn !== undefined ||
689 !entryBelongsToActiveTurn(entry, index)
690 ) {
691 continue;
692 }
693 if (!isVisibleActiveToolEntry(entry.entry)) {
694 continue;
695 }
696
697 const anchorEntry = entry;
698 let latestToolEntry = entry;
699 const batchEntryIds = [entry.id];
700 const visibleBatchEntries = [entry.entry];
701 let cursor = index + 1;
702 while (cursor < input.timelineEntries.length) {
703 const nextEntry = input.timelineEntries[cursor];
704 if (
705 !nextEntry ||
706 nextEntry.kind !== "work" ||
707 nextEntry.entry.agentSpawn !== undefined ||
708 !entryBelongsToActiveTurn(nextEntry, cursor)
709 ) {
710 break;
711 }
712 batchEntryIds.push(nextEntry.id);
713 if (isVisibleActiveToolEntry(nextEntry.entry)) {
714 latestToolEntry = nextEntry;
715 visibleBatchEntries.push(nextEntry.entry);
716 }
717 cursor += 1;
718 }
719
720 // Once newer commentary, a plan, or another tool batch exists, this batch
721 // is history. Let the regular work-group path turn it into an expandable
722 // summary so none of its calls disappear behind the live one-line view.
723 if (hasLaterTurnContent[cursor] !== true) {
724 for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId);
725 const groupId = workGroupId(anchorEntry.id, anchorEntry.entry);
726 activeWorkRowsByAnchorId.set(anchorEntry.id, {
727 kind: "work-live",
728 id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`,
729 createdAt: anchorEntry.createdAt,
730 entry: latestToolEntry.entry,
731 groupedEntries: visibleBatchEntries,
732 groupId,
733 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
734 });
735 }
736 index = cursor - 1;
737 }
738
739480 for (let index = 0; index < input.timelineEntries.length; index += 1) {
740481 const timelineEntry = input.timelineEntries[index];
741482 if (!timelineEntry) {
742483 continue;
743484 }
744485
745 if (input.isWorking && index === activeTurnHeaderIndex) {
486 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
487 if (turnFold) {
746488 nextRows.push({
747 kind: "working",
748 id: "working-indicator-row",
749 createdAt: input.activeTurnStartedAt,
750 showThinking: !activeTurnHasVisibleContent,
489 kind: "turn-fold",
490 id: `turn-fold:${turnFold.turnId}`,
491 createdAt: turnFold.createdAt,
492 turnId: turnFold.turnId,
493 label: turnFold.label,
494 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
751495 });
752496 }
753497
754 const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id);
755 if (anchoredTurnFolds) {
756 for (const turnFold of anchoredTurnFolds) {
757 nextRows.push({
758 kind: "turn-fold",
759 id: `turn-fold:${turnFold.turnId}`,
760 createdAt: turnFold.createdAt,
761 turnId: turnFold.turnId,
762 label: turnFold.label,
763 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
764 });
765 }
766 }
767
768498 if (collapsedEntryIds.has(timelineEntry.id)) {
769499 continue;
770500 }
771501
772 if (activeWorkEntryIds.has(timelineEntry.id)) {
773 const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id);
774 if (activeWorkRow) {
775 nextRows.push(activeWorkRow);
776 if (activeWorkRow.expanded) {
777 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
778 nextRows.push({
779 kind: "work",
780 id: workEntry.id,
781 createdAt: workEntry.createdAt,
782 groupedEntries: [workEntry],
783 isExpandedToolGroupEntry: true,
784 isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
785 });
786 }
787 }
788 }
789 continue;
790 }
791
792502 if (timelineEntry.kind === "work") {
793503 const groupedEntries = [timelineEntry.entry];
794504 let cursor = index + 1;
......@@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: {
797507 if (
798508 !nextEntry ||
799509 nextEntry.kind !== "work" ||
800 activeWorkEntryIds.has(nextEntry.id) ||
801510 collapsedEntryIds.has(nextEntry.id) ||
802511 foldsByAnchorEntryId.has(nextEntry.id)
803512 ) {
......@@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: {
810519 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
811520 );
812521 if (visibleGroupedEntries.length > 0) {
813 const onlyToolEntries = visibleGroupedEntries.every(
814 (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined,
815 );
816 if (onlyToolEntries) {
817 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
818 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
819 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
820 nextRows.push({
821 kind: "work-toggle",
822 id: `work-toggle:${timelineEntry.id}`,
823 createdAt: timelineEntry.createdAt,
824 groupId,
825 hiddenCount: visibleGroupedEntries.length,
826 expanded,
827 onlyToolEntries: true,
828 summary: summarizeToolGroup(visibleGroupedEntries),
829 summaryKind,
830 hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)),
831 });
832 if (expanded) {
833 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
834 nextRows.push({
835 kind: "work",
836 id: workEntry.id,
837 createdAt: workEntry.createdAt,
838 groupedEntries: [workEntry],
839 isExpandedToolGroupEntry: true,
840 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
841 });
842 }
843 }
844 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
522 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
845523 nextRows.push({
846524 kind: "work",
847525 id: timelineEntry.id,
848526 createdAt: timelineEntry.createdAt,
849527 groupedEntries: visibleGroupedEntries,
850 isExpandedToolGroupEntry: false,
851 isLastExpandedToolGroupEntry: false,
852528 });
853529 } else {
854 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
530 const groupId = `work-group:${timelineEntry.id}`;
855531 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
856532 // Agent-spawn CTA rows are always visible: a running fleet must
857533 // never hide behind a "+N tool calls" toggle. Selection is by
......@@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: {
875551 id: workEntry.id,
876552 createdAt: workEntry.createdAt,
877553 groupedEntries: [workEntry],
878 isExpandedToolGroupEntry: false,
879 isLastExpandedToolGroupEntry: false,
880554 });
881555 }
882556
......@@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: {
888562 groupId,
889563 hiddenCount: hiddenEntries.length,
890564 expanded,
891 onlyToolEntries,
892 summary: null,
893 summaryKind: null,
894 hasFailure: visibleGroupedEntries.some((entry) =>
895 workEntryIndicatesToolFailure(entry),
565 onlyToolEntries: visibleGroupedEntries.every((entry) =>
566 workLogEntryIsToolLike(entry),
896567 ),
897568 });
898569 }
......@@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: {
958629 });
959630 }
960631
961 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
632 if (input.isWorking) {
962633 nextRows.push({
963634 kind: "working",
964635 id: "working-indicator-row",
965636 createdAt: input.activeTurnStartedAt,
966 showThinking: !activeTurnHasVisibleContent,
967637 });
968638 }
969639
......@@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
996666
997667 switch (a.kind) {
998668 case "working":
999 return (
1000 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1001 );
669 return a.createdAt === (b as typeof a).createdAt;
1002670
1003671 case "turn-fold": {
1004672 const bf = b as typeof a;
......@@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1015683 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
1016684 }
1017685
1018 case "work": {
1019 const bw = b as typeof a;
1020 return (
1021 a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&
1022 a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
1023 Equal.equals(a.groupedEntries, bw.groupedEntries)
1024 );
1025 }
1026
1027 case "work-live": {
1028 const bw = b as typeof a;
1029 return (
1030 a.createdAt === bw.createdAt &&
1031 a.groupId === bw.groupId &&
1032 a.expanded === bw.expanded &&
1033 Equal.equals(a.entry, bw.entry) &&
1034 Equal.equals(a.groupedEntries, bw.groupedEntries)
1035 );
1036 }
686 case "work":
687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
1037688
1038689 case "work-toggle": {
1039690 const bw = b as typeof a;
......@@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1042693 a.groupId === bw.groupId &&
1043694 a.hiddenCount === bw.hiddenCount &&
1044695 a.expanded === bw.expanded &&
1045 a.onlyToolEntries === bw.onlyToolEntries &&
1046 a.summary === bw.summary &&
1047 a.summaryKind === bw.summaryKind &&
1048 a.hasFailure === bw.hasFailure
696 a.onlyToolEntries === bw.onlyToolEntries
1049697 );
1050698 }
1051699
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-44
......@@ -554,49 +554,7 @@ describe("MessagesTimeline", () => {
554554 expect(markup).toContain("Work Log");
555555 });
556556
557 it("makes the whole live tool row expandable without adding a chevron", () => {
558 const turnId = TurnId.make("turn-live-tools");
559 const markup = renderToStaticMarkup(
560 <MessagesTimeline
561 {...buildProps()}
562 isWorking
563 activeTurnInProgress
564 activeTurnStartedAt="2026-03-17T19:12:28.000Z"
565 latestTurn={{
566 turnId,
567 state: "running",
568 startedAt: "2026-03-17T19:12:28.000Z",
569 completedAt: null,
570 }}
571 runningTurnId={turnId}
572 timelineEntries={[
573 {
574 id: "entry-live-tool",
575 kind: "work",
576 createdAt: "2026-03-17T19:12:29.000Z",
577 entry: {
578 id: "work-live-tool",
579 createdAt: "2026-03-17T19:12:29.000Z",
580 turnId,
581 toolCallId: "call-live-tool",
582 label: "Running tests",
583 command: "env -C /tmp sudo -iu postgres psql",
584 tone: "tool",
585 toolLifecycleStatus: "inProgress",
586 },
587 },
588 ]}
589 />,
590 );
591
592 expect(markup).not.toContain('aria-label="Expand current tool calls"');
593 expect(markup).toContain('aria-expanded="false"');
594 expect(markup).toContain("Running psql");
595 expect(markup).not.toContain("lucide-chevron-right");
596 expect(markup).not.toContain("hover:bg-accent/20");
597 });
598
599 it("summarizes completed changed-file activity", () => {
557 it("formats changed file paths from the workspace root", () => {
600558 const markup = renderToStaticMarkup(
601559 <MessagesTimeline
602560 {...buildProps()}
......@@ -618,7 +576,7 @@ describe("MessagesTimeline", () => {
618576 />,
619577 );
620578
621 expect(markup).toContain("Changed 1 file");
579 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
622580 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
623581 });
624582
apps/web/src/components/chat/MessagesTimeline.tsx+155-336
......@@ -35,6 +35,7 @@ import {
3535 deriveTimelineEntries,
3636 workEntryIndicatesToolFailure,
3737 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
3839 workLogEntryIsToolLike,
3940} from "../../session-logic";
4041import { type TurnDiffSummary } from "../../types";
......@@ -56,6 +57,7 @@ import {
5657 MessageCircleIcon,
5758 MousePointerClickIcon,
5859 PaintbrushIcon,
60 MinusIcon,
5961 SquarePenIcon,
6062 TerminalIcon,
6163 Undo2Icon,
......@@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
918920type TimelineRow = MessagesTimelineRow;
919921
920922const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
921 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
922 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
923 const isExpandedToolGroupHeader =
924 (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) ||
925 (row.kind === "work-live" && row.expanded);
926
927923 return (
928924 <div
929925 className={cn(
930926 // Commentary (non-terminal assistant) rows carry no metadata row, so
931927 // they sit closer to the work that follows them.
932 isExpandedToolGroupEntry
933 ? isLastExpandedToolGroupEntry
934 ? "pb-1"
935 : "pb-0"
936 : isExpandedToolGroupHeader
937 ? "pb-0"
938 : row.kind === "turn-fold"
939 ? "pb-1.5"
940 : (row.kind === "message" &&
941 row.message.role === "assistant" &&
942 !row.showAssistantMeta) ||
943 row.kind === "work" ||
944 row.kind === "work-live" ||
945 row.kind === "work-toggle" ||
946 row.kind === "turn-plan"
947 ? "pb-2"
948 : "pb-4",
928 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
929 row.kind === "work" ||
930 row.kind === "work-toggle" ||
931 row.kind === "turn-plan"
932 ? "pb-2"
933 : "pb-4",
949934 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
950935 )}
951936 data-timeline-row-id={row.id}
......@@ -953,13 +938,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
953938 data-message-id={row.kind === "message" ? row.message.id : undefined}
954939 data-message-role={row.kind === "message" ? row.message.role : undefined}
955940 >
956 {row.kind === "work" ? (
957 <WorkGroupSection
958 groupedEntries={row.groupedEntries}
959 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}
960 />
961 ) : null}
962 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
941 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
963942 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
964943 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
965944 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
......@@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
11041083
11051084function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
11061085 const ctx = use(TimelineRowCtx);
1086 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
11071087
11081088 return (
11091089 <div className="border-b border-border/60 pb-2 pt-1">
......@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
11121092 aria-expanded={row.expanded}
11131093 data-scroll-anchor-ignore
11141094 onClick={() => ctx.onToggleTurnFold(row.turnId)}
1115 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"
1095 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"
11161096 >
11171097 <span>{row.label}</span>
1118 <ChevronRightIcon
1119 className={cn("size-3.5 transition-transform", row.expanded && "-rotate-90")}
1120 />
1098 <Icon className="size-3.5" />
11211099 </button>
11221100 </div>
11231101 );
......@@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
13001278});
13011279
13021280function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1281 const { workingStepLabel } = use(TimelineRowActivityCtx);
13031282 return (
1304 <div>
1305 <div className="border-b border-border/60 pb-2 pt-1">
1306 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1283 <div className="py-0.5 pl-1.5">
1284 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">
1285 <span className="inline-flex items-center gap-[3px]">
1286 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1287 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
1288 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
1289 </span>
1290 <span className="shrink-0">
13071291 {row.createdAt ? (
13081292 <>
13091293 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
13111295 ) : (
13121296 "Working..."
13131297 )}
1314 </div>
1298 </span>
1299 {workingStepLabel ? (
1300 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1301 ) : null}
13151302 </div>
1316 {row.showThinking ? (
1317 <div className="mt-2">
1318 <ThinkingActivityRow />
1319 </div>
1320 ) : null}
13211303 </div>
13221304 );
13231305}
......@@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
13581340/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
13591341const WorkGroupSection = memo(function WorkGroupSection({
13601342 groupedEntries,
1361 isExpandedToolGroupEntry,
13621343}: {
13631344 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1364 isExpandedToolGroupEntry: boolean;
13651345}) {
13661346 const { workspaceRoot } = use(TimelineRowCtx);
13671347 const nonEmptyEntries = useMemo(
......@@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
13781358 if (nonEmptyEntries.length === 0) return null;
13791359
13801360 return (
1381 <section
1382 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1383 aria-label={groupLabel}
1384 >
1361 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
13851362 {!onlyToolEntries && (
13861363 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
13871364 )}
......@@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({
13911368 key={workEntry.id}
13921369 workEntry={workEntry}
13931370 workspaceRoot={workspaceRoot}
1394 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
13951371 />
13961372 ))}
13971373 </div>
......@@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
13991375 );
14001376});
14011377
1402function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
1403 return (
1404 <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
1405 <LiveActivityContent label={label} iconName={iconName} />
1406 <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-0">
1407 <div className="live-activity-focus-counter">
1408 <div className="live-activity-focus-aligned">
1409 <LiveActivityContent label={label} iconName={iconName} highlighted />
1410 </div>
1411 </div>
1412 </div>
1413 </div>
1414 );
1415}
1416
1417function ThinkingActivityRow() {
1418 return <LiveActivityRow label="Thinking" />;
1419}
1420
1421function LiveActivityContent({
1422 label,
1423 iconName,
1424 highlighted = false,
1425}: {
1426 label: string;
1427 iconName: WorkEntryIconName | undefined;
1428 highlighted?: boolean;
1429}) {
1430 return (
1431 <div
1432 className={cn(
1433 "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
1434 highlighted ? "text-foreground" : "text-secondary-label",
1435 )}
1436 >
1437 {iconName ? (
1438 <span
1439 className={cn(
1440 "flex size-6 shrink-0 items-center justify-center",
1441 highlighted ? "text-foreground" : "text-icon-muted",
1442 )}
1443 >
1444 <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />
1445 </span>
1446 ) : null}
1447 <span className="min-w-0 flex-1 truncate">{label}</span>
1448 </div>
1449 );
1450}
1451
1452function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
1453 const ctx = use(TimelineRowCtx);
1454
1455 return (
1456 <button
1457 type="button"
1458 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1459 aria-expanded={row.expanded}
1460 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1461 >
1462 <LiveActivityRow
1463 label={liveWorkEntryLabel(row.entry, ctx.workspaceRoot)}
1464 iconName={workEntryIconName(row.entry)}
1465 />
1466 </button>
1467 );
1468}
1469
1470function toolGroupSummaryIconName(
1471 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
1472): WorkEntryIconName {
1473 switch (kind) {
1474 case "read":
1475 return "eye";
1476 case "edit":
1477 return "square-pen";
1478 case "command":
1479 return "terminal";
1480 case "search":
1481 return "globe";
1482 case "other":
1483 return "wrench";
1484 case "mixed":
1485 case null:
1486 return "hammer";
1487 }
1488}
1489
14901378function WorkGroupToggleTimelineRow({
14911379 row,
14921380}: {
14931381 row: Extract<TimelineRow, { kind: "work-toggle" }>;
14941382}) {
14951383 const ctx = use(TimelineRowCtx);
1496 if (row.onlyToolEntries && row.summary) {
1497 return (
1498 <button
1499 type="button"
1500 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1501 aria-expanded={row.expanded}
1502 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1503 >
1504 <span
1505 className={cn(
1506 "flex size-6 shrink-0 items-center justify-center",
1507 row.hasFailure ? "text-destructive" : "text-icon-muted",
1508 )}
1509 aria-label={row.hasFailure ? "Tool call failed" : undefined}
1510 >
1511 {row.hasFailure ? (
1512 <XIcon className="size-4 shrink-0 stroke-[1.8]" aria-hidden />
1513 ) : (
1514 <WorkEntryIconSvg
1515 name={toolGroupSummaryIconName(row.summaryKind)}
1516 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1517 />
1518 )}
1519 </span>
1520 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1521 </button>
1522 );
1523 }
15241384 const labelNoun = row.onlyToolEntries
15251385 ? row.hiddenCount === 1
15261386 ? "tool call"
......@@ -2159,101 +2019,32 @@ function workEntryPreview(
21592019 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
21602020}
21612021
2162type CommandWrapper = "env" | "sudo";
2163
2164const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
2165 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
2166 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
2167};
2168
2169const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
2170 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]),
2171 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
2172};
2173
2174function commandProgramName(command: string): string | null {
2175 const tokens = command.trim().split(/\s+/);
2176 let index = 0;
2177 let wrapper: CommandWrapper | null = null;
2178
2179 while (index < tokens.length) {
2180 const token = tokens[index]?.replace(/^["']|["']$/g, "");
2181 if (!token) return null;
2182 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
2183 index += 1;
2184 continue;
2185 }
2186 if (token === "env" || token === "sudo") {
2187 wrapper = token;
2188 index += 1;
2189 continue;
2190 }
2191 if (wrapper !== null && token === "--") {
2192 wrapper = null;
2193 index += 1;
2194 continue;
2195 }
2196 if (wrapper !== null && token.startsWith("-")) {
2197 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
2198 if (tokens[index + 1] === undefined) return null;
2199 index += 2;
2200 continue;
2201 }
2202 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) {
2203 index += 1;
2204 continue;
2205 }
2206 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
2207 let consumesNextToken = false;
2208 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
2209 const shortOption = `-${option}`;
2210 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
2211 consumesNextToken = optionIndex === token.length - 2;
2212 break;
2213 }
2214 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
2215 }
2216 if (consumesNextToken && tokens[index + 1] === undefined) return null;
2217 index += consumesNextToken ? 2 : 1;
2218 continue;
2219 }
2220 return null;
2221 }
2222 return token.split(/[\\/]/).at(-1) || null;
2223 }
2224
2225 return null;
2226}
2227
2228function liveWorkEntryLabel(
2229 workEntry: TimelineWorkEntry,
2230 workspaceRoot: string | undefined,
2231): string {
2232 const command = workEntry.command?.trim();
2233 if (command) {
2234 const program = commandProgramName(command);
2235 if (program) return `Running ${program}`;
2236 return "Running command";
2022function workEntryRawCommand(
2023 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
2024): string | null {
2025 const rawCommand = workEntry.rawCommand?.trim();
2026 if (!rawCommand || !workEntry.command) {
2027 return null;
22372028 }
2238
2239 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2029 return rawCommand === workEntry.command.trim() ? null : rawCommand;
22402030}
22412031
22422032function buildToolCallExpandedBody(
22432033 workEntry: TimelineWorkEntry,
22442034 workspaceRoot: string | undefined,
22452035): string | null {
2246 const command = workEntry.rawCommand?.trim() || workEntry.command?.trim();
22472036 const blocks: string[] = [];
2248 if (command) {
2249 blocks.push(command);
2250 }
22512037 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
22522038 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
22532039 }
2254 const detail = workEntry.detail?.trim();
2255 if (detail && detail !== command) {
2256 blocks.push(detail);
2040 const raw = workEntryRawCommand(workEntry);
2041 if (raw?.trim()) {
2042 blocks.push(raw.trim());
2043 } else if (workEntry.command?.trim()) {
2044 blocks.push(workEntry.command.trim());
2045 }
2046 if (workEntry.detail?.trim()) {
2047 blocks.push(workEntry.detail.trim());
22572048 }
22582049 const changedFiles = workEntry.changedFiles ?? [];
22592050 if (changedFiles.length > 0) {
......@@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
23892180 : "working"
23902181 : failed > 0
23912182 ? `${failed} failed`
2392 : "Completed";
2183 : "✓ completed";
23932184
23942185 return (
2395 <div className="-mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32">
2396 <div className="flex items-center justify-between gap-2 rounded-xl px-1">
2397 <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left text-xs leading-4">
2398 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2399 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2400 <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>
2401 {workflowName ? (
2402 <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground md:inline">
2403 {workflowName}
2404 </span>
2405 ) : null}
2406 <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground sm:flex">
2407 <span>{status}</span>
2408 {totalTokens > 0 ? (
2409 <span className="font-mono tabular-nums">
2410 Σ {formatSubagentTokenCount(totalTokens)}
2411 </span>
2412 ) : null}
2413 </span>
2414 </div>
2415 <Button
2416 type="button"
2417 size="xs"
2418 variant="outline"
2419 aria-label={live ? "Open agents" : "View agents"}
2420 onClick={onOpenAgents}
2421 >
2422 <EyeIcon className="size-3" />
2423 <span className="hidden sm:inline">{live ? "Open agents" : "View agents"}</span>
2424 </Button>
2425 </div>
2426 </div>
2186 <button
2187 type="button"
2188 onClick={onOpenAgents}
2189 className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
2190 >
2191 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2192 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2193 <span className="min-w-0 truncate">
2194 <span className="font-medium">{lead}</span>
2195 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
2196 </span>
2197 <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
2198 <span>{status}</span>
2199 {totalTokens > 0 ? (
2200 <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
2201 ) : null}
2202 <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
2203 </span>
2204 </button>
24272205 );
24282206});
24292207
24302208const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
24312209 workEntry: TimelineWorkEntry;
24322210 workspaceRoot: string | undefined;
2433 isExpandedToolGroupEntry: boolean;
24342211}) {
2435 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2212 const { workEntry, workspaceRoot } = props;
24362213 // Before any hooks: spawn CTA rows render their own component.
24372214 if (workEntry.agentSpawn) {
24382215 return <AgentSpawnCtaRow workEntry={workEntry} />;
24392216 }
2440 return (
2441 <PlainWorkEntryRow
2442 workEntry={workEntry}
2443 workspaceRoot={workspaceRoot}
2444 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2445 />
2446 );
2217 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;
24472218});
24482219
24492220const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24502221 workEntry: TimelineWorkEntry;
24512222 workspaceRoot: string | undefined;
2452 isExpandedToolGroupEntry: boolean;
24532223}) {
2454 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2224 const { workEntry, workspaceRoot } = props;
2225 const activity = use(TimelineRowActivityCtx);
24552226 const [expanded, setExpanded] = useState(false);
24562227 const iconConfig = workToneIcon(workEntry.tone);
24572228 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2458 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2459 const entryIconName =
2460 showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);
2461 const isCommandEntry =
2462 workEntry.requestKind === "command" ||
2463 workEntry.itemType === "command_execution" ||
2464 Boolean(workEntry.command);
2465 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2229 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
2230 const heading = toolWorkEntryHeading(workEntry);
2231 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
2232 const preview =
2233 rawPreview &&
2234 normalizeCompactToolLabel(rawPreview).toLowerCase() ===
2235 normalizeCompactToolLabel(heading).toLowerCase()
2236 ? null
2237 : rawPreview;
2238 const displayText = preview ? `${heading} - ${preview}` : heading;
24662239 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
24672240 const canExpand = expandedBody !== null;
2241 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
24682242 const showDestructiveRowStyle =
24692243 showFailedIndicator &&
24702244 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
24712245 const iconWrapperClass = cn(
2472 "flex size-6 shrink-0 items-center justify-center",
2473 showWarningIndicator || showFailedIndicator
2246 "flex size-5 shrink-0 items-center justify-center",
2247 showWarningIndicator
24742248 ? "text-destructive"
24752249 : showDestructiveRowStyle
24762250 ? "text-destructive"
......@@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24822256 ? "font-medium text-warning"
24832257 : showDestructiveRowStyle
24842258 ? "font-medium text-destructive"
2485 : workLogEntryIsToolLike(workEntry)
2486 ? "text-secondary-label"
2487 : "text-foreground/80";
2488 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
2259 : "font-medium text-foreground";
2260 const turnSettled = !activity.activeTurnInProgress;
2261 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);
2262 const showSuccessIndicator =
2263 workEntryIndicatesToolSuccess(workEntry) ||
2264 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
24892265 const rowToggleProps = canExpand
24902266 ? {
24912267 role: "button" as const,
24922268 tabIndex: 0 as const,
24932269 "aria-label": displayText,
2494 "aria-expanded": expanded,
24952270 onClick: () => setExpanded((v) => !v),
24962271 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
24972272 if (e.key === "Enter" || e.key === " ") {
......@@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25052280 return (
25062281 <div
25072282 className={cn(
2508 "flex flex-col rounded-md px-0.5 transition-colors",
2509 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
2283 "flex flex-col rounded-md px-0.5 py-0.5 transition-colors",
25102284 canExpand &&
2511 "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
2285 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
25122286 )}
25132287 {...rowToggleProps}
25142288 >
25152289 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
2516 {showEntryIcon ? (
2517 <span
2518 className={iconWrapperClass}
2519 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
2520 >
2521 <WorkEntryIconSvg
2522 name={entryIconName}
2523 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
2524 />
2525 </span>
2526 ) : null}
2290 <span className={iconWrapperClass}>
2291 <WorkEntryIconSvg
2292 name={entryIconName}
2293 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
2294 />
2295 </span>
25272296 <div className="flex min-w-0 flex-1 items-center gap-1.5">
25282297 <div className="min-w-0 flex-1 overflow-hidden">
2529 <p
2530 className={cn(
2531 "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed",
2532 isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm",
2298 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
2299 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
2300 {preview && (
2301 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
25332302 )}
2534 >
2535 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
25362303 </p>
25372304 </div>
2305 <div className="flex shrink-0 items-center gap-px text-icon-muted">
2306 <span
2307 className="flex size-4 shrink-0 items-center justify-center"
2308 aria-hidden={!canExpand}
2309 >
2310 {canExpand ? (
2311 <ChevronDownIcon
2312 className={cn(
2313 "size-3 shrink-0 opacity-70 transition-transform duration-200",
2314 expanded && "rotate-180",
2315 )}
2316 aria-hidden
2317 />
2318 ) : null}
2319 </span>
2320 <span className="flex size-4 shrink-0 items-center justify-center">
2321 {showFailedIndicator ? (
2322 <Tooltip>
2323 <TooltipTrigger
2324 render={
2325 <span
2326 className="flex size-4 items-center justify-center"
2327 aria-label="Tool call failed"
2328 />
2329 }
2330 >
2331 <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden />
2332 </TooltipTrigger>
2333 <TooltipPopup>Failed</TooltipPopup>
2334 </Tooltip>
2335 ) : showSuccessIndicator ? (
2336 <Tooltip>
2337 <TooltipTrigger
2338 render={<span className="flex size-4 items-center justify-center" />}
2339 >
2340 <span className="inline-flex size-4 items-center justify-center">
2341 <CheckIcon
2342 className="block size-3 shrink-0 stroke-current"
2343 stroke="currentColor"
2344 aria-hidden
2345 />
2346 </span>
2347 </TooltipTrigger>
2348 <TooltipPopup>Completed</TooltipPopup>
2349 </Tooltip>
2350 ) : showNeutralIndicator ? (
2351 <Tooltip>
2352 <TooltipTrigger
2353 render={<span className="flex size-4 items-center justify-center" />}
2354 >
2355 <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden />
2356 </TooltipTrigger>
2357 <TooltipPopup>Empty</TooltipPopup>
2358 </Tooltip>
2359 ) : null}
2360 </span>
2361 </div>
25382362 </div>
25392363 </div>
25402364 {expanded && canExpand && expandedBody ? (
25412365 <div
2542 className="mt-1 ms-[1.875rem] cursor-default pt-0.5"
2366 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
25432367 onClick={stopRowToggle}
25442368 onPointerDown={stopRowToggle}
25452369 >
2546 <pre
2547 className={cn(
2548 "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed",
2549 isCommandEntry ? "select-all" : "select-text",
2550 )}
2551 >
2370 <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed select-text">
25522371 {expandedBody}
25532372 </pre>
25542373 </div>
apps/web/src/components/chat/PanelLayoutControls.tsx+5-13
......@@ -1,7 +1,6 @@
11import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";
22import { memo } from "react";
33
4import { cn } from "../../lib/utils";
54import { Toggle } from "../ui/toggle";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76
......@@ -13,7 +12,6 @@ interface PanelLayoutControlsProps {
1312 rightPanelAvailable: boolean;
1413 rightPanelOpen: boolean;
1514 rightPanelShortcutLabel: string | null;
16 rightPanelUnavailableLabel?: string;
1715 /** Running + waiting subagents in this thread; badges the right panel toggle. */
1816 liveAgentCount: number;
1917 onToggleTerminal: () => void;
......@@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
2826 rightPanelAvailable,
2927 rightPanelOpen,
3028 rightPanelShortcutLabel,
31 rightPanelUnavailableLabel = "Right panel is unavailable",
3229 liveAgentCount,
3330 onToggleTerminal,
3431 onToggleRightPanel,
......@@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
4340 <TooltipTrigger
4441 render={
4542 <Toggle
46 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"
43 className="shrink-0 [-webkit-app-region:no-drag]"
4744 pressed={terminalOpen}
4845 onPressedChange={onToggleTerminal}
4946 aria-label="Toggle terminal drawer"
......@@ -66,7 +63,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
6663 <TooltipTrigger
6764 render={
6865 <Toggle
69 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag] disabled:opacity-100"
66 className="shrink-0 [-webkit-app-region:no-drag]"
7067 pressed={rightPanelOpen}
7168 onPressedChange={onToggleRightPanel}
7269 aria-label={
......@@ -78,12 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
7875 size="sm"
7976 disabled={!rightPanelAvailable}
8077 >
81 <PanelRightIcon
82 className={cn(
83 "size-4",
84 !rightPanelAvailable && "text-muted-foreground opacity-100",
85 )}
86 />
78 <PanelRightIcon className="size-4" />
8779 {liveAgentCount > 0 ? (
8880 <span
8981 aria-hidden
......@@ -102,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
10294 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
10395 : ""
10496 }`
105 : rightPanelUnavailableLabel}
97 : "Right panel is unavailable"}
10698 </TooltipPopup>
10799 </Tooltip>
108100 </div>
......@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl
122114 <TooltipTrigger
123115 render={
124116 <Toggle
125 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"
117 className="shrink-0 [-webkit-app-region:no-drag]"
126118 pressed={maximized}
127119 onPressedChange={onToggle}
128120 aria-label={label}
apps/web/src/components/composerInlineChip.ts+7-7
......@@ -2,7 +2,7 @@
22// composer honors the prompt font-size preference). The chat variant pins the
33// original 12px, where every em value resolves to the same pixels as before.
44const INLINE_CHIP_CLASS_NAME =
5 "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] font-medium leading-none text-foreground align-middle";
5 "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle";
66
77export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;
88
......@@ -11,18 +11,18 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[
1111export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
1212 "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-['']";
1313
14export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME =
15 "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block";
14export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85";
1615
1716export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
1817
19export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
20 "block self-center truncate leading-none select-none";
18export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
2119
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
20// The skill label is smaller than the surrounding prompt text; offset its
21// glyphs without moving the pill box or changing the editor's line height.
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`;
2323
2424export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
25 "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";
25 "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";
2626
2727export const SKILL_CHIP_ICON_SVG = `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
2828
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+332-285
......@@ -25,7 +25,6 @@ import {
2525 GitPullRequestDraftIcon,
2626 GitPullRequestIcon,
2727 HammerIcon,
28 LayersIcon,
2928 MessageCircleQuestionIcon,
3029 MessageSquareIcon,
3130 LinkIcon,
......@@ -51,7 +50,6 @@ import {
5150import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5251import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
5352import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
5553import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
5654import { cn } from "~/lib/utils";
5755import { readLocalApi } from "~/localApi";
......@@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query";
6260import { useLiveRefresh } from "~/hooks/useLiveRefresh";
6361import { pullRequestEnvironment } from "~/state/pullRequests";
6462import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
6663import { formatRelativeTimeLabel } from "~/timestampFormat";
6764
6865import {
......@@ -77,7 +74,6 @@ import {
7774import { Badge } from "../ui/badge";
7875import { Button } from "../ui/button";
7976import { Input } from "../ui/input";
80import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
8177import {
8278 Menu,
8379 MenuItem,
......@@ -120,7 +116,6 @@ import {
120116} from "./pullRequestProjectAssignment.logic";
121117import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
122118import {
123 PullRequestActorAvatar,
124119 PullRequestActorLabel,
125120 PullRequestDiffStat,
126121 PullRequestMetaLine,
......@@ -354,6 +349,7 @@ export function PullRequestDetailPanel({
354349 onClose,
355350 onStateChange,
356351 context = "page",
352 chromeVariant = "full",
357353 composerDraftTarget,
358354}: {
359355 environmentId: EnvironmentId;
......@@ -385,6 +381,12 @@ export function PullRequestDetailPanel({
385381 * again is at best a no-op and at worst git refusing a branch two checkouts.
386382 */
387383 context?: "page" | "thread";
384 /**
385 * How the metadata above the content behaves: `full` keeps every row pinned; `collapse`
386 * folds the whole of it into the top row once the active tab scrolls, and unfolds at the
387 * top — the chrome spends its height on what is being read.
388 */
389 chromeVariant?: "full" | "collapse";
388390 /**
389391 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
390392 * land here instead of opening a new thread — the branch is already under the reader's feet.
......@@ -421,13 +423,26 @@ export function PullRequestDetailPanel({
421423 );
422424 }, [tab]);
423425 const [chromeCondensed, setChromeCondensed] = useState(false);
426 // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll
427 // events, so the capture handler always writes the active tab's entry — and a tab switch
428 // reads the destination's memory instead of inheriting the tab being left. A tab too short
429 // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome
430 // it has no scrollbar to reopen.
424431 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
425432 useEffect(() => {
426433 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
427434 }, [tab]);
428 const condensed = chromeCondensed;
435 const condensed = chromeVariant === "collapse" && chromeCondensed;
436 // Collapsing removes the fold's height from the chrome, which would otherwise hand that
437 // height to the scrollport and leap the content up by it mid-scroll. The cure is exact
438 // compensation: collapse only once the reader has scrolled at least the fold's height,
439 // then give that height back to `scrollTop` before the next paint — the content under
440 // their eyes does not move, and the collapse itself is the only thing that changes.
429441 const scrollerRef = useRef<HTMLElement | null>(null);
430442 const foldRef = useRef<HTMLDivElement | null>(null);
443 // The condensed chrome's second row opens as the fold closes, so the height the scrollport
444 // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight`
445 // through a zero track reads its natural height in either state.
431446 const condensedRowRef = useRef<HTMLDivElement | null>(null);
432447 const compensationRef = useRef<number | null>(null);
433448 useLayoutEffect(() => {
......@@ -448,6 +463,7 @@ export function PullRequestDetailPanel({
448463 target: "branch name",
449464 timeout: 1600,
450465 });
466
451467 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
452468 // clicked, so a reader who does click it lands on a chunk already in the module cache.
453469 useEffect(() => {
......@@ -486,30 +502,6 @@ export function PullRequestDetailPanel({
486502 },
487503 [activity, coreDetail],
488504 );
489 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
490 const baseBranchRefQuery = useEnvironmentQuery(
491 detail === null
492 ? null
493 : vcsEnvironment.listRefs({
494 environmentId,
495 input: {
496 cwd: detail.workspaceRoot,
497 query: detail.baseBranch,
498 includeMatchingRemoteRefs: true,
499 limit: 20,
500 },
501 }),
502 );
503 const matchingBaseBranchRefs =
504 detail === null
505 ? []
506 : (baseBranchRefQuery.data?.refs.filter(
507 (refName) =>
508 refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`),
509 ) ?? []);
510 const isStackedPullRequest =
511 matchingBaseBranchRefs.length > 0 &&
512 !matchingBaseBranchRefs.some((refName) => refName.isDefault);
513505 const activityPending = activityQuery.isPending && activity === null;
514506 const activityError = activity === null ? activityQuery.error : null;
515507 const refreshDetail = useCallback(() => {
......@@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({
10271019 const can = (action: PullRequestAction) =>
10281020 detail?.capabilities.actions.includes(action) === true &&
10291021 detail.viewerPermissions.actions.includes(action);
1030 // One live action holds the slot. Conflicts take priority because every other completion action
1031 // depends on resolving them first, even for a reader who cannot merge on the host themselves.
1022 // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes
1023 // to the thing that would help instead of a Merge button that only ever says no.
10321024 const primaryAction =
10331025 detail === null || detail.state !== "open"
10341026 ? null
1035 : conflicting
1036 ? "resolve"
1037 : detail.isDraft && can("ready")
1038 ? "ready"
1039 : !can("merge")
1040 ? null
1027 : detail.isDraft && can("ready")
1028 ? "ready"
1029 : !can("merge")
1030 ? null
1031 : conflicting
1032 ? "resolve"
10411033 : allowedMergeMethods.length > 0
10421034 ? "merge"
10431035 : null;
10441036 // The pull request number carries this state in the overview and the right-panel tab mirrors
1045 // it. The conflict action is separate from this state: an open pull request remains green.
1037 // it. Conflicts keep their own row below: an open pull request remains green there.
10461038 const statePresentation = detail
10471039 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
10481040 : null;
10491041 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
10501042 const checksState = detail ? pullRequestChecksState(detail.checks) : null;
10511043
1052 if (detailQuery.isPending && !detail) {
1053 return <PullRequestDetailGhost />;
1054 }
1055
10561044 return (
10571045 <div className="flex h-full min-h-0 w-full flex-col bg-background">
1046 {/* The top row's geometry never changes: both of its states occupy the same stacked
1047 cell and crossfade, so the actions on the right have one home whatever the chrome
1048 is doing below. The fold and this fade share one 200ms clock. */}
10581049 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
1059 <div className="ml-4 grid h-7 min-w-0 items-center">
1050 {/* The fixed height lives on the two top-row cells — not the grid, whose later rows
1051 are the fold — so the actions have one immovable home in both states. */}
1052 <div className="ml-4 grid h-11 min-w-0 items-center">
10601053 <div
10611054 aria-hidden={condensed}
10621055 inert={condensed}
10631056 className={cn(
1064 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none",
1057 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none",
1058 // Sequenced, not simultaneous: the leaving layer clears quickly before the
1059 // arriving one lands, so no frame shows both texts superimposed at half opacity.
10651060 condensed
1066 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1067 : "translate-y-0 opacity-100 delay-50 duration-150",
1061 ? "pointer-events-none opacity-0 duration-100"
1062 : "opacity-100 delay-75 duration-150",
10681063 )}
10691064 >
10701065 {detail && statePresentation ? (
10711066 <>
1072 {repositoryUrl ? (
1073 <button
1074 type="button"
1075 onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)}
1076 className="min-w-0 truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
1077 title={`Open ${detail.repository} repository`}
1078 >
1079 {detail.repository}
1080 </button>
1081 ) : (
1082 <span className="min-w-0 truncate font-medium text-muted-foreground">
1083 {detail.repository}
1084 </span>
1085 )}
1067 <span className="min-w-0 truncate" title={detail.repository}>
1068 {detail.repository}
1069 </span>
10861070 <button
10871071 type="button"
10881072 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
......@@ -1103,10 +1087,10 @@ export function PullRequestDetailPanel({
11031087 aria-hidden={!condensed}
11041088 inert={!condensed}
11051089 className={cn(
1106 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none",
1090 "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none",
11071091 condensed
1108 ? "translate-y-0 opacity-100 delay-50 duration-150"
1109 : "pointer-events-none translate-y-1 opacity-0 duration-100",
1092 ? "opacity-100 delay-75 duration-150"
1093 : "pointer-events-none opacity-0 duration-100",
11101094 )}
11111095 >
11121096 {detail && statePresentation ? (
......@@ -1125,14 +1109,30 @@ export function PullRequestDetailPanel({
11251109 >
11261110 #{detail.number}
11271111 </button>
1128 <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
1112 <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
11291113 {detail.title}
1130 </h1>
1114 </span>
1115 {conflicting ? (
1116 <Badge
1117 variant="error"
1118 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
1119 >
1120 <TriangleAlertIcon className="size-3" />
1121 Conflicts
1122 </Badge>
1123 ) : checksSummary ? (
1124 <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
1125 {detail && checksState !== null ? (
1126 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1127 ) : null}
1128 {checksSummary}
1129 </span>
1130 ) : null}
11311131 </>
11321132 ) : null}
11331133 </div>
11341134 </div>
1135 <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
1135 <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
11361136 {detail ? (
11371137 <>
11381138 <Menu>
......@@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({
11401140 render={
11411141 <Button
11421142 aria-label="More pull request actions"
1143 className="order-last size-6"
1143 className="size-6"
11441144 size="icon-xs"
11451145 variant="ghost-muted"
11461146 />
......@@ -1312,15 +1312,16 @@ export function PullRequestDetailPanel({
13121312 <MenuTrigger
13131313 disabled={handoff !== null}
13141314 render={
1315 <Button size="xs" variant="outline" className="gap-0 p-0">
1316 <span className="inline-flex h-full items-center gap-1.5 ps-[8.5px] pe-2">
1317 <GitBranchIcon aria-hidden className="size-3.5" />
1318 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1319 </span>
1320 <span aria-hidden className="h-4 w-px shrink-0 bg-input" />
1321 <span className="inline-flex h-full w-6 shrink-0 items-center justify-center">
1322 <ChevronDownIcon aria-hidden className="size-4 text-muted-foreground" />
1323 </span>
1315 <Button size="xs" variant="outline">
1316 {handoff?.startsWith("checkout") ? (
1317 "Checking out..."
1318 ) : (
1319 <>
1320 <GitBranchIcon className="size-3" />
1321 Check out
1322 <ChevronDownIcon className="size-3 text-muted-foreground" />
1323 </>
1324 )}
13241325 </Button>
13251326 }
13261327 />
......@@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({
13661367 Auto-merge
13671368 </Badge>
13681369 ) : null}
1369 {primaryAction === "resolve" ? (
1370 <Badge
1371 variant="error"
1372 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive hover:bg-destructive/14"
1373 render={
1374 <button
1375 type="button"
1376 disabled={handoff !== null}
1377 onClick={startResolveConflicts}
1378 />
1379 }
1380 >
1381 <TriangleAlertIcon className="size-3" />
1382 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1383 </Badge>
1384 ) : primaryAction === "ready" ? (
1370 {primaryAction === "ready" ? (
13851371 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
13861372 Ready for review
13871373 </Button>
......@@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({
14081394 ) : null}
14091395 </div>
14101396
1411 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
1397 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,
1398 and compact copies of the branch pair and diff stat so they stay in sight while
1399 the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
1400 <div
1401 className={cn(
1402 "col-span-2 grid",
1403 // Inverse of the fold's track: closing eases while the fold above eases open,
1404 // so the chrome trades rows in one motion instead of two jumps.
1405 condensed
1406 ? "grid-rows-[1fr]"
1407 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1408 )}
1409 >
14121410 <div
14131411 ref={condensedRowRef}
14141412 className={cn(
1415 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
1413 "min-h-0 overflow-hidden",
14161414 condensed
1417 ? "translate-y-0 opacity-100 delay-50"
1418 : "translate-y-1 opacity-0 duration-100",
1415 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
1416 : "opacity-0",
14191417 )}
14201418 inert={!condensed}
14211419 >
14221420 {detail ? (
1423 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
1424 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1425 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
1426 <PullRequestActorAvatar actor={detail.author} className="shrink-0" />
1427 <span className="sr-only">{detail.author?.login ?? "ghost"}</span>
1428 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
1429 </span>
1430 <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
1431 <span
1432 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65"
1433 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1434 >
1435 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1436 {isStackedPullRequest ? (
1437 <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" />
1438 ) : null}
1439 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1440 </span>
1441 {freshness ? (
1442 <PullRequestBaseFreshnessWarning
1443 baseBranch={detail.baseBranch}
1444 freshness={freshness}
1445 pending={actionPending}
1446 onUpdate={(method) => void perform("update-branch", undefined, method)}
1447 iconClassName="size-3"
1448 />
1449 ) : null}
1450 <ArrowLeftIcon
1451 aria-label="receives changes from"
1452 className="size-3 shrink-0 opacity-60"
1453 />
1454 <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code>
1455 </span>
1456 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]">
1457 <span
1458 className="inline-flex items-center gap-1 tabular-nums"
1459 aria-label={`${detail.changedFiles.toLocaleString()} changed ${
1460 detail.changedFiles === 1 ? "file" : "files"
1461 }`}
1421 <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
1422 <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
1423 {visibleTabs.map((item) => (
1424 <button
1425 key={item.value}
1426 type="button"
1427 tabIndex={condensed ? 0 : -1}
1428 aria-pressed={tab === item.value}
1429 onClick={() => setTab(item.value)}
1430 className={cn(
1431 "rounded-md px-2 py-1 text-[11px] transition-colors",
1432 tab === item.value
1433 ? "bg-accent text-foreground"
1434 : "text-muted-foreground hover:text-foreground",
1435 )}
14621436 >
1463 <FileDiffIcon aria-hidden className="size-3" />
1464 {detail.changedFiles.toLocaleString()}
1465 </span>
1466 <PullRequestDiffStat
1467 additions={detail.additions}
1468 deletions={detail.deletions}
1469 className="shrink-0 font-mono text-[11px]"
1437 {item.label}
1438 </button>
1439 ))}
1440 </nav>
1441 <span
1442 className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"
1443 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1444 >
1445 <span className="truncate">{detail.baseBranch}</span>
1446 {freshness ? (
1447 <PullRequestBaseFreshnessWarning
1448 baseBranch={detail.baseBranch}
1449 freshness={freshness}
1450 pending={actionPending}
1451 onUpdate={(method) => void perform("update-branch", undefined, method)}
1452 iconClassName="size-3"
14701453 />
1454 ) : null}
1455 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />
1456 <span className="truncate">{detail.headBranch}</span>
1457 </span>
1458 <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
1459 <span className="inline-flex items-center gap-1 tabular-nums">
1460 <FileDiffIcon className="size-3" />
1461 {detail.changedFiles.toLocaleString()}
14711462 </span>
1472 </div>
1463 <PullRequestDiffStat
1464 additions={detail.additions}
1465 deletions={detail.deletions}
1466 className="shrink-0 font-mono text-[11px]"
1467 />
1468 </span>
14731469 </div>
14741470 ) : null}
14751471 </div>
14761472 </div>
14771473
1478 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}>
1474 {/* Folding is a grid track going to zero: the rows below stay mounted, the track
1475 animates closed over them, and `inert` takes the hidden controls out of the tab
1476 order for as long as the chrome is condensed. */}
1477 <div
1478 className={cn(
1479 "col-span-2 grid",
1480 // Collapse is instant: it happens mid-scroll, and the compensation that keeps the
1481 // content pinned would fight an animated track frame by frame. Reopening happens
1482 // at the top with no compensation, so the fold can ease back in instead of
1483 // snapping the content down.
1484 condensed
1485 ? "grid-rows-[0fr]"
1486 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1487 )}
1488 >
14791489 <div
14801490 ref={foldRef}
1491 // One-way on purpose: appearing content eases in over ground the instant track
1492 // already reserved; departing content cuts, because its ground is gone in the
1493 // same frame and the scroll compensation reads it as scrolled past.
14811494 className={cn(
1482 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
1495 "min-h-0 overflow-hidden",
14831496 condensed
1484 ? "-translate-y-1 opacity-0 duration-100"
1485 : "translate-y-0 opacity-100 delay-50",
1497 ? "opacity-0"
1498 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",
14861499 )}
14871500 inert={condensed}
14881501 >
14891502 {detail ? (
1490 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
1503 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">
14911504 {titleDraft === null ? (
14921505 <div className="group flex min-w-0 items-start gap-1">
14931506 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
......@@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({
15541567 </PullRequestMetaLine>
15551568
15561569 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1557 <span
1558 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"
1559 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1570 <code
1571 className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"
1572 title={detail.baseBranch}
15601573 >
1561 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1562 {isStackedPullRequest ? (
1563 <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" />
1564 ) : null}
1565 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1566 </span>
1567 {freshness ? (
1568 <PullRequestBaseFreshnessWarning
1569 baseBranch={detail.baseBranch}
1570 freshness={freshness}
1571 pending={actionPending}
1572 onUpdate={(method) => void perform("update-branch", undefined, method)}
1573 />
1574 ) : null}
1575 <ArrowLeftIcon
1576 aria-label="receives changes from"
1577 className="size-3.5 shrink-0 opacity-60"
1574 {detail.baseBranch}
1575 </code>
1576 {freshness ? (
1577 <PullRequestBaseFreshnessWarning
1578 baseBranch={detail.baseBranch}
1579 freshness={freshness}
1580 pending={actionPending}
1581 onUpdate={(method) => void perform("update-branch", undefined, method)}
15781582 />
1579 <button
1580 type="button"
1581 className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1582 aria-label={
1583 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1584 }
1585 title={isBranchCopied ? "Copied" : "Copy pull request branch"}
1586 onClick={() => copyBranchToClipboard(detail.headBranch)}
1583 ) : null}
1584 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1585 <button
1586 type="button"
1587 className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
1588 aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"}
1589 title={isBranchCopied ? "Copied" : "Copy pull request branch"}
1590 onClick={() => copyBranchToClipboard(detail.headBranch)}
1591 >
1592 <code
1593 className={cn(
1594 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1595 isBranchCopied ? "opacity-0" : "opacity-100",
1596 )}
1597 title={detail.headBranch}
15871598 >
1588 <code
1589 className={cn(
1590 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1591 isBranchCopied ? "opacity-0" : "opacity-100",
1592 )}
1593 >
1594 {detail.headBranch}
1595 </code>
1596 <span
1597 aria-hidden="true"
1598 className={cn(
1599 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1600 isBranchCopied ? "opacity-100" : "opacity-0",
1601 )}
1602 >
1603 Copied
1604 </span>
1605 </button>
1606 </span>
1599 {detail.headBranch}
1600 </code>
1601 <span
1602 aria-hidden="true"
1603 className={cn(
1604 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1605 isBranchCopied ? "opacity-100" : "opacity-0",
1606 )}
1607 >
1608 Copied
1609 </span>
1610 </button>
16071611 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
16081612 <span className="inline-flex items-center gap-1.5 tabular-nums">
16091613 <FileDiffIcon className="size-3.5" />
......@@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({
16191623 </div>
16201624 </div>
16211625 ) : null}
1622 </div>
1623 </div>
16241626
1625 {detail ? (
1626 <nav
1627 className={cn(
1628 "col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
1629 condensed ? "py-1.5" : "py-2",
1630 )}
1631 aria-label="Pull request tabs"
1632 >
1633 <SegmentedTabList>
1634 {visibleTabs.map((item) => (
1635 <SegmentedTab
1636 key={item.value}
1637 selected={tab === item.value}
1638 onClick={() => setTab(item.value)}
1639 >
1640 {item.label}
1641 </SegmentedTab>
1642 ))}
1643 </SegmentedTabList>
1644 {tab === "summary" ? (
1645 <span
1646 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1647 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1648 >
1649 {checksState !== null ? (
1650 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1651 ) : (
1652 <CircleDotIcon aria-hidden className="size-3.5" />
1653 )}
1654 {checksSummary}
1655 </span>
1656 ) : tab === "timeline" ? (
1657 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1658 <PullRequestMetaLine
1659 className={cn(
1660 "whitespace-nowrap text-[11px] transition-opacity",
1661 (activityPending || activityError) && "opacity-35",
1662 )}
1627 {detail && conflicting ? (
1628 <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
1629 <Badge
1630 variant="error"
1631 className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
16631632 >
1664 <span
1665 className="inline-flex items-center gap-1"
1666 aria-label={
1667 activityError
1668 ? "Comments unavailable"
1669 : `${detail.commentCount.toLocaleString()} ${
1670 detail.commentCount === 1 ? "comment" : "comments"
1671 }`
1672 }
1673 >
1674 <MessageSquareIcon aria-hidden className="size-3" />
1675 {activityError
1676 ? "—"
1677 : activityPending
1678 ? "…"
1679 : detail.commentCount.toLocaleString()}
1680 </span>
1681 <span
1682 className="inline-flex items-center gap-1"
1683 aria-label={
1684 activityError
1685 ? "Commits unavailable"
1686 : `${detail.commits.length.toLocaleString()} ${
1687 detail.commits.length === 1 ? "commit" : "commits"
1688 }`
1689 }
1690 >
1691 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1692 {activityError
1693 ? "—"
1694 : activityPending
1695 ? "…"
1696 : detail.commits.length.toLocaleString()}
1697 </span>
1698 </PullRequestMetaLine>
1633 <TriangleAlertIcon className="size-3.5" />
1634 Merge conflicts
1635 </Badge>
16991636 <Button
17001637 size="xs"
17011638 variant="ghost"
1702 className="h-7 px-2 text-[10px] text-muted-foreground"
1703 aria-label={
1704 timelineOrder === "newest"
1705 ? "Show oldest activity first"
1706 : "Show newest activity first"
1707 }
1708 onClick={() =>
1709 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1710 }
1639 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"
1640 disabled={handoff !== null}
1641 onClick={startResolveConflicts}
17111642 >
1712 <ArrowDownUpIcon aria-hidden className="size-3" />
1713 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1643 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}
1644 <ArrowUpRightIcon className="size-3.5 text-destructive" />
17141645 </Button>
17151646 </div>
17161647 ) : null}
1717 </nav>
1718 ) : null}
1648
1649 {detail ? (
1650 <nav
1651 className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1652 aria-label="Pull request tabs"
1653 >
1654 {visibleTabs.map((item) => (
1655 <button
1656 key={item.value}
1657 type="button"
1658 aria-pressed={tab === item.value}
1659 onClick={() => setTab(item.value)}
1660 className={cn(
1661 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",
1662 tab === item.value
1663 ? "bg-accent text-foreground"
1664 : "text-muted-foreground hover:text-foreground",
1665 )}
1666 >
1667 {item.label}
1668 </button>
1669 ))}
1670 {tab === "summary" ? (
1671 <span
1672 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1673 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1674 >
1675 {/* The rollup icon opens the checks behind the summary; with none reported
1676 there is nothing to open, so the plain glyph stays. */}
1677 {detail && checksState !== null ? (
1678 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1679 ) : (
1680 <CircleDotIcon aria-hidden className="size-3.5" />
1681 )}
1682 {checksSummary}
1683 </span>
1684 ) : tab === "timeline" ? (
1685 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1686 <PullRequestMetaLine
1687 className={cn(
1688 "whitespace-nowrap text-[11px] transition-opacity",
1689 (activityPending || activityError) && "opacity-35",
1690 )}
1691 >
1692 <span
1693 className="inline-flex items-center gap-1"
1694 aria-label={
1695 activityError
1696 ? "Comments unavailable"
1697 : `${detail.commentCount.toLocaleString()} ${
1698 detail.commentCount === 1 ? "comment" : "comments"
1699 }`
1700 }
1701 >
1702 <MessageSquareIcon aria-hidden className="size-3" />
1703 {activityError
1704 ? "—"
1705 : activityPending
1706 ? "…"
1707 : detail.commentCount.toLocaleString()}
1708 </span>
1709 <span
1710 className="inline-flex items-center gap-1"
1711 aria-label={
1712 activityError
1713 ? "Commits unavailable"
1714 : `${detail.commits.length.toLocaleString()} ${
1715 detail.commits.length === 1 ? "commit" : "commits"
1716 }`
1717 }
1718 >
1719 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1720 {activityError
1721 ? "—"
1722 : activityPending
1723 ? "…"
1724 : detail.commits.length.toLocaleString()}
1725 </span>
1726 </PullRequestMetaLine>
1727 <Button
1728 size="xs"
1729 variant="ghost"
1730 className="h-7 px-2 text-[10px] text-muted-foreground"
1731 aria-label={
1732 timelineOrder === "newest"
1733 ? "Show oldest activity first"
1734 : "Show newest activity first"
1735 }
1736 onClick={() =>
1737 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1738 }
1739 >
1740 <ArrowDownUpIcon aria-hidden className="size-3" />
1741 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1742 </Button>
1743 </div>
1744 ) : null}
1745 </nav>
1746 ) : null}
1747 </div>
1748 </div>
17191749 </div>
17201750
17211751 <div
17221752 className="relative min-h-0 flex-1 overflow-hidden"
1753 // Scroll does not bubble, but it captures: one listener hears every tab's own scroll
1754 // container. Collapse past two line-heights, expand only back at the very top, so the
1755 // boundary row cannot flap the chrome open and shut.
17231756 onScrollCapture={(event) => {
1757 if (chromeVariant !== "collapse") return;
17241758 const scroller = event.target as HTMLElement;
17251759 scrollerRef.current = scroller;
17261760 const top = scroller.scrollTop;
17271761 setChromeCondensed((previous) => {
17281762 let next = previous;
1763 // `scrollHeight` reads the fold's natural height whichever state the track is in.
17291764 const foldHeight = foldRef.current?.scrollHeight ?? 0;
1765 // The chrome trades the fold for the condensed second row, so the height the
1766 // scrollport actually gains is the difference between the two.
17301767 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
17311768 if (previous) {
17321769 // The hard top reopens the chrome with no refund: the reader asked for the top,
......@@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({
17441781 });
17451782 }}
17461783 >
1747 {detailQuery.error && !detail ? (
1784 {detailQuery.isPending && !detail ? (
1785 // The ghost wears the shape of the tab being waited on, so switching tabs mid-load
1786 // does not flash a summary outline under a timeline heading.
1787 tab === "timeline" ? (
1788 <PullRequestTimelineGhost />
1789 ) : tab === "code" ? (
1790 <DiffPanelLoadingState label="Loading pull request diff..." />
1791 ) : (
1792 <PullRequestDetailGhost />
1793 )
1794 ) : detailQuery.error && !detail ? (
17481795 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
17491796 ) : detail ? (
17501797 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+24-91
......@@ -45,11 +45,13 @@ export function PullRequestListGhost({
4545 <GhostBar className="size-4 rounded-full" />
4646 <div className="min-w-0 space-y-1.5">
4747 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
48 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
48 <GhostBar
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
4951 </div>
5052 <div className="flex flex-col items-end gap-1.5">
5153 <GhostBar className="w-12" />
52 <GhostBar className="w-16" />
54 <GhostBar className="w-16 bg-muted-foreground/10" />
5355 </div>
5456 </div>
5557 ))}
......@@ -57,101 +59,32 @@ export function PullRequestListGhost({
5759 );
5860}
5961
60/**
61 * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description
62 * boundaries in the ghost prevents the loaded pull request from replacing one layout with
63 * another a moment later.
64 */
62/** The summary's own shape: a title, a byline, the facts rows, the description. */
6563export function PullRequestDetailGhost() {
6664 return (
6765 <div
6866 role="status"
6967 aria-label="Loading pull request"
70 className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background"
68 className="animate-ghost-pulse space-y-6 px-4 py-5"
7169 >
72 <div className="shrink-0 border-b border-border/60">
73 <div className="flex h-7 items-center justify-between gap-3 px-4">
74 <div className="flex min-w-0 flex-1 items-center gap-1.5">
75 <GhostBar className="w-24" />
76 <GhostBar className="w-9" />
77 </div>
78 <div className="flex shrink-0 items-center gap-1">
79 <GhostBar className="h-5 w-16 rounded-md" />
80 <GhostBar className="size-5 rounded-md" />
81 </div>
82 </div>
83
84 <div className="px-4 pb-4 pt-1">
85 <GhostBar className="h-5 w-4/5 max-w-md" />
86 <div className="mt-2 flex items-center gap-1.5">
87 <GhostBar className="size-4 rounded-full" />
88 <GhostBar className="w-24" />
89 </div>
90 <div className="mt-4 flex min-w-0 items-center gap-2">
91 <GhostBar className="h-6 w-24 rounded-md" />
92 <GhostBar className="size-3 rounded-full" />
93 <GhostBar className="h-6 w-32 rounded-md" />
94 <div className="ml-auto flex shrink-0 items-center gap-2">
95 <GhostBar className="w-10" />
96 <GhostBar className="w-20" />
97 </div>
98 </div>
99 </div>
100
101 <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2">
102 <div className="flex items-center gap-1 p-0.5">
103 <GhostBar className="h-6 w-16 rounded-md" />
104 <GhostBar className="h-6 w-16 rounded-md" />
105 <GhostBar className="h-6 w-12 rounded-md" />
106 </div>
107 <GhostBar className="w-20" />
108 </div>
70 <div className="space-y-2">
71 <GhostBar className="h-5 w-4/5" />
72 <GhostBar className="w-2/5 bg-muted-foreground/10" />
10973 </div>
110
111 <div className="min-h-0 flex-1 overflow-hidden">
112 <section className="px-4 py-3">
113 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
114 <div className="flex items-center gap-1.5">
115 <GhostBar className="size-3.5 rounded-full" />
116 <GhostBar className="w-14" />
117 </div>
118 <div className="flex items-center gap-1">
119 <GhostBar className="size-4 rounded-full" />
120 <GhostBar className="size-4 rounded-full" />
121 <GhostBar className="ml-1 size-5 rounded-md" />
122 </div>
123 </div>
124 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
125 <div className="flex items-center gap-1.5">
126 <GhostBar className="size-3.5 rounded-full" />
127 <GhostBar className="w-10" />
128 </div>
129 <div className="flex items-center gap-1">
130 <GhostBar className="h-5 w-24 rounded-full" />
131 <GhostBar className="h-5 w-20 rounded-full" />
132 </div>
133 </div>
134 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
135 <div className="flex items-center gap-1.5">
136 <GhostBar className="size-3.5 rounded-full" />
137 <GhostBar className="w-14" />
138 </div>
139 <GhostBar className="w-20" />
140 </div>
141 </section>
142
143 <section className="border-t border-border/60">
144 <div className="flex min-h-11 items-center gap-1.5 px-4 py-3">
145 <GhostBar className="h-4 w-24" />
74 <div className="space-y-3">
75 {Array.from({ length: 4 }, (_, index) => (
76 <div key={index} className="flex items-center gap-3">
14677 <GhostBar className="size-3.5 rounded-full" />
78 <GhostBar className="w-20 bg-muted-foreground/10" />
79 <GhostBar className={TITLE_WIDTHS[(index + 1) % TITLE_WIDTHS.length]} />
14780 </div>
148 <div className="space-y-2 px-4 pb-4">
149 <GhostBar className="w-full" />
150 <GhostBar className="w-11/12" />
151 <GhostBar className="w-4/5" />
152 <GhostBar className="w-2/3" />
153 </div>
154 </section>
81 ))}
82 </div>
83 <div className="space-y-2 pt-1">
84 <GhostBar className="w-full bg-muted-foreground/10" />
85 <GhostBar className="w-11/12 bg-muted-foreground/10" />
86 <GhostBar className="w-4/5 bg-muted-foreground/10" />
87 <GhostBar className="w-2/3 bg-muted-foreground/10" />
15588 </div>
15689 </div>
15790 );
......@@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
180113 <div key={index} className="relative pb-5">
181114 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
182115 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
183 <GhostBar className="mt-1.5 w-16" />
116 <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" />
184117 </div>
185118 ))}
186119 </div>
......@@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
201134 <GhostBar className="size-5 shrink-0 rounded-full" />
202135 <div className="flex-1 space-y-1.5">
203136 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
204 <GhostBar className="w-full" />
205 <GhostBar className="w-3/4" />
137 <GhostBar className="w-full bg-muted-foreground/10" />
138 <GhostBar className="w-3/4 bg-muted-foreground/10" />
206139 </div>
207140 </div>
208141 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+6-9
......@@ -25,7 +25,6 @@ import { cn } from "~/lib/utils";
2525import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
2626import { ProjectFavicon } from "../ProjectFavicon";
2727import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
28import { Button } from "../ui/button";
2928
3029import {
3130 Menu,
......@@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({
262261 return (
263262 <Menu>
264263 <MenuTrigger
265 render={
266 <Button
267 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}
268 size="icon"
269 variant="outline"
270 aria-label="Filter pull requests"
271 />
272 }
264 className={cn(
265 // The icon-button size that pairs with a full-height input, so the two read as one strip.
266 "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",
267 filtered && "text-foreground",
268 )}
269 aria-label="Filter pull requests"
273270 >
274271 <ListFilterIcon className="size-4" />
275272 {filtered ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3
......@@ -196,12 +196,12 @@ function MetaRow({
196196 children: ReactNode;
197197}) {
198198 return (
199 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">
200 <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
199 <div className="flex items-center gap-2 py-1.5 text-xs">
200 <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground">
201201 {icon}
202202 {label}
203203 </span>
204 <span className="min-w-0 text-foreground">{children}</span>
204 <span className="min-w-0 flex-1 text-foreground">{children}</span>
205205 </div>
206206 );
207207}
apps/web/src/components/settings/DiagnosticsSettings.tsx+1-1
......@@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() {
992992 : false;
993993
994994 return (
995 <SettingsPageContainer width="expanded" className="gap-10">
995 <SettingsPageContainer className="max-w-6xl gap-10">
996996 <ResourceTelemetryDiagnostics />
997997
998998 <SettingsSection
apps/web/src/components/settings/KeybindingsSettings.tsx+1-1
......@@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() {
11951195 );
11961196
11971197 return (
1198 <SettingsPageContainer width="wide">
1198 <SettingsPageContainer className="max-w-5xl">
11991199 <SettingsSection
12001200 {...searchableSetting("keybindings")}
12011201 headerAction={
apps/web/src/components/settings/ProjectSettingsPanel.tsx+22-4
......@@ -81,6 +81,8 @@ import {
8181 type NewProjectScriptInput,
8282 type ProjectScriptEditorRequest,
8383} from "../projectScriptEditor";
84import { cn } from "../../lib/utils";
85import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
8486import { Button } from "../ui/button";
8587import { Input } from "../ui/input";
8688import {
......@@ -100,7 +102,6 @@ import {
100102 WorkspaceBreadcrumbItem,
101103 WorkspaceBreadcrumbSeparator,
102104} from "../WorkspaceBreadcrumb";
103import { WorkspacePageHeader } from "../WorkspacePageContainer";
104105import {
105106 SettingResetButton,
106107 SettingsPageContainer,
......@@ -173,9 +174,26 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
173174 return (
174175 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
175176 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
176 <WorkspacePageHeader electron={isElectron}>
177 <ProjectSettingsBreadcrumb projectKey={projectKey} />
178 </WorkspacePageHeader>
177 {!isElectron && (
178 <header
179 className={cn(
180 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
181 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
182 )}
183 >
184 <ProjectSettingsBreadcrumb projectKey={projectKey} />
185 </header>
186 )}
187 {isElectron && (
188 <div
189 className={cn(
190 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
191 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
192 )}
193 >
194 <ProjectSettingsBreadcrumb projectKey={projectKey} />
195 </div>
196 )}
179197 <ProjectSettingsPanel projectKey={projectKey} />
180198 </div>
181199 </SidebarInset>
apps/web/src/components/settings/SettingsSidebarNav.tsx+22-3
......@@ -9,6 +9,7 @@ import {
99} from "react";
1010import {
1111 ArchiveIcon,
12 ArrowLeftIcon,
1213 BotIcon,
1314 GitBranchIcon,
1415 KeyboardIcon,
......@@ -18,7 +19,7 @@ import {
1819 Settings2Icon,
1920 XIcon,
2021} from "lucide-react";
21import { useLocation, useNavigate } from "@tanstack/react-router";
22import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
2223
2324import { Button } from "../ui/button";
2425import { Input } from "../ui/input";
......@@ -33,7 +34,6 @@ import {
3334 useSidebar,
3435} from "../ui/sidebar";
3536import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
36import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
3737import { scrollToSettingsTarget } from "./settingsLayout";
3838import {
3939 searchSettings,
......@@ -72,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
7272export function SettingsSidebarNav({ pathname }: { pathname: string }) {
7373 const navigate = useNavigate();
7474 const currentHash = useLocation({ select: (location) => location.hash });
75 const canGoBack = useCanGoBack();
7576 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
7677 const searchInputRef = useRef<HTMLInputElement>(null);
7778 const [query, setQuery] = useState("");
......@@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
175176 },
176177 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
177178 );
179 const handleBackClick = useCallback(() => {
180 if (isMobile) {
181 setOpenMobile(false);
182 }
183 if (canGoBack) {
184 window.history.back();
185 return;
186 }
187 void navigate({ to: "/" });
188 }, [canGoBack, isMobile, navigate, setOpenMobile]);
189
178190 return (
179191 <>
180192 <SidebarContent className="overflow-x-hidden">
......@@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
284296 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
285297 <T3ConnectSidebarSignIn />
286298 <div className="flex items-center gap-1">
287 <SidebarUtilityMenu />
299 <SidebarMenu className="min-w-0 flex-1">
300 <SidebarMenuItem>
301 <SidebarMenuButton onClick={handleBackClick}>
302 <ArrowLeftIcon />
303 <span>Back</span>
304 </SidebarMenuButton>
305 </SidebarMenuItem>
306 </SidebarMenu>
288307 <T3ConnectSidebarAvatar />
289308 </div>
290309 </SidebarFooter>
apps/web/src/components/settings/ThemeSettings.tsx+1-1
......@@ -768,7 +768,7 @@ export function ThemeLibrary({
768768 <TooltipProvider>
769769 <div
770770 className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4"
771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 1fr))" }}
772772 >
773773 {STANDARD_THEME_CARDS.map((standardTheme) => (
774774 <ThemeLibraryCard
apps/web/src/components/settings/settingsLayout.tsx+3-6
......@@ -12,7 +12,6 @@ import {
1212} from "react";
1313
1414import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
1615import { Button } from "../ui/button";
1716import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1817
......@@ -234,11 +233,9 @@ export function SettingResetButton({
234233export function SettingsPageContainer({
235234 children,
236235 className,
237 width = "readable",
238236}: {
239237 children: ReactNode;
240238 className?: string;
241 width?: WorkspacePageWidth;
242239}) {
243240 const navigate = useNavigate();
244241 const hash = useLocation({ select: (location) => location.hash });
......@@ -250,12 +247,12 @@ export function SettingsPageContainer({
250247 return (
251248 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
252249 <div
253 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]"
250 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto px-4 pt-10 pb-7 sm:px-8 sm:pt-12 sm:pb-10"
254251 data-settings-page-scroll
255252 >
256 <WorkspacePageContainer width={width} className={cn("gap-12", className)}>
253 <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}>
257254 {children}
258 </WorkspacePageContainer>
255 </div>
259256 </div>
260257 </SettingsSearchTargetProvider>
261258 );
apps/web/src/components/sidebar/SidebarChrome.tsx+68-78
......@@ -4,9 +4,8 @@ import {
44 GitPullRequestIcon,
55 SettingsIcon,
66} from "lucide-react";
7import type { ReactNode } from "react";
87import { memo, useCallback } from "react";
9import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
8import { Link, useLocation, useNavigate } from "@tanstack/react-router";
109
1110import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
1211import { cn } from "../../lib/utils";
......@@ -118,44 +117,16 @@ function T3Wordmark() {
118117 );
119118}
120119
121function SidebarUtilityItem({
122 icon,
123 label,
124 onClick,
125}: {
126 icon: ReactNode;
127 label: string;
128 onClick: () => void;
129}) {
130 return (
131 <SidebarMenuItem className="shrink-0">
132 <Tooltip>
133 <TooltipTrigger
134 render={
135 <SidebarMenuButton aria-label={label} onClick={onClick} size="icon">
136 {icon}
137 </SidebarMenuButton>
138 }
139 />
140 <TooltipPopup side="top">{label}</TooltipPopup>
141 </Tooltip>
142 </SidebarMenuItem>
143 );
144}
145
146export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
120export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
147121 const navigate = useNavigate();
148 const canGoBack = useCanGoBack();
149122 const { isMobile, setOpenMobile } = useSidebar();
150123 const currentFooterPage = useLocation({
151124 select: (location) =>
152 /^\/settings(?:\/|$)/.test(location.pathname)
153 ? "settings"
154 : location.pathname === "/usage"
155 ? "usage"
156 : location.pathname === "/pull-requests"
157 ? "pull-requests"
158 : null,
125 location.pathname === "/usage"
126 ? "usage"
127 : location.pathname === "/pull-requests"
128 ? "pull-requests"
129 : null,
159130 });
160131 const { environments } = useEnvironments();
161132 // 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() {
186157
187158 const handleBackClick = useCallback(() => {
188159 closeMobileSidebar();
189 if (canGoBack) {
190 window.history.back();
191 return;
192 }
193160 void navigate({ to: "/" });
194 }, [canGoBack, closeMobileSidebar, navigate]);
195
196 return (
197 <SidebarMenu className="flex-row items-center">
198 {currentFooterPage ? (
199 <SidebarMenuItem className="min-w-0 flex-1">
200 <SidebarMenuButton onClick={handleBackClick}>
201 <ArrowLeftIcon />
202 <span>Back</span>
203 </SidebarMenuButton>
204 </SidebarMenuItem>
205 ) : (
206 <>
207 <SidebarUtilityItem
208 icon={<SettingsIcon />}
209 label="Settings"
210 onClick={handleSettingsClick}
211 />
212 {pullRequestsSupported ? (
213 <SidebarUtilityItem
214 icon={<GitPullRequestIcon />}
215 label="Pull Requests"
216 onClick={handlePullRequestsClick}
217 />
218 ) : null}
219 <SidebarUtilityItem
220 icon={<ChartNoAxesColumnIcon />}
221 label="Usage"
222 onClick={handleUsageClick}
223 />
224 </>
225 )}
226 <SidebarUpdatePill />
227 </SidebarMenu>
228 );
229});
161 }, [closeMobileSidebar, navigate]);
230162
231export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
232163 return (
233164 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
234165 <SidebarProviderUpdatePill />
235166 <SidebarUpdateArchitectureWarning />
236 <SidebarUtilityMenu />
167 <SidebarMenu className="flex-row items-center">
168 {currentFooterPage ? (
169 <SidebarMenuItem className="min-w-0 flex-1">
170 <SidebarMenuButton onClick={handleBackClick}>
171 <ArrowLeftIcon />
172 <span>Back</span>
173 </SidebarMenuButton>
174 </SidebarMenuItem>
175 ) : (
176 <>
177 <SidebarMenuItem className="shrink-0">
178 <Tooltip>
179 <TooltipTrigger
180 render={
181 <SidebarMenuButton
182 aria-label="Settings"
183 onClick={handleSettingsClick}
184 size="icon"
185 >
186 <SettingsIcon />
187 </SidebarMenuButton>
188 }
189 />
190 <TooltipPopup side="top">Settings</TooltipPopup>
191 </Tooltip>
192 </SidebarMenuItem>
193 {pullRequestsSupported ? (
194 <SidebarMenuItem className="shrink-0">
195 <Tooltip>
196 <TooltipTrigger
197 render={
198 <SidebarMenuButton
199 aria-label="Pull Requests"
200 onClick={handlePullRequestsClick}
201 size="icon"
202 >
203 <GitPullRequestIcon />
204 </SidebarMenuButton>
205 }
206 />
207 <TooltipPopup side="top">Pull Requests</TooltipPopup>
208 </Tooltip>
209 </SidebarMenuItem>
210 ) : null}
211 <SidebarMenuItem className="shrink-0">
212 <Tooltip>
213 <TooltipTrigger
214 render={
215 <SidebarMenuButton aria-label="Usage" onClick={handleUsageClick} size="icon">
216 <ChartNoAxesColumnIcon />
217 </SidebarMenuButton>
218 }
219 />
220 <TooltipPopup side="top">Usage</TooltipPopup>
221 </Tooltip>
222 </SidebarMenuItem>
223 </>
224 )}
225 <SidebarUpdatePill />
226 </SidebarMenu>
237227 </SidebarFooter>
238228 );
239229});
apps/web/src/components/threadActionMenu.logic.test.ts+4-10
......@@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] {
1919 return buildThreadActionMenuItems(state).map((item) => item.id);
2020}
2121
22function allIds(state: ThreadActionMenuState): string[] {
23 const flatten = (items: ReturnType<typeof buildThreadActionMenuItems>): string[] =>
24 items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]);
25 return flatten(buildThreadActionMenuItems(state));
26}
27
2822describe("buildThreadActionMenuItems", () => {
2923 it("hides lifecycle items when the environment lacks the capabilities", () => {
3024 expect(
......@@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => {
3226 ...baseState,
3327 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
3428 }),
35 ).toEqual(["rename", "mark-unread", "copy", "delete"]);
29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);
3630 });
3731
3832 it("includes branch items only for threads with a branch", () => {
39 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
33 const withBranch = ids({ ...baseState, branch: "feat/menu" });
4034 expect(withBranch).toContain("new-thread-on-branch");
4135 expect(withBranch).toContain("copy-branch");
42 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
43 expect(allIds(baseState)).not.toContain("copy-branch");
36 expect(ids(baseState)).not.toContain("new-thread-on-branch");
37 expect(ids(baseState)).not.toContain("copy-branch");
4438 });
4539
4640 it("flips lifecycle labels with thread state", () => {
apps/web/src/components/threadActionMenu.logic.ts+11-31
......@@ -18,7 +18,6 @@ export type ThreadActionMenuId =
1818 | "rename"
1919 | "regenerate-title"
2020 | "mark-unread"
21 | "copy"
2221 | "copy-path"
2322 | "copy-branch"
2423 | "copy-thread-id"
......@@ -54,15 +53,14 @@ export function buildThreadActionMenuItems(
5453 {
5554 id: "new-thread-on-branch" as const,
5655 label: `New thread on ${state.branch}`,
57 icon: "message-square-plus",
5856 },
5957 ]
6058 : []),
6159 ...(state.supports.pinning
6260 ? [
6361 state.isPinned
64 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
65 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
62 ? { id: "unpin" as const, label: "Unpin thread" }
63 : { id: "pin" as const, label: "Pin thread" },
6664 ]
6765 : []),
6866 // Both lifecycle actions stay available on pinned threads: settling
......@@ -71,18 +69,17 @@ export function buildThreadActionMenuItems(
7169 ...(state.supports.settlement
7270 ? [
7371 state.isSettled
74 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
75 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
72 ? { id: "unsettle" as const, label: "Un-settle thread" }
73 : { id: "settle" as const, label: "Settle thread" },
7674 ]
7775 : []),
7876 ...(state.supports.snooze
7977 ? [
8078 state.isSnoozed
81 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
79 ? { id: "unsnooze" as const, label: "Wake thread" }
8280 : {
8381 id: "snooze" as const,
8482 label: "Snooze",
85 icon: "clock",
8683 disabled: !state.canSnoozeNow,
8784 children: state.snoozePresets.map((preset) => ({
8885 id: `snooze:${preset.id}` as const,
......@@ -91,37 +88,20 @@ export function buildThreadActionMenuItems(
9188 },
9289 ]
9390 : []),
94 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
91 { id: "rename", label: "Rename thread" },
9592 ...(state.supports.titleRegeneration
9693 ? [
9794 {
9895 id: "regenerate-title" as const,
9996 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
100 icon: "refresh-cw",
10197 disabled: state.isRegeneratingTitle,
10298 },
10399 ]
104100 : []),
105 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
106 {
107 id: "copy",
108 label: "Copy",
109 icon: "copy",
110 separatorBefore: true,
111 children: [
112 { id: "copy-path", label: "Path", icon: "folder" },
113 ...(state.branch
114 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
115 : []),
116 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
117 ],
118 },
119 {
120 id: "delete",
121 label: "Delete",
122 destructive: true,
123 icon: "trash",
124 separatorBefore: true,
125 },
101 { id: "mark-unread", label: "Mark unread" },
102 { id: "copy-path", label: "Copy path", icon: "copy" },
103 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
104 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
105 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
126106 ];
127107}
apps/web/src/components/ui/segmented-tabs.tsx deleted-40
......@@ -1,40 +0,0 @@
1import type { ComponentProps, HTMLAttributes } from "react";
2
3import { cn } from "~/lib/utils";
4import { Toggle } from "~/components/ui/toggle";
5
6function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
7 return (
8 <div
9 className={cn(
10 "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
11 className,
12 )}
13 role="group"
14 {...props}
15 />
16 );
17}
18
19function SegmentedTab({
20 selected,
21 density = "default",
22 className,
23 ...props
24}: {
25 selected: boolean;
26 density?: "default" | "compact";
27} & Omit<ComponentProps<typeof Toggle>, "aria-pressed" | "pressed" | "size" | "type" | "variant">) {
28 return (
29 <Toggle
30 type="button"
31 pressed={selected}
32 size={density === "compact" ? "segmented-compact" : "segmented"}
33 variant="segmented"
34 className={className}
35 {...props}
36 />
37 );
38}
39
40export { SegmentedTab, SegmentedTabList };
apps/web/src/components/ui/toggle.tsx-6
......@@ -18,10 +18,6 @@ const toggleVariants = cva(
1818 "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",
1919 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
2020 lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",
21 segmented:
22 "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]",
23 "segmented-compact":
24 "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]",
2521 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2622 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2723 },
......@@ -31,8 +27,6 @@ const toggleVariants = cva(
3127 "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",
3228 outline:
3329 "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",
34 segmented:
35 "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",
3630 },
3731 },
3832 },
apps/web/src/components/usage/UsagePage.tsx+243-150
......@@ -19,22 +19,13 @@ import {
1919 formatUsd,
2020 makeWindow,
2121} from "@t3tools/shared/usageFormat";
22import { Button } from "../ui/button";
2322import { ScrollArea } from "../ui/scroll-area";
23import { Button } from "../ui/button";
2424import { SidebarInset } from "../ui/sidebar";
25import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
26import {
27 WorkspaceBreadcrumb,
28 WorkspaceBreadcrumbItem,
29 WorkspaceBreadcrumbSeparator,
30} from "../WorkspaceBreadcrumb";
31import {
32 WorkspacePageContainer,
33 WorkspacePageHeader,
34 WorkspacePageHeaderEdgeControl,
35} from "../WorkspacePageContainer";
36import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
37import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
25import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
26import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
27import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
28import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
3829
3930const WINDOW_OPTIONS = [
4031 { days: 1, label: "Past 24h" },
......@@ -75,6 +66,21 @@ export function UsagePage() {
7566 [isPast24Hours, merged.daily, merged.hourly],
7667 );
7768
69 // Ranked by whatever the toggle is showing, so the bars always descend.
70 const orderedProviders = useMemo(
71 () =>
72 merged.providers.toSorted((a, b) =>
73 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
74 ),
75 [merged.providers, metric],
76 );
77
78 const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter(
79 (period) => period.totalTokens > 0,
80 ).length;
81 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
82 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
83 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
7884 const selectWindow = (days: number) => {
7985 setWindowSelection({
8086 days,
......@@ -94,66 +100,78 @@ export function UsagePage() {
94100 setWindowSelection({ days: windowDays, window: nextWindow });
95101 }
96102 };
97 const windowLabel =
98 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
99 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
100 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
101 const topbarContent = (
102 <div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
103 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
104 <WorkspaceBreadcrumbItem current>
105 <h1>Usage</h1>
106 </WorkspaceBreadcrumbItem>
107 <WorkspaceBreadcrumbSeparator />
108 <WorkspaceBreadcrumbItem className="min-w-0 shrink">
109 <span className="truncate">{windowLabel}</span>
110 </WorkspaceBreadcrumbItem>
111 </WorkspaceBreadcrumb>
112 <div className="ms-auto flex min-w-0 basis-full flex-wrap items-center justify-end gap-2 sm:basis-auto">
113 <SegmentedTabList aria-label="Usage metric">
114 {(["cost", "tokens"] as const).map((option) => (
115 <SegmentedTab
116 key={option}
117 selected={option === metric}
118 onClick={() => setMetric(option)}
119 >
120 {option === "cost" ? "Cost" : "Tokens"}
121 </SegmentedTab>
122 ))}
123 </SegmentedTabList>
124 <SegmentedTabList aria-label="Usage period">
125 {WINDOW_OPTIONS.map((option) => (
126 <SegmentedTab
127 key={option.days}
128 selected={option.days === windowDays}
129 onClick={() => selectWindow(option.days)}
130 >
131 {option.label}
132 </SegmentedTab>
133 ))}
134 </SegmentedTabList>
135 <WorkspacePageHeaderEdgeControl>
136 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
137 <RefreshCwIcon className="size-3.5" />
138 </Button>
139 </WorkspacePageHeaderEdgeControl>
140 </div>
141 </div>
142 );
143103
144104 return (
145105 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
146106 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
147 <WorkspacePageHeader electron={isElectron} className="h-auto py-2">
148 {topbarContent}
149 </WorkspacePageHeader>
107 {!isElectron && (
108 <header
109 className={cn(
110 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
111 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
112 )}
113 >
114 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
115 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
116 </WorkspaceBreadcrumb>
117 </header>
118 )}
119
120 {isElectron && (
121 <div
122 className={cn(
123 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
124 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
125 )}
126 >
127 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
128 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
129 </WorkspaceBreadcrumb>
130 </div>
131 )}
150132
151133 <ScrollArea className="min-h-0 flex-1">
152 <WorkspacePageContainer width="wide">
134 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">
135 <div className="flex flex-wrap items-center justify-between gap-4">
136 <p className="text-sm text-muted-foreground">
137 {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
138 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
139 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`}
140 </p>
141 <div className="flex items-center gap-2">
142 <div className="flex rounded-md border border-border">
143 {WINDOW_OPTIONS.map((option) => (
144 <button
145 key={option.days}
146 type="button"
147 aria-pressed={option.days === windowDays}
148 onClick={() => selectWindow(option.days)}
149 className={cn(
150 "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
151 option.days === windowDays
152 ? "bg-muted text-foreground"
153 : "text-muted-foreground hover:text-foreground",
154 )}
155 >
156 {option.label}
157 </button>
158 ))}
159 </div>
160 <Button
161 size="icon"
162 variant="outline"
163 onClick={refreshWindow}
164 aria-label="Refresh usage"
165 >
166 <RefreshCwIcon className="size-3.5" />
167 </Button>
168 </div>
169 </div>
170
153171 {settling ? (
154172 <>
155173 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
156 <UsageSkeleton />
174 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
157175 </>
158176 ) : (
159177 <>
......@@ -163,62 +181,88 @@ export function UsagePage() {
163181 staleEnvironments={merged.staleEnvironments}
164182 />
165183
166 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
167 <div className="flex min-w-0 flex-col gap-5">
184 {/* Cost first: the financial answer, then the provider split. */}
185 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
186 {/* The summary follows the chart toggle, so the headline and the
187 series are always reading the same units. */}
188 <div className="flex flex-col gap-5">
168189 <div className="flex flex-col gap-1">
190 <span className="text-xs tracking-wide text-muted-foreground uppercase">
191 {metric === "cost" ? "Raw token cost" : "Processed tokens"}
192 </span>
169193 <span className="text-4xl font-semibold text-foreground tabular-nums">
170194 {metric === "cost"
171 ? formatUsd(merged.costUsd)
195 ? `${formatUsd(merged.costUsd)}*`
172196 : formatTokens(merged.totalTokens)}
173197 </span>
174198 <span className="text-xs text-muted-foreground">
175199 {metric === "cost"
176 ? `${formatCount(merged.sessions)} sessions · API estimate`
177 : `${formatCount(merged.sessions)} sessions`}
200 ? "* if billed at full API rate"
201 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
178202 </span>
179203 </div>
180204
181 {PROVIDER_ORDER.map((provider) => {
182 const totals = merged.providers.find((entry) => entry.provider === provider);
183 const share =
184 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
185 const providerSessions = totals?.sessions ?? 0;
186 const sessionLabel = `${formatCount(providerSessions)} ${
187 providerSessions === 1 ? "session" : "sessions"
188 }`;
205 {orderedProviders.map((provider) => {
206 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
189207 return (
190 <div key={provider} className="flex flex-col gap-1">
191 <div className="flex items-baseline justify-between gap-4">
192 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
193 <ProviderMark provider={provider} className="size-4" />
194 <span className="flex min-w-0 items-baseline gap-1.5">
195 <span className="truncate">{PROVIDER_LABEL[provider]}</span>
196 <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground tabular-nums">
197 {sessionLabel}
198 </span>
199 </span>
208 <div key={provider.provider} className="flex flex-col gap-1.5">
209 <div className="flex items-baseline justify-between">
210 <span className="flex items-center gap-2 text-sm text-foreground">
211 <ProviderMark provider={provider.provider} className="size-4" />
212 {PROVIDER_LABEL[provider.provider]}
200213 </span>
201 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
214 <span className="text-sm text-foreground tabular-nums">
202215 {metric === "cost"
203 ? formatUsd(totals?.costUsd ?? 0)
204 : formatTokens(totals?.totalTokens ?? 0)}
216 ? formatUsd(provider.costUsd)
217 : formatTokens(provider.totalTokens)}
205218 </span>
206219 </div>
220 <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
221 <div
222 className="h-full"
223 style={{
224 width: `${(share * 100).toFixed(1)}%`,
225 backgroundColor: PROVIDER_COLOR[provider.provider],
226 }}
227 />
228 </div>
207229 <span className="text-xs text-muted-foreground">
208230 {metric === "cost"
209 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
210 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
231 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
232 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
211233 </span>
212234 </div>
213235 );
214236 })}
215237 </div>
216238
217 <div className="flex min-w-0 flex-col gap-3">
218 <h2 className="text-sm font-medium text-foreground">
219 {isPast24Hours ? "Hourly" : "Daily"}{" "}
220 {metric === "tokens" ? "processed tokens" : "cost"}
221 </h2>
239 <div className="flex flex-col gap-3">
240 <div className="flex flex-wrap items-center justify-between gap-3">
241 <h2 className="text-sm font-medium text-foreground">
242 {isPast24Hours ? "Hourly" : "Daily"}{" "}
243 {metric === "tokens" ? "processed tokens" : "cost"}
244 </h2>
245 <div className="flex items-center gap-4">
246 <div className="flex overflow-hidden rounded-md border border-border">
247 {(["cost", "tokens"] as const).map((option) => (
248 <button
249 key={option}
250 type="button"
251 onClick={() => setMetric(option)}
252 className={cn(
253 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
254 option === metric
255 ? "bg-muted text-foreground"
256 : "text-muted-foreground hover:text-foreground",
257 )}
258 >
259 {option}
260 </button>
261 ))}
262 </div>
263 <UsageChartLegend />
264 </div>
265 </div>
222266 <UsageProviderChart
223267 days={days}
224268 daily={merged.daily}
......@@ -232,42 +276,63 @@ export function UsagePage() {
232276 </div>
233277 </section>
234278
235 <section className="flex flex-col gap-2">
236 <h2 className="text-sm font-medium text-foreground">Totals</h2>
237 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
238 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
239 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
240 <Metric
241 label="Uncached input"
242 value={formatTokens(merged.uncachedInputTokens)}
243 />
244 <Metric label="Output" value={formatTokens(merged.outputTokens)} />
245 <Metric
246 label="Cache savings"
247 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
248 />
249 </div>
279 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
280 <Metric
281 label="Processed tokens"
282 value={formatTokens(merged.totalTokens)}
283 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}
284 />
285 <Metric
286 label="Cached input"
287 value={formatTokens(merged.cachedInputTokens)}
288 detail={`${formatPercent(cachedShare)} of observed input`}
289 />
290 <Metric
291 label="Uncached input"
292 value={formatTokens(merged.uncachedInputTokens)}
293 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
294 />
295 <Metric
296 label="Output"
297 value={formatTokens(merged.outputTokens)}
298 detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`}
299 />
300 <Metric
301 label="Cache savings"
302 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
303 detail={
304 merged.costUsd > 0
305 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost`
306 : "vs full input rates"
307 }
308 />
250309 </section>
251310
252311 <section className="flex flex-col gap-3">
253312 <div className="flex items-center justify-between gap-3">
254313 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
255 <SegmentedTabList aria-label="Usage breakdown">
314 <div className="flex overflow-hidden rounded-md border border-border">
256315 {(
257316 [
258 { value: "model", label: "Model" },
259 { value: "time", label: isPast24Hours ? "Hour" : "Day" },
317 { value: "model", label: "model" },
318 { value: "time", label: isPast24Hours ? "hour" : "day" },
260319 ] as const
261320 ).map((option) => (
262 <SegmentedTab
321 <button
263322 key={option.value}
264 selected={option.value === breakdown}
323 type="button"
265324 onClick={() => setBreakdown(option.value)}
325 className={cn(
326 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
327 option.value === breakdown
328 ? "bg-muted text-foreground"
329 : "text-muted-foreground hover:text-foreground",
330 )}
266331 >
267332 {option.label}
268 </SegmentedTab>
333 </button>
269334 ))}
270 </SegmentedTabList>
335 </div>
271336 </div>
272337
273338 {breakdown === "model" ? (
......@@ -291,7 +356,7 @@ export function UsagePage() {
291356 merged.models.map((model) => (
292357 <tr
293358 key={`${model.provider}:${model.model}`}
294 className="hover:bg-muted/20"
359 className="border-b border-border/50"
295360 >
296361 <td className="py-2 text-foreground">
297362 <span className="flex items-center gap-2">
......@@ -338,7 +403,7 @@ export function UsagePage() {
338403 recentPeriods.map((period) => (
339404 <tr
340405 key={"hourStart" in period ? period.hourStart : period.day}
341 className="hover:bg-muted/20"
406 className="border-b border-border/50"
342407 >
343408 <td className="py-2 text-foreground">
344409 {"hourStart" in period
......@@ -368,7 +433,7 @@ export function UsagePage() {
368433 </section>
369434 </>
370435 )}
371 </WorkspacePageContainer>
436 </div>
372437 </ScrollArea>
373438 </div>
374439 </SidebarInset>
......@@ -387,11 +452,20 @@ function ProviderMark({
387452 return <Mark className={cn("shrink-0", className)} aria-hidden />;
388453}
389454
390function Metric({ label, value }: { readonly label: string; readonly value: string }) {
455function Metric({
456 label,
457 value,
458 detail,
459}: {
460 readonly label: string;
461 readonly value: string;
462 readonly detail: string;
463}) {
391464 return (
392 <div className="flex min-w-0 flex-col gap-0.5">
465 <div className="flex flex-col gap-0.5 bg-background px-4 py-3">
393466 <span className="text-xs text-muted-foreground">{label}</span>
394 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
467 <span className="text-lg text-foreground tabular-nums">{value}</span>
468 <span className="text-xs text-muted-foreground">{detail}</span>
395469 </div>
396470 );
397471}
......@@ -495,51 +569,70 @@ function UsageDeviceStrip({
495569 );
496570}
497571
572/** Deterministic bar heights (each unique: they double as keys). */
573const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
574
498575/**
499 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
500 * exactly once when the last device answers.
576 * Static stand-in with the loaded page's shape: headline, provider split,
577 * chart and metrics strip. No shimmer; blocks fill in exactly once when the
578 * last device answers.
501579 */
502function UsageSkeleton() {
580function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
503581 return (
504582 <>
505 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
583 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
506584 <div className="flex flex-col gap-5">
507585 <div className="flex flex-col gap-1">
508 <div className="h-8 w-36 rounded-sm bg-muted" />
509 <div className="h-3 w-32 rounded-sm bg-muted" />
586 <span className="text-xs tracking-wide text-muted-foreground uppercase">
587 Raw token cost
588 </span>
589 <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
590 <div className="h-3 w-28 rounded-sm bg-muted" />
510591 </div>
592
511593 {PROVIDER_ORDER.map((provider) => (
512 <div key={provider} className="flex flex-col gap-1">
513 <div className="flex items-center justify-between gap-4">
514 <span className="flex items-center gap-2">
594 <div key={provider} className="flex flex-col gap-1.5">
595 <div className="flex items-center justify-between">
596 <span className="flex items-center gap-2 text-sm text-foreground">
515597 <ProviderMark provider={provider} className="size-4" />
516 <div className="h-3.5 w-20 rounded-sm bg-muted" />
598 {PROVIDER_LABEL[provider]}
517599 </span>
518600 <div className="h-3.5 w-14 rounded-sm bg-muted" />
519601 </div>
602 <div className="h-1 w-full rounded-full bg-muted" />
520603 <div className="h-3 w-36 rounded-sm bg-muted" />
521604 </div>
522605 ))}
523606 </div>
524607
525608 <div className="flex flex-col gap-3">
526 <div className="h-4 w-24 rounded-sm bg-muted" />
527 <div className="h-56 rounded-sm bg-muted/35" />
609 <h2 className="py-1 text-sm font-medium text-foreground">
610 {resolution === "hour" ? "Hourly" : "Daily"} cost
611 </h2>
612 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
613 relayout when the real chart swaps in. */}
614 <div className="flex h-56 items-end gap-1 pl-16">
615 {SKELETON_BAR_HEIGHTS.map((height) => (
616 <div
617 key={height}
618 className="flex-1 rounded-sm bg-muted"
619 style={{ height: `${height}%` }}
620 />
621 ))}
622 </div>
528623 </div>
529624 </section>
530625
531 <section className="flex flex-col gap-2">
532 <h2 className="text-sm font-medium text-foreground">Totals</h2>
533 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
534 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
535 (label) => (
536 <div key={label} className="flex flex-col gap-0.5">
537 <span className="text-xs text-muted-foreground">{label}</span>
538 <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" />
539 </div>
540 ),
541 )}
542 </div>
626 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
627 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
628 (label) => (
629 <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">
630 <span className="text-xs text-muted-foreground">{label}</span>
631 <div className="my-1 h-5 w-16 rounded-sm bg-muted" />
632 <div className="h-3 w-24 rounded-sm bg-muted" />
633 </div>
634 ),
635 )}
543636 </section>
544637 </>
545638 );
apps/web/src/components/usage/UsageProviderChart.tsx+65-72
......@@ -1,5 +1,5 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
2import { useCallback, useMemo, useRef, useState } from "react";
33
44import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
55import {
......@@ -68,7 +68,13 @@ function buildPeriodColumns(
6868 });
6969}
7070
71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
71/**
72 * Monotone cubic tangents (Fritsch-Carlson).
73 *
74 * Plain cubic smoothing overshoots on spiky daily data and would dip the area
75 * below zero between points, which reads as negative spend. This variant is
76 * shape-preserving, so a smoothed series never leaves the range of its samples.
77 */
7278function monotoneTangents(points: readonly Point[]): readonly number[] {
7379 const count = points.length;
7480 if (count < 2) return [0];
......@@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
109115 return tangents;
110116}
111117
118/** One cubic segment of a smoothed boundary. */
112119interface CurveSegment {
113120 readonly from: Point;
114121 readonly c1: Point;
......@@ -116,6 +123,7 @@ interface CurveSegment {
116123 readonly to: Point;
117124}
118125
126/** Smoothed polyline through `points`, as explicit cubic control points. */
119127function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
120128 if (points.length < 2) return [];
121129 const tangents = monotoneTangents(points);
......@@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
136144 return segments;
137145}
138146
139function curvePath(segments: readonly CurveSegment[]): string {
147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
140148 const first = segments[0];
141149 if (first === undefined) return "";
142 let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
150 let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
143151 for (const segment of segments) {
144152 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)}`;
145153 }
......@@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
171179/**
172180 * Turns the merged daily totals into one column per day.
173181 *
174 * Values are absolute, not cumulative: each provider is drawn from the same
175 * zero baseline so the chart never implies that one provider is always larger.
182 * Values are absolute, not cumulative: the series are layered from a shared
183 * zero baseline rather than stacked. A stacked chart puts whichever provider is
184 * drawn last permanently above the other, which reads as "that one is bigger"
185 * even on days where it is not.
176186 *
177187 * The chart paths and the hover readout both consume this, so the number under
178188 * the cursor is by construction the number that was plotted rather than a
......@@ -206,39 +216,42 @@ export function UsageProviderChart({
206216 );
207217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
208218 const plotRef = useRef<HTMLDivElement | null>(null);
209 const tooltipRef = useRef<HTMLDivElement | null>(null);
210 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
211219
212 const { paths, series, stepX, ticks, toY } = useMemo(() => {
220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
213221 if (periods.length === 0) {
214222 return {
215223 paths: [],
216 series: [] as readonly DayColumn[],
217 stepX: 0,
218224 ticks: [0] as readonly number[],
225 stepX: 0,
219226 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
220228 };
221229 }
222230
223231 const columns = buildPeriodColumns(periods, byPeriod, metric);
232
233 // The scale tops out at the largest single provider-day, not the largest
234 // sum: layered series each measure from zero, so a combined peak would
235 // leave the plot permanently half empty.
224236 const peak = columns.reduce(
225237 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
226238 0,
227239 );
228240 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
229241 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
242 // Reserve a sliver above the top gridline so the series stroke, which is
243 // drawn at constant screen width, is not shaved off at a peak.
230244 const toY = (value: number) =>
231245 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
232246
233247 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
234 const line = curvePath(
235 smoothCurve(
236 columns.map((column, periodIndex) => ({
237 x: periodIndex * step,
238 y: toY(column.bands[providerIndex]?.value ?? 0),
239 })),
240 ),
248 const curve = smoothCurve(
249 columns.map((column, dayIndex) => ({
250 x: dayIndex * step,
251 y: toY(column.bands[providerIndex]?.value ?? 0),
252 })),
241253 );
254 const line = curvePath(curve, "M");
242255 return {
243256 provider,
244257 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
......@@ -247,65 +260,30 @@ export function UsageProviderChart({
247260 };
248261 });
249262
250 return {
251 paths: built.toSorted((a, b) => b.total - a.total),
252 series: columns,
253 stepX: step,
254 ticks: tickValues,
255 toY,
256 };
263 // Paint the heavier series first so the lighter one is never buried under
264 // it. The fills are faint enough that the order barely shows, but the
265 // strokes are drawn in a second pass regardless, so neither can be hidden.
266 const ordered = [...built].sort((a, b) => b.total - a.total);
267
268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
257269 }, [byPeriod, metric, periods]);
258270
259271 const format = metric === "tokens" ? formatTokens : formatUsd;
260272
261 const positionTooltip = useCallback(() => {
262 const plot = plotRef.current;
263 const tooltip = tooltipRef.current;
264 const hoverPosition = hoverPositionRef.current;
265 if (plot === null || tooltip === null || hoverPosition === null) return;
266
267 const gap = 12;
268 const tooltipWidth = tooltip.offsetWidth;
269 const tooltipHeight = tooltip.offsetHeight;
270 const plotWidth = plot.clientWidth;
271 const plotHeight = plot.clientHeight;
272 const preferredLeft =
273 hoverPosition.x + gap + tooltipWidth <= plotWidth
274 ? hoverPosition.x + gap
275 : hoverPosition.x - gap - tooltipWidth;
276 const preferredTop =
277 hoverPosition.y + gap + tooltipHeight <= plotHeight
278 ? hoverPosition.y + gap
279 : hoverPosition.y - gap - tooltipHeight;
280 const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
281 const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
282 plot.style.setProperty("--usage-tooltip-left", `${left}px`);
283 plot.style.setProperty("--usage-tooltip-top", `${top}px`);
284 }, []);
285
286 useLayoutEffect(() => {
287 if (hoverIndex !== null) positionTooltip();
288 }, [hoverIndex, positionTooltip]);
289
290273 const handleMove = useCallback(
291274 (event: React.MouseEvent<HTMLDivElement>) => {
292 const plot = plotRef.current;
293 if (plot === null || periods.length === 0) return;
294 const bounds = plot.getBoundingClientRect();
295 if (bounds.width === 0) return;
296 const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
297 const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
298 const fraction = localX / bounds.width;
275 const bounds = plotRef.current?.getBoundingClientRect();
276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;
277 const fraction = (event.clientX - bounds.left) / bounds.width;
299278 const index = Math.round(fraction * (periods.length - 1));
300 hoverPositionRef.current = { x: localX, y: localY };
301 positionTooltip();
302279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
303280 },
304 [periods.length, positionTooltip],
281 [periods.length],
305282 );
306283
307284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
308285 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
309287 const formatPeriod = (period: string) =>
310288 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
311289 const formatTooltipPeriod = (period: string) =>
......@@ -333,10 +311,7 @@ export function UsageProviderChart({
333311 ref={plotRef}
334312 className="relative h-56 flex-1"
335313 onMouseMove={handleMove}
336 onMouseLeave={() => {
337 hoverPositionRef.current = null;
338 setHoverIndex(null);
339 }}
314 onMouseLeave={() => setHoverIndex(null)}
340315 >
341316 <svg
342317 className="h-full w-full"
......@@ -362,6 +337,7 @@ export function UsageProviderChart({
362337 );
363338 })}
364339
340 {/* Fills first, then every stroke, so no series covers another's line. */}
365341 {paths.map(({ provider, area }) => (
366342 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
367343 ))}
......@@ -392,11 +368,10 @@ export function UsageProviderChart({
392368
393369 {hoveredPeriod === undefined ? null : (
394370 <div
395 ref={tooltipRef}
396 className="pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 bg-background/65 px-2.5 py-2 text-xs shadow-lg backdrop-blur-xl backdrop-saturate-150"
371 className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
397372 style={{
398 left: "var(--usage-tooltip-left, 0px)",
399 top: "var(--usage-tooltip-top, 0px)",
373 left: `${hoverLeft}%`,
374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
400375 }}
401376 >
402377 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
......@@ -443,3 +418,21 @@ export function UsageProviderChart({
443418 </div>
444419 );
445420}
421
422export function UsageChartLegend() {
423 return (
424 <div className="flex items-center gap-4">
425 {PROVIDER_ORDER.map((provider) => {
426 // The marks carry the same fills as the bands, so they key the chart
427 // just as a colour swatch would.
428 const Mark = PROVIDER_MARK[provider];
429 return (
430 <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground">
431 <Mark className="size-3.5 shrink-0" aria-hidden />
432 {PROVIDER_LABEL[provider]}
433 </span>
434 );
435 })}
436 </div>
437 );
438}
apps/web/src/components/usage/usageProviders.ts+3-1
......@@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";
33import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
55/**
6 * Stable provider reading order across summaries, tables, and hover rows.
6 * Series and table order. The chart layers both providers from a shared zero
7 * baseline, so this only fixes the reading order of legends, tables and hover
8 * rows; it does not decide which series sits above the other.
79 */
810export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
911
apps/web/src/contextMenuFallback.ts+6-92
......@@ -4,15 +4,6 @@ const SVG_NS = "http://www.w3.org/2000/svg";
44
55// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).
66const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {
7 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
8 "circle-check": [
9 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
10 { tag: "path", attrs: { d: "m9 12 2 2 4-4" } },
11 ],
12 clock: [
13 { tag: "path", attrs: { d: "M12 6v6l4 2" } },
14 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
15 ],
167 pencil: [
178 {
189 tag: "path",
......@@ -26,71 +17,6 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
2617 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
2718 { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } },
2819 ],
29 folder: [
30 {
31 tag: "path",
32 attrs: {
33 d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",
34 },
35 },
36 ],
37 "git-branch": [
38 { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } },
39 { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } },
40 { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } },
41 { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } },
42 ],
43 hash: [
44 { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } },
45 { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } },
46 { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } },
47 { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } },
48 ],
49 "mail-open": [
50 {
51 tag: "path",
52 attrs: {
53 d: "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z",
54 },
55 },
56 { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } },
57 ],
58 "message-square-plus": [
59 {
60 tag: "path",
61 attrs: {
62 d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",
63 },
64 },
65 { tag: "path", attrs: { d: "M12 8v6" } },
66 { tag: "path", attrs: { d: "M9 11h6" } },
67 ],
68 pin: [
69 { tag: "path", attrs: { d: "M12 17v5" } },
70 {
71 tag: "path",
72 attrs: {
73 d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z",
74 },
75 },
76 ],
77 "pin-off": [
78 { tag: "path", attrs: { d: "M12 17v5" } },
79 { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } },
80 { tag: "path", attrs: { d: "m2 2 20 20" } },
81 {
82 tag: "path",
83 attrs: {
84 d: "M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11",
85 },
86 },
87 ],
88 "refresh-cw": [
89 { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } },
90 { tag: "path", attrs: { d: "M21 3v5h-5" } },
91 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
92 { tag: "path", attrs: { d: "M8 16H3v5" } },
93 ],
9420 "folder-tree": [
9521 {
9622 tag: "path",
......@@ -118,7 +44,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
11844
11945function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
12046 const paths = ICON_PATHS[name];
121 if (!paths || typeof document.createElementNS !== "function") {
47 if (!paths) {
12248 return null;
12349 }
12450 const svg = document.createElementNS(SVG_NS, "svg");
......@@ -131,7 +57,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
13157 svg.setAttribute("stroke-linejoin", "round");
13258 svg.setAttribute(
13359 "class",
134 tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground",
60 tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
13561 );
13662 for (const node of paths) {
13763 const child = document.createElementNS(SVG_NS, node.tag);
......@@ -274,16 +200,6 @@ export function showContextMenuFallback<T extends string>(
274200 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
275201
276202 for (const item of entries) {
277 if (item.separatorBefore === true && inner.childElementCount > 0) {
278 const separator = document.createElement("div");
279 separator.className = "my-1 h-px bg-border/70";
280 separator.style.cssText =
281 "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;";
282 separator.dataset.contextMenuSeparator = "true";
283 separator.setAttribute("role", "separator");
284 inner.appendChild(separator);
285 }
286
287203 if (item.header === true) {
288204 const header = document.createElement("div");
289205 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
......@@ -331,12 +247,10 @@ export function showContextMenuFallback<T extends string>(
331247 button.appendChild(label);
332248
333249 if (hasChildren) {
334 const chevron = createIconElement("chevron-right", "neutral");
335 if (chevron) {
336 chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80");
337 chevron.dataset.contextMenuChevron = "true";
338 button.appendChild(chevron);
339 }
250 const chevron = document.createElement("span");
251 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";
252 chevron.textContent = ">";
253 button.appendChild(chevron);
340254 }
341255
342256 if (!isDisabled) {
apps/web/src/index.css+5-78
......@@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
241241 opacity: 1;
242242 }
243243 }
244 @keyframes live-activity-focus {
245 0% {
246 transform: translateX(0);
247 }
248 100% {
249 transform: translateX(100%);
250 }
251 }
252 @keyframes live-activity-focus-counter {
253 0% {
254 transform: translateX(0);
255 }
256 100% {
257 transform: translateX(-100%);
258 }
259 }
260244 @keyframes status-ping {
261245 /* Burst first (immediate feedback for click ripples), then hold
262246 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
447431 }
448432}
449433
450@utility live-activity-focus {
451 --live-activity-focus-width: 4.5rem;
452
453 right: auto;
454 left: calc(-1 * var(--live-activity-focus-width));
455 width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width));
456 -webkit-mask-image: linear-gradient(
457 to right,
458 transparent 0,
459 rgb(0 0 0 / 12%) 0.675rem,
460 rgb(0 0 0 / 55%) 1.575rem,
461 black 2.25rem,
462 rgb(0 0 0 / 55%) 2.925rem,
463 rgb(0 0 0 / 12%) 3.825rem,
464 transparent var(--live-activity-focus-width),
465 transparent 100%
466 );
467 -webkit-mask-repeat: no-repeat;
468 mask-image: linear-gradient(
469 to right,
470 transparent 0,
471 rgb(0 0 0 / 12%) 0.675rem,
472 rgb(0 0 0 / 55%) 1.575rem,
473 black 2.25rem,
474 rgb(0 0 0 / 55%) 2.925rem,
475 rgb(0 0 0 / 12%) 3.825rem,
476 transparent var(--live-activity-focus-width),
477 transparent 100%
478 );
479 mask-repeat: no-repeat;
480 animation: live-activity-focus 2.2s linear infinite;
481 will-change: transform;
482
483 @media (prefers-reduced-motion: reduce) {
484 animation: none;
485 opacity: 0;
486 will-change: auto;
487 }
488}
489
490@utility live-activity-focus-counter {
491 width: 100%;
492 animation: live-activity-focus-counter 2.2s linear infinite;
493 will-change: transform;
494
495 @media (prefers-reduced-motion: reduce) {
496 animation: none;
497 will-change: auto;
498 }
499}
500
501@utility live-activity-focus-aligned {
502 width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width));
503 margin-left: var(--live-activity-focus-width);
504}
505
506434@layer base {
507435 :root {
508436 /* 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] {
14211349
14221350/* The panel layout toggles stay ghost: they render both inside the header and
14231351 in the titlebar strip, so filling them would make them change appearance as
1424 the panel opens. Their icons use the same themed foreground as the toolbar
1425 action text; hover and pressed keep the base ghost accent. The tooltip
1426 trigger's data-slot wins over the toggle's when it renders the toggle, so
1427 match both. */
1352 the panel opens. They only take the themed foreground; hover and pressed
1353 keep the base ghost accent. The tooltip trigger's data-slot wins over the
1354 toggle's when the trigger renders the toggle, so match both. */
14281355html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
14291356html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
14301357html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
14311358html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1432 --control-icon-color: var(--toolbar-control-foreground);
1433 color: var(--toolbar-control-foreground);
1359 --control-icon-color: var(--toolbar-foreground);
1360 color: var(--toolbar-foreground);
14341361}
14351362
14361363html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
apps/web/src/lib/openPullRequestLink.ts-11
......@@ -118,17 +118,6 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118118 return null;
119119}
120120
121/** The repository root behind a recognised change-request URL, without PR-specific state. */
122export function changeRequestRepositoryUrl(targetUrl: string): string | null {
123 const changeRequest = parseChangeRequestUrl(targetUrl);
124 if (changeRequest === null) return null;
125 const url = new URL(targetUrl);
126 url.pathname = `/${changeRequest.repository}`;
127 url.search = "";
128 url.hash = "";
129 return url.toString();
130}
131
132121function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
133122 const repository = match?.[1];
134123 const number = Number(match?.[2]);
apps/web/src/routes/-chatIndexTitlebar.test.ts+3-1
......@@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => {
1515
1616 const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd);
1717
18 expect(onboardingHeader).toContain('<WorkspacePageHeader className="border-b border-border">');
18 expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]");
19 expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]");
20 expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS");
1921 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);
2022 });
2123});
apps/web/src/routes/_chat.index.tsx+9-3
......@@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
88import { Button } from "../components/ui/button";
99import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
1010import { SidebarInset } from "../components/ui/sidebar";
11import { WorkspacePageHeader } from "../components/WorkspacePageContainer";
1211import { useNewThreadHandler } from "../hooks/useHandleNewThread";
1312import {
1413 useAllEnvironmentShellsBootstrapped,
......@@ -18,6 +17,8 @@ import {
1817import { useEnvironments } from "../state/environments";
1918import { APP_DISPLAY_NAME } from "~/branding";
2019import { hasCloudPublicConfig } from "~/cloud/publicConfig";
20import { cn } from "~/lib/utils";
21import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
2122
2223function ChatIndexRouteView() {
2324 const { authGateState } = Route.useRouteContext();
......@@ -142,13 +143,18 @@ function HostedStaticOnboardingState() {
142143 return (
143144 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
144145 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
145 <WorkspacePageHeader className="border-b border-border">
146 <header
147 className={cn(
148 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
149 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
150 )}
151 >
146152 <div className="flex items-center gap-2">
147153 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
148154 {APP_DISPLAY_NAME}
149155 </span>
150156 </div>
151 </WorkspacePageHeader>
157 </header>
152158
153159 <Empty className="flex-1">
154160 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">
apps/web/src/routes/_chat.pull-requests.tsx+37-66
......@@ -74,12 +74,6 @@ import {
7474 WorkspaceBreadcrumbItem,
7575 WorkspaceBreadcrumbSeparator,
7676} from "../components/WorkspaceBreadcrumb";
77import {
78 WorkspacePageContainer,
79 WorkspacePageHeader,
80 WorkspacePageHeaderEdgeControl,
81} from "../components/WorkspacePageContainer";
82import { isElectron } from "../env";
8377import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
8478import { Button } from "../components/ui/button";
8579import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
......@@ -105,6 +99,7 @@ import {
10599import { useAtomCommand } from "../state/use-atom-command";
106100import { cn } from "~/lib/utils";
107101import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
102import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
108103
109104export interface PullRequestsSearch {
110105 readonly involvement: PullRequestInvolvement;
......@@ -1186,17 +1181,6 @@ function PullRequestsRouteView() {
11861181 : null,
11871182 [search.number, search.repository, selectedProject],
11881183 );
1189 const linkedSelectionMatchesSurface =
1190 linkedSelection !== null &&
1191 selectedPullRequestSurface !== null &&
1192 linkedSelection.environmentId === selectedPullRequestSurface.environmentId &&
1193 linkedSelection.projectId === selectedPullRequestSurface.projectId &&
1194 linkedSelection.repository === selectedPullRequestSurface.repository &&
1195 linkedSelection.number === selectedPullRequestSurface.number;
1196 // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are
1197 // history, though, not a current selection: without this check they leave the toggle looking
1198 // available after the selected pull request has been cleared.
1199 const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface;
12001184 useEffect(() => {
12011185 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
12021186 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
......@@ -1310,10 +1294,9 @@ function PullRequestsRouteView() {
13101294 terminalAvailable={false}
13111295 terminalOpen={false}
13121296 terminalShortcutLabel={null}
1313 rightPanelAvailable={rightPanelAvailable}
1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}
13141298 rightPanelOpen={rightPanelState.isOpen}
13151299 rightPanelShortcutLabel={null}
1316 rightPanelUnavailableLabel="Select a pull request first"
13171300 liveAgentCount={0}
13181301 onToggleTerminal={() => undefined}
13191302 onToggleRightPanel={toggleRightPanel}
......@@ -1620,6 +1603,7 @@ function PullRequestsRouteView() {
16201603 reviewingQuery.refresh();
16211604 }}
16221605 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
16231607 />
16241608 </RightPanelTabs>
16251609 ) : null}
......@@ -1843,10 +1827,18 @@ function PullRequestsColumn({
18431827 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
18441828 // content surface that lets it show reads as a different background than every thread.
18451829 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
1846 {/* A closed right panel leaves this column full-width, so the shared header
1847 reserves native window controls. While the panel is open, the column ends
1848 at the panel and the absolute controls strip owns the top-right corner. */}
1849 <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}>
1830 <header
1831 className={cn(
1832 "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1.5 px-3 sm:px-5",
1833 // A closed right panel leaves this column full-width, so its header runs
1834 // underneath the native window controls on Windows; reserve the inset the
1835 // way Settings and the chat view do. While the panel is open the column
1836 // ends at the panel's left edge and the absolute controls strip (already
1837 // WCO-aware) owns the top-right corner.
1838 !rightPanelOpen && "wco:pr-[var(--workspace-native-controls-inset)]",
1839 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1840 )}
1841 >
18501842 {condensed ? (
18511843 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
18521844 {/* The page name remains the foreground anchor in both states; the live filters are
......@@ -1888,24 +1880,27 @@ function PullRequestsColumn({
18881880 )}
18891881 <div className="min-w-0 flex-1" />
18901882 {condensed ? (
1891 <div className="flex shrink-0 items-center gap-1.5">
1892 <ExpandableSearch
1893 searchInput={searchInput}
1894 searchValue={searchValue}
1895 open={searchOpen}
1896 onOpenChange={setSearchOpen}
1897 focusToken={searchFocusToken}
1898 onFocusWithin={(focused) => {
1899 topbarSearchFocusedRef.current = focused;
1900 }}
1901 />
1902 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
1903 </div>
1904 ) : null}
1905 {rightPanelControl ? (
1906 <WorkspacePageHeaderEdgeControl>{rightPanelControl}</WorkspacePageHeaderEdgeControl>
1883 <ExpandableSearch
1884 searchInput={searchInput}
1885 searchValue={searchValue}
1886 open={searchOpen}
1887 onOpenChange={setSearchOpen}
1888 focusToken={searchFocusToken}
1889 onFocusWithin={(focused) => {
1890 topbarSearchFocusedRef.current = focused;
1891 }}
1892 />
19071893 ) : null}
1908 </WorkspacePageHeader>
1894 <Button
1895 size="icon-sm"
1896 variant="ghost"
1897 aria-label="Refresh pull requests"
1898 onClick={onRefresh}
1899 >
1900 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1901 </Button>
1902 {rightPanelControl}
1903 </header>
19091904
19101905 <div
19111906 ref={scrollRef}
......@@ -1914,43 +1909,19 @@ function PullRequestsColumn({
19141909 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
19151910 settings page makes: at rest the controls sit fully below the mask, and only
19161911 content actually passing under the chrome fades. */}
1917 <WorkspacePageContainer className="gap-4">
1912 <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
19181913 <div className="flex flex-col gap-3">
19191914 <div ref={inFlowSearchRef} className="flex items-center gap-2">
19201915 {searchInput}
19211916 {filtersMenu}
1922 {!condensed ? (
1923 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1924 ) : null}
19251917 </div>
19261918 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
19271919 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
19281920 </div>
19291921
19301922 {listBody}
1931 </WorkspacePageContainer>
1923 </div>
19321924 </div>
19331925 </div>
19341926 );
19351927}
1936
1937function PullRequestRefreshControl({
1938 compact = false,
1939 refreshing,
1940 onRefresh,
1941}: {
1942 compact?: boolean;
1943 refreshing: boolean;
1944 onRefresh: () => void;
1945}) {
1946 return (
1947 <Button
1948 size={compact ? "icon-sm" : "icon"}
1949 variant={compact ? "ghost" : "outline"}
1950 aria-label="Refresh pull requests"
1951 onClick={onRefresh}
1952 >
1953 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1954 </Button>
1955 );
1956}
apps/web/src/routes/settings.tsx+36-10
......@@ -13,8 +13,9 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels";
1313import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
1414import { Button } from "../components/ui/button";
1515import { SidebarInset } from "../components/ui/sidebar";
16import { WorkspacePageHeader } from "../components/WorkspacePageContainer";
1716import { isElectron } from "../env";
17import { cn } from "~/lib/utils";
18import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1819
1920function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
2021 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
......@@ -71,16 +72,41 @@ function SettingsContentLayout() {
7172 return (
7273 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
7374 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
74 <WorkspacePageHeader electron={isElectron}>
75 <div className="flex w-full items-center gap-3">
76 <SettingsBreadcrumb pathname={location.pathname} />
77 {showRestoreDefaults ? (
78 <div className="ms-auto flex items-center gap-2">
79 <RestoreDefaultsButton onRestored={handleRestored} />
80 </div>
81 ) : null}
75 {!isElectron && (
76 <header
77 className={cn(
78 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
79 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
80 )}
81 >
82 <div className="flex w-full items-center gap-2">
83 <SettingsBreadcrumb pathname={location.pathname} />
84 {showRestoreDefaults ? (
85 <div className="ms-auto flex items-center gap-2">
86 <RestoreDefaultsButton onRestored={handleRestored} />
87 </div>
88 ) : null}
89 </div>
90 </header>
91 )}
92
93 {isElectron && (
94 <div
95 className={cn(
96 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
97 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
98 )}
99 >
100 <div className="flex w-full items-center gap-2">
101 <SettingsBreadcrumb pathname={location.pathname} />
102 {showRestoreDefaults ? (
103 <div className="ms-auto flex items-center gap-2">
104 <RestoreDefaultsButton onRestored={handleRestored} />
105 </div>
106 ) : null}
107 </div>
82108 </div>
83 </WorkspacePageHeader>
109 )}
84110
85111 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
86112 <Outlet />
apps/web/src/session-logic.test.ts+6-127
......@@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => {
722722});
723723
724724describe("deriveWorkLogEntries", () => {
725 it("shows a command from its start event while it is still running", () => {
725 it("omits tool started entries and keeps completed entries", () => {
726726 const activities: OrchestrationThreadActivity[] = [
727 makeActivity({
728 id: "tool-start",
729 createdAt: "2026-02-23T00:00:02.000Z",
730 summary: "Command run started",
731 kind: "tool.started",
732 payload: {
733 itemType: "command_execution",
734 toolCallId: "call-1",
735 status: "inProgress",
736 title: "Command run",
737 detail: "Bash: vp test run",
738 data: {
739 toolName: "Bash",
740 input: { command: "vp test run" },
741 },
742 },
743 }),
744 ];
745
746 const [entry] = deriveWorkLogEntries(activities);
747 expect(entry).toMatchObject({
748 id: "tool-start",
749 command: "vp test run",
750 toolCallId: "call-1",
751 toolLifecycleStatus: "inProgress",
752 sourceActivityKind: "tool.started",
753 });
754 });
755
756 it("retains the start command when the matching completion omits it", () => {
757 const activities: OrchestrationThreadActivity[] = [
758 makeActivity({
759 id: "tool-start",
760 createdAt: "2026-02-23T00:00:02.000Z",
761 summary: "Command run started",
762 kind: "tool.started",
763 payload: {
764 itemType: "command_execution",
765 toolCallId: "call-1",
766 status: "inProgress",
767 title: "Command run",
768 data: { input: { command: "vp test run" } },
769 },
770 }),
771 makeActivity({
772 id: "other-tool-start",
773 createdAt: "2026-02-23T00:00:02.500Z",
774 summary: "Other command started",
775 kind: "tool.started",
776 payload: {
777 itemType: "command_execution",
778 toolCallId: "call-2",
779 status: "inProgress",
780 title: "Other command",
781 data: { input: { command: "vp lint" } },
782 },
783 }),
784727 makeActivity({
785728 id: "tool-complete",
786729 createdAt: "2026-02-23T00:00:03.000Z",
787 summary: "Command run",
788 kind: "tool.completed",
789 payload: {
790 itemType: "command_execution",
791 toolCallId: "call-1",
792 status: "completed",
793 title: "Command run",
794 },
795 }),
796 makeActivity({
797 id: "other-tool-complete",
798 createdAt: "2026-02-23T00:00:04.000Z",
799 summary: "Other command",
730 summary: "Tool call complete",
800731 kind: "tool.completed",
801 payload: {
802 itemType: "command_execution",
803 toolCallId: "call-2",
804 status: "completed",
805 title: "Other command",
806 },
807732 }),
808 ];
809
810 const entries = deriveWorkLogEntries(activities);
811 expect(entries).toHaveLength(2);
812 expect(entries[0]).toMatchObject({
813 id: "tool-complete",
814 command: "vp test run",
815 toolCallId: "call-1",
816 toolLifecycleStatus: "completed",
817 sourceActivityKind: "tool.completed",
818 });
819 expect(entries[1]).toMatchObject({
820 id: "other-tool-complete",
821 command: "vp lint",
822 toolCallId: "call-2",
823 toolLifecycleStatus: "completed",
824 sourceActivityKind: "tool.completed",
825 });
826 });
827
828 it("does not merge non-adjacent tool starts without stable call ids", () => {
829 const activities: OrchestrationThreadActivity[] = [
830733 makeActivity({
831 id: "unkeyed-start-1",
832 createdAt: "2026-02-23T00:00:01.000Z",
833 summary: "Search started",
834 kind: "tool.started",
835 payload: { itemType: "search", title: "Search", status: "inProgress" },
836 }),
837 makeActivity({
838 id: "keyed-start",
734 id: "tool-start",
839735 createdAt: "2026-02-23T00:00:02.000Z",
840 summary: "Command started",
841 kind: "tool.started",
842 payload: {
843 itemType: "command_execution",
844 toolCallId: "call-between",
845 title: "Command",
846 status: "inProgress",
847 },
848 }),
849 makeActivity({
850 id: "unkeyed-start-2",
851 createdAt: "2026-02-23T00:00:03.000Z",
852 summary: "Search started",
736 summary: "Tool call",
853737 kind: "tool.started",
854 payload: { itemType: "search", title: "Search", status: "inProgress" },
855738 }),
856739 ];
857740
858 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([
859 "unkeyed-start-1",
860 "keyed-start",
861 "unkeyed-start-2",
862 ]);
741 const entries = deriveWorkLogEntries(activities);
742 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
863743 });
864744
865745 it("omits task.started but shows task.progress and task.completed", () => {
......@@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => {
13591239 expect(entries).toHaveLength(1);
13601240 expect(entries[0]).toMatchObject({
13611241 id: "grep-complete",
1362 toolCallId: "tool-grep-1",
13631242 toolTitle: "grep",
13641243 detail: "19 files",
13651244 itemType: "web_search",
apps/web/src/session-logic.ts+7-98
......@@ -65,8 +65,6 @@ export interface WorkLogEntry {
6565 id: string;
6666 createdAt: string;
6767 turnId?: TurnId | null;
68 /** Stable provider identity across in-progress and completed lifecycle updates. */
69 toolCallId?: string;
7068 label: string;
7169 detail?: string;
7270 command?: string;
......@@ -750,6 +748,7 @@ export function deriveWorkLogEntries(
750748 const ordered = [...activities].toSorted(compareActivitiesByOrder);
751749 const entries: DerivedWorkLogEntry[] = [];
752750 for (const activity of ordered) {
751 if (activity.kind === "tool.started") continue;
753752 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
754753 // which is the batch key (completions of background subagents arrive
755754 // under later synthetic turns and must not start new batches). They
......@@ -758,13 +757,8 @@ export function deriveWorkLogEntries(
758757 if (activity.kind === "task.updated") continue;
759758 if (activity.kind === "tool.progress") continue;
760759 if (activity.kind === "context-window.updated") continue;
761 // Plan updates have a dedicated task row. Keeping the raw activity here
762 // duplicates it as a legacy "Work Log / Plan updated" row when history
763 // is expanded.
764 if (activity.kind === "turn.plan.updated") continue;
765760 if (activity.summary === "Checkpoint captured") continue;
766761 if (isPlanBoundaryToolActivity(activity)) continue;
767 if (isCodexTerminalInteractionActivity(activity)) continue;
768762 if (isAgentInternalActivity(activity)) continue;
769763 entries.push(toDerivedWorkLogEntry(activity));
770764 }
......@@ -775,11 +769,7 @@ export function deriveWorkLogEntries(
775769}
776770
777771function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
778 if (
779 activity.kind !== "tool.started" &&
780 activity.kind !== "tool.updated" &&
781 activity.kind !== "tool.completed"
782 ) {
772 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
783773 return false;
784774 }
785775
......@@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
790780 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
791781}
792782
793/**
794 * Codex terminal interactions report bytes written to an already-running PTY.
795 * Some thread histories contain them as generic tool.updated rows, so filter
796 * their exact wire shape from the presentation model. This repairs existing
797 * history without deleting or rewriting persisted activities.
798 */
799function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean {
800 if (activity.kind !== "tool.updated") {
801 return false;
802 }
803 const payload = asRecord(activity.payload);
804 const data = asRecord(payload?.data);
805 return (
806 payload?.itemType === "command_execution" &&
807 typeof data?.itemId === "string" &&
808 typeof data.processId === "string" &&
809 typeof data.stdin === "string" &&
810 typeof data.threadId === "string" &&
811 typeof data.turnId === "string"
812 );
813}
814
815783function extractWorkLogToolLifecycleStatus(
816784 payload: Record<string, unknown> | null,
817785): WorkLogToolLifecycleStatus | undefined {
......@@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
910878 entry.toolCallId = toolCallId;
911879 }
912880 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
913 if (!toolLifecycleStatus && activity.kind === "tool.started") {
914 toolLifecycleStatus = "inProgress";
915 }
916881 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
917882 toolLifecycleStatus = "completed";
918883 }
......@@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
968933 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
969934}
970935
971function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
972 if (
973 entry.activityKind !== "tool.started" &&
974 entry.activityKind !== "tool.updated" &&
975 entry.activityKind !== "tool.completed"
976 ) {
977 return undefined;
978 }
979 return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined;
980}
981
982936function collapseDerivedWorkLogEntries(
983937 entries: ReadonlyArray<DerivedWorkLogEntry>,
984938): DerivedWorkLogEntry[] {
......@@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries(
995949 // own turn splintered one batch into a stream of "Kicked off N subagents"
996950 // rows (live-test finding, thread 7ac7ef05).
997951 const groupKeyByTaskId = new Map<string, string>();
998 const toolLifecycleRowIndex = new Map<string, number>();
999952 for (const entry of entries) {
1000953 const isTaskRow =
1001954 entry.taskId !== undefined &&
......@@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries(
1040993 });
1041994 continue;
1042995 }
1043 const lifecycleKey = toolLifecycleCollapseMapKey(entry);
1044 if (lifecycleKey !== undefined) {
1045 const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
1046 if (matchingLifecycleIndex !== undefined) {
1047 const matchingEntry = collapsed[matchingLifecycleIndex];
1048 if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
1049 toolLifecycleRowIndex.delete(lifecycleKey);
1050 const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
1051 collapsed[matchingLifecycleIndex] = merged;
1052 if (merged.activityKind !== "tool.completed") {
1053 toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
1054 }
1055 continue;
1056 }
1057 toolLifecycleRowIndex.delete(lifecycleKey);
1058 }
1059 }
1060996 const previous = collapsed.at(-1);
1061997 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
1062 const previousIndex = collapsed.length - 1;
1063 const previousKey = toolLifecycleCollapseMapKey(previous);
1064 if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
1065 const merged = mergeDerivedWorkLogEntries(previous, entry);
1066 collapsed[previousIndex] = merged;
1067 const mergedKey = toolLifecycleCollapseMapKey(merged);
1068 if (mergedKey !== undefined && merged.activityKind !== "tool.completed") {
1069 toolLifecycleRowIndex.set(mergedKey, previousIndex);
1070 }
998 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
1071999 continue;
10721000 }
10731001 collapsed.push(entry);
1074 if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") {
1075 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1076 }
10771002 }
10781003 return collapsed;
10791004}
......@@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries(
10821007 previous: DerivedWorkLogEntry,
10831008 next: DerivedWorkLogEntry,
10841009): boolean {
1085 if (
1086 previous.activityKind !== "tool.started" &&
1087 previous.activityKind !== "tool.updated" &&
1088 previous.activityKind !== "tool.completed"
1089 ) {
1010 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
10901011 return false;
10911012 }
1092 if (
1093 next.activityKind !== "tool.started" &&
1094 next.activityKind !== "tool.updated" &&
1095 next.activityKind !== "tool.completed"
1096 ) {
1013 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
10971014 return false;
10981015 }
10991016 if (previous.activityKind === "tool.completed") {
......@@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
11631080 ) {
11641081 return `task${entry.taskId}`;
11651082 }
1166 if (
1167 entry.activityKind !== "tool.started" &&
1168 entry.activityKind !== "tool.updated" &&
1169 entry.activityKind !== "tool.completed"
1170 ) {
1083 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
11711084 return undefined;
11721085 }
11731086 if (entry.toolCallId) {
......@@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
13701283 const item = asRecord(data?.item);
13711284 const itemResult = asRecord(item?.result);
13721285 const itemInput = asRecord(item?.input);
1373 const dataInput = asRecord(data?.input);
1374 const stateInput = asRecord(asRecord(data?.state)?.input);
13751286 const itemType = asTrimmedString(payload?.itemType);
13761287 const detail = asTrimmedString(payload?.detail);
13771288 const candidates: unknown[] = [
......@@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
13791290 itemInput?.command,
13801291 itemResult?.command,
13811292 data?.command,
1382 dataInput?.command,
1383 stateInput?.command,
13841293 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
13851294 ];
13861295
......@@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
14071316
14081317function extractToolCallId(payload: Record<string, unknown> | null): string | null {
14091318 const data = asRecord(payload?.data);
1410 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
1319 return asTrimmedString(data?.toolCallId);
14111320}
14121321
14131322function normalizeInlinePreview(value: string): string {
apps/web/src/terminalUiStateStore.test.ts-8
......@@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => {
1818 useTerminalUiStateStore.persist.clearStorage();
1919 useTerminalUiStateStore.setState({
2020 terminalUiStateByThreadKey: {},
21 terminalCustomLabelsByThreadKey: {},
2221 suppressedTerminalIdsByThreadKey: {},
2322 });
2423 });
......@@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => {
249248 it("reconciles terminal ids from an external ordered list", () => {
250249 const store = useTerminalUiStateStore.getState();
251250 store.setTerminalOpen(THREAD_REF, true);
252 store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server");
253 store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task");
254251 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
255252
256253 const terminalUiState = selectThreadTerminalUiState(
......@@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => {
263260 { id: "group-term-a", terminalIds: ["term-a"] },
264261 { id: "group-term-b", terminalIds: ["term-b"] },
265262 ]);
266 expect(
267 useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[
268 scopedThreadKey(THREAD_REF)
269 ],
270 ).toEqual({ "term-a": "API server" });
271263 });
272264
273265 it("does not import a closed panel terminal from stale metadata", () => {
apps/web/src/terminalUiStateStore.ts+16-154
......@@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
3232interface PersistedTerminalUiStateStoreState {
3333 terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>;
3434 terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;
35 terminalCustomLabelsByThreadKey?: Record<string, Record<string, string>>;
3635}
3736
38const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({});
39
4037export function migratePersistedTerminalUiStateStoreState(
4138 persistedState: unknown,
4239 _version: number,
......@@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState(
5350 parseScopedThreadKey(threadKey),
5451 ),
5552 );
56 const terminalCustomLabelsByThreadKey = Object.fromEntries(
57 Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap(
58 ([threadKey, labels]) => {
59 if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return [];
60 const normalizedLabels = Object.fromEntries(
61 Object.entries(labels).flatMap(([terminalId, label]) => {
62 const normalizedTerminalId = terminalId.trim();
63 const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : "";
64 return normalizedTerminalId && normalizedLabel
65 ? [[normalizedTerminalId, normalizedLabel] as const]
66 : [];
67 }),
68 );
69 return Object.keys(normalizedLabels).length > 0
70 ? [[threadKey, normalizedLabels] as const]
71 : [];
72 },
73 ),
74 );
7553
76 return {
77 terminalUiStateByThreadKey,
78 ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0
79 ? { terminalCustomLabelsByThreadKey }
80 : {}),
81 };
54 return { terminalUiStateByThreadKey };
8255}
8356
8457function createTerminalUiStateStorage() {
......@@ -516,18 +489,6 @@ export function selectThreadTerminalUiState(
516489 );
517490}
518491
519export function selectThreadTerminalCustomLabels(
520 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>,
521 threadRef: ScopedThreadRef | null | undefined,
522): Readonly<Record<string, string>> {
523 if (!threadRef || threadRef.threadId.length === 0) {
524 return EMPTY_TERMINAL_CUSTOM_LABELS;
525 }
526 return (
527 terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS
528 );
529}
530
531492function updateTerminalUiStateByThreadKey(
532493 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
533494 threadRef: ScopedThreadRef,
......@@ -601,7 +562,6 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st
601562
602563interface TerminalUiStateStoreState {
603564 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
604 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>;
605565 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */
606566 suppressedTerminalIdsByThreadKey: Record<string, string[]>;
607567 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
......@@ -615,11 +575,6 @@ interface TerminalUiStateStoreState {
615575 options?: { open?: boolean; active?: boolean },
616576 ) => void;
617577 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
618 setTerminalCustomLabel: (
619 threadRef: ScopedThreadRef,
620 terminalId: string,
621 label: string | null,
622 ) => void;
623578 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
624579 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
625580 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
......@@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
636591 state: ThreadTerminalUiState,
637592 suppressedTerminalIds: readonly string[],
638593 ) => ThreadTerminalUiState,
639 suppression?: {
640 terminalId: string;
641 suppressed: boolean;
642 clearCustomLabel?: boolean;
643 },
644 pruneCustomLabels = false,
594 suppression?: { terminalId: string; suppressed: boolean },
645595 ) => {
646596 set((state) => {
647597 const threadKey = terminalThreadKey(threadRef);
......@@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
659609 suppression.suppressed,
660610 )
661611 : state.suppressedTerminalIdsByThreadKey;
662 const terminalIdToClear = suppression?.clearCustomLabel
663 ? suppression.terminalId.trim()
664 : "";
665 const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {};
666 let nextTerminalCustomLabelsByThreadKey =
667 terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined
668 ? Object.keys(currentLabels).length === 1
669 ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey)
670 : {
671 ...state.terminalCustomLabelsByThreadKey,
672 [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear),
673 }
674 : state.terminalCustomLabelsByThreadKey;
675 if (pruneCustomLabels) {
676 const survivingIds = new Set(
677 selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds,
678 );
679 const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {};
680 const survivingLabels = Object.fromEntries(
681 Object.entries(labelsForThread).filter(([terminalId]) =>
682 survivingIds.has(terminalId),
683 ),
684 );
685 if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) {
686 nextTerminalCustomLabelsByThreadKey =
687 Object.keys(survivingLabels).length > 0
688 ? {
689 ...nextTerminalCustomLabelsByThreadKey,
690 [threadKey]: survivingLabels,
691 }
692 : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey);
693 }
694 }
695612 if (
696613 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
697 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey &&
698 nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey
614 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey
699615 ) {
700616 return state;
701617 }
702618 return {
703619 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
704620 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
705 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
706621 };
707622 });
708623 };
709624
710625 return {
711626 terminalUiStateByThreadKey: {},
712 terminalCustomLabelsByThreadKey: {},
713627 suppressedTerminalIdsByThreadKey: {},
714628 setTerminalOpen: (threadRef, open) => {
715629 const terminalState = selectThreadTerminalUiState(
......@@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
768682 ),
769683 setActiveTerminal: (threadRef, terminalId) =>
770684 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
771 setTerminalCustomLabel: (threadRef, terminalId, label) =>
772 set((state) => {
773 const normalizedTerminalId = terminalId.trim();
774 if (normalizedTerminalId.length === 0) return state;
775 const threadKey = terminalThreadKey(threadRef);
776 const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {};
777 const normalizedLabel = label?.trim().slice(0, 80) ?? "";
778 if (normalizedLabel.length > 0) {
779 if (currentLabels[normalizedTerminalId] === normalizedLabel) return state;
780 return {
781 terminalCustomLabelsByThreadKey: {
782 ...state.terminalCustomLabelsByThreadKey,
783 [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel },
784 },
785 };
786 }
787 if (currentLabels[normalizedTerminalId] === undefined) return state;
788 const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels;
789 return {
790 terminalCustomLabelsByThreadKey:
791 Object.keys(remainingLabels).length > 0
792 ? {
793 ...state.terminalCustomLabelsByThreadKey,
794 [threadKey]: remainingLabels,
795 }
796 : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey),
797 };
798 }),
799685 closeTerminal: (threadRef, terminalId) =>
800686 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {
801687 terminalId,
802688 suppressed: true,
803 clearCustomLabel: true,
804689 }),
805690 reconcileTerminalIds: (threadRef, nextIds) =>
806 updateTerminal(
807 threadRef,
808 (state, suppressedTerminalIds) => {
809 if (suppressedTerminalIds.length === 0) {
810 return reconcileThreadTerminalSessionIds(state, nextIds);
811 }
812 const suppressedIds = new Set(suppressedTerminalIds);
813 return reconcileThreadTerminalSessionIds(
814 state,
815 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),
816 );
817 },
818 undefined,
819 true,
820 ),
691 updateTerminal(threadRef, (state, suppressedTerminalIds) => {
692 if (suppressedTerminalIds.length === 0) {
693 return reconcileThreadTerminalSessionIds(state, nextIds);
694 }
695 const suppressedIds = new Set(suppressedTerminalIds);
696 return reconcileThreadTerminalSessionIds(
697 state,
698 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),
699 );
700 }),
821701 clearTerminalUiState: (threadRef) =>
822702 set((state) => {
823703 const threadKey = terminalThreadKey(threadRef);
......@@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
828708 );
829709 const hadSuppressedTerminalIds =
830710 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
831 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
832711 if (
833712 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
834 !hadSuppressedTerminalIds &&
835 !hadCustomLabels
713 !hadSuppressedTerminalIds
836714 ) {
837715 return state;
838716 }
839717 return {
840718 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
841 terminalCustomLabelsByThreadKey: removeRecordEntry(
842 state.terminalCustomLabelsByThreadKey,
843 threadKey,
844 ),
845719 suppressedTerminalIdsByThreadKey: removeRecordEntry(
846720 state.suppressedTerminalIdsByThreadKey,
847721 threadKey,
......@@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
854728 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
855729 const hadSuppressedTerminalIds =
856730 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
857 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
858 if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) {
731 if (!hadTerminalUiState && !hadSuppressedTerminalIds) {
859732 return state;
860733 }
861734 return {
......@@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
863736 state.terminalUiStateByThreadKey,
864737 threadKey,
865738 ),
866 terminalCustomLabelsByThreadKey: removeRecordEntry(
867 state.terminalCustomLabelsByThreadKey,
868 threadKey,
869 ),
870739 suppressedTerminalIdsByThreadKey: removeRecordEntry(
871740 state.suppressedTerminalIdsByThreadKey,
872741 threadKey,
......@@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
878747 const orphanedIds = new Set(
879748 [
880749 ...Object.keys(state.terminalUiStateByThreadKey),
881 ...Object.keys(state.terminalCustomLabelsByThreadKey),
882750 ...Object.keys(state.suppressedTerminalIdsByThreadKey),
883751 ].filter((key) => !activeThreadKeys.has(key)),
884752 );
......@@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
889757 const nextSuppressedTerminalIdsByThreadKey = {
890758 ...state.suppressedTerminalIdsByThreadKey,
891759 };
892 const nextTerminalCustomLabelsByThreadKey = {
893 ...state.terminalCustomLabelsByThreadKey,
894 };
895760 for (const id of orphanedIds) {
896761 delete nextTerminalUiStateByThreadKey[id];
897 delete nextTerminalCustomLabelsByThreadKey[id];
898762 delete nextSuppressedTerminalIdsByThreadKey[id];
899763 }
900764 return {
901765 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
902 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
903766 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
904767 };
905768 }),
......@@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
907770 },
908771 {
909772 name: TERMINAL_UI_STATE_STORAGE_KEY,
910 version: 5,
773 version: 4,
911774 storage: createJSONStorage(createTerminalUiStateStorage),
912775 migrate: migratePersistedTerminalUiStateStoreState,
913776 partialize: (state) => ({
914777 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
915 terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey,
916778 }),
917779 },
918780 ),
packages/contracts/src/ipc.ts-4
......@@ -111,8 +111,6 @@ export interface ContextMenuItem<T extends string = string> {
111111 header?: boolean;
112112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */
113113 icon?: string;
114 /** Inserts a visual section divider immediately before this item. */
115 separatorBefore?: boolean;
116114 children?: readonly ContextMenuItem<T>[];
117115}
118116
......@@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType {
123121 readonly disabled?: boolean;
124122 readonly header?: boolean;
125123 readonly icon?: string;
126 readonly separatorBefore?: boolean;
127124 readonly children?: readonly ContextMenuItemSchemaType[];
128125}
129126
......@@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
134131 disabled: Schema.optionalKey(Schema.Boolean),
135132 header: Schema.optionalKey(Schema.Boolean),
136133 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
138134 children: Schema.optionalKey(
139135 Schema.Array(
140136 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/shared/src/usageMerge.test.ts-1
......@@ -248,7 +248,6 @@ describe("mergeUsage", () => {
248248 );
249249
250250 expect(merged.sessions).toBe(1);
251 expect(merged.providers[0]?.sessions).toBe(1);
252251 });
253252
254253 it("returns empty totals with no environments", () => {
packages/shared/src/usageMerge.ts+11-29
......@@ -25,7 +25,6 @@ export interface ProviderTotals {
2525 readonly costUsd: number;
2626 readonly totalTokens: number;
2727 readonly records: number;
28 readonly sessions: number;
2928 readonly costShare: number;
3029 readonly tokenShare: number;
3130}
......@@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
136135function ownedContribution(
137136 environment: EnvironmentUsage,
138137 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
139): {
140 readonly buckets: readonly UsageBucket[];
141 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
142} {
138): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
143139 const ownedProviders = new Set<UsageProviderKind>();
144 const sessionsByProvider = new Map<UsageProviderKind, number>();
140 let sessions = 0;
145141 for (const source of environment.summary.sources) {
146142 if (source.status === "missing") continue;
147143 const key = fingerprintKey(source.fingerprint);
148144 if (ownerByFingerprint.get(key) === environment.environmentId) {
149 const provider = source.fingerprint.provider;
150 ownedProviders.add(provider);
145 ownedProviders.add(source.fingerprint.provider);
151146 // Distinct within a directory. Summing per-bucket session counts instead
152147 // would count a session once per day and model it spans.
153 sessionsByProvider.set(
154 provider,
155 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
156 );
148 sessions += source.distinctSessions;
157149 }
158150 }
159151 return {
160152 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
161 sessionsByProvider,
153 sessions,
162154 };
163155}
164156
......@@ -236,7 +228,7 @@ export function mergeUsage(
236228
237229 const providerAccumulator = new Map<
238230 UsageProviderKind,
239 { costUsd: number; totalTokens: number; records: number; sessions: number }
231 { costUsd: number; totalTokens: number; records: number }
240232 >();
241233 const modelAccumulator = new Map<
242234 string,
......@@ -263,20 +255,12 @@ export function mergeUsage(
263255 const contributingEnvironments: EnvironmentId[] = [];
264256
265257 for (const environment of current) {
266 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
258 const { buckets, sessions: environmentSessions } = ownedContribution(
259 environment,
260 ownerByFingerprint,
261 );
267262 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
268
269 for (const [providerKind, providerSessions] of sessionsByProvider) {
270 sessions += providerSessions;
271 const provider = providerAccumulator.get(providerKind) ?? {
272 costUsd: 0,
273 totalTokens: 0,
274 records: 0,
275 sessions: 0,
276 };
277 provider.sessions += providerSessions;
278 providerAccumulator.set(providerKind, provider);
279 }
263 sessions += environmentSessions;
280264
281265 for (const bucket of buckets) {
282266 const tokens = bucketTokens(bucket);
......@@ -296,7 +280,6 @@ export function mergeUsage(
296280 costUsd: 0,
297281 totalTokens: 0,
298282 records: 0,
299 sessions: 0,
300283 };
301284 provider.costUsd += bucket.costUsd;
302285 provider.totalTokens += tokens;
......@@ -358,7 +341,6 @@ export function mergeUsage(
358341 costUsd: totals.costUsd,
359342 totalTokens: totals.totalTokens,
360343 records: totals.records,
361 sessions: totals.sessions,
362344 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
363345 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
364346 }))