authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-14 21:54:04-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 21:54:04-04:00
logd7abd7f3bb6f392ecb4ca21a1eeaec5bbc2d9393
tree5e10f71f70fc228b1ae14edfe1daa42c3169157a
parent57a299a7852b430613dfdd97ba76249ee9f374d5
signature Signed by PGP key B5690EEEBB952194

feat(web): refresh workspace layouts and tool activity


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

apps/desktop/src/electron/ElectronMenu.test.ts+10-1
......@@ -98,7 +98,10 @@ describe("ElectronMenu", () => {
9898 const electronMenu = yield* ElectronMenu.ElectronMenu;
9999 const selectedItemId = yield* electronMenu.showContextMenu({
100100 window: makeWindow(2),
101 items: [{ id: "copy", label: "Copy" }],
101 items: [
102 { id: "copy", label: "Copy" },
103 { id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104 ],
102105 position: Option.some({ x: 10.8, y: 20.2 }),
103106 });
104107
......@@ -110,6 +113,12 @@ describe("ElectronMenu", () => {
110113 enabled: true,
111114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
112115 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
113122 }).pipe(Effect.provide(TestLayer)),
114123 );
115124
apps/desktop/src/electron/ElectronMenu.ts+9-1
......@@ -78,6 +78,7 @@ 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 } : {}),
8182 };
8283
8384 if (sourceItem.children) {
......@@ -141,10 +142,17 @@ export const make = Effect.gen(function* () {
141142 ): Electron.MenuItemConstructorOptions[] => {
142143 const template: Electron.MenuItemConstructorOptions[] = [];
143144 let hasInsertedDestructiveSeparator = false;
145 const appendSeparator = () => {
146 if (template.length === 0 || template.at(-1)?.type === "separator") return;
147 template.push({ type: "separator" });
148 };
144149
145150 for (const item of entries) {
151 if (item.separatorBefore) {
152 appendSeparator();
153 }
146154 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
147 template.push({ type: "separator" });
155 appendSeparator();
148156 hasInsertedDestructiveSeparator = true;
149157 }
150158
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+40-1
......@@ -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 agent-field survival", () => {
23describe("projectActivityPayload", () => {
2424 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
2525 const projected = projectActivityPayload(
2626 activity({
......@@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => {
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
4786 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
4887 const projected = projectActivityPayload(
4988 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+27-7
......@@ -104,6 +104,24 @@ 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
107125function summarizeToolTextOutput(value: string): string | null {
108126 const lines: string[] = [];
109127 for (const rawLine of value.split(/\r?\n/u)) {
......@@ -287,8 +305,9 @@ export function projectActivityPayload(
287305 if (item) {
288306 projectedData.item = item;
289307 }
290 if ("command" in data) {
291 projectedData.command = data.command;
308 const command = projectCommandValue(data);
309 if (command !== undefined) {
310 projectedData.command = command;
292311 }
293312
294313 const changedFiles: string[] = [];
......@@ -368,10 +387,10 @@ function dropStaleContextWindowActivities(
368387/**
369388 * Identity both clients use to fold a tool lifecycle row into the call it
370389 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
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.
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.
375394 */
376395function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
377396 const payload = asRecord(activity.payload);
......@@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |
379398 return null;
380399 }
381400
382 const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);
401 const toolCallId =
402 asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
383403 if (toolCallId) {
384404 return `id:${toolCallId}`;
385405 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+22-8
......@@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => {
28112811 createdAt: now,
28122812 threadId: asThreadId("thread-1"),
28132813 turnId: asTurnId("turn-9"),
2814 itemId: asItemId("tool-call-9"),
28142815 payload: {
28152816 itemType: "command_execution",
2816 status: "in_progress",
2817 title: "Read file",
2818 detail: "/tmp/file.ts",
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 },
28192824 },
28202825 });
28212826
......@@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => {
28302835 );
28312836
28322837 expect(thread.session?.status).toBe("ready");
2833 expect(
2834 thread.activities.some(
2835 (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2836 ),
2837 ).toBe(true);
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 });
28382852 });
28392853
28402854 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6
......@@ -794,6 +794,7 @@ export function runtimeEventToActivities(
794794 summary: event.payload.title ?? "Tool updated",
795795 payload: {
796796 itemType: event.payload.itemType,
797 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
797798 ...(event.payload.status ? { status: event.payload.status } : {}),
798799 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
799800 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
......@@ -821,6 +822,8 @@ export function runtimeEventToActivities(
821822 summary: event.payload.title ?? "Tool",
822823 payload: {
823824 itemType: event.payload.itemType,
825 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
826 ...(event.payload.status ? { status: event.payload.status } : {}),
824827 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
825828 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
826829 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
......@@ -847,7 +850,10 @@ export function runtimeEventToActivities(
847850 summary: `${event.payload.title ?? "Tool"} started`,
848851 payload: {
849852 itemType: event.payload.itemType,
853 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
854 ...(event.payload.status ? { status: event.payload.status } : {}),
850855 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
856 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
851857 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
852858 ...(event.payload.parentToolUseId
853859 ? { parentToolUseId: event.payload.parentToolUseId }
apps/web/src/components/ChatView.tsx+32-26
......@@ -167,7 +167,6 @@ import {
167167 WifiOffIcon,
168168} from "lucide-react";
169169import { cn, randomHex } from "~/lib/utils";
170import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
171170import { stackedThreadToast, toastManager } from "./ui/toast";
172171import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
173172import { type NewProjectScriptInput } from "./ProjectScriptsControl";
......@@ -220,7 +219,11 @@ import {
220219import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
221220import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
222221import { environmentCatalog } from "../connection/catalog";
223import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
222import {
223 selectThreadTerminalCustomLabels,
224 selectThreadTerminalUiState,
225 useTerminalUiStateStore,
226} from "../terminalUiStateStore";
224227import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
225228import { projectEnvironment } from "../state/projects";
226229import { useEnvironmentQuery } from "../state/query";
......@@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader";
256259import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
257260import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
258261import { NoActiveThreadState } from "./NoActiveThreadState";
262import { WorkspacePageHeader } from "./WorkspacePageContainer";
259263import {
260264 resolveEffectiveEnvMode,
261265 resolveLocalCheckoutBranchMismatch,
......@@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps {
653657 newShortcutLabel: string | undefined;
654658 closeShortcutLabel: string | undefined;
655659 keybindings: ResolvedKeybindingsConfig;
660 onHide: () => void;
656661 onAddTerminalContext: (selection: TerminalContextSelection) => void;
657662}
658663
......@@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
667672 newShortcutLabel,
668673 closeShortcutLabel,
669674 keybindings,
675 onHide,
670676 onAddTerminalContext,
671677}: PersistentThreadTerminalDrawerProps) {
672678 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
......@@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
990996 onSplitTerminal={splitTerminal}
991997 onSplitTerminalVertical={splitTerminalVertical}
992998 onNewTerminal={createNewTerminal}
999 onHide={onHide}
9931000 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
9941001 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
9951002 newShortcutLabel={visible ? newShortcutLabel : undefined}
......@@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) {
15401547 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
15411548 const activeThreadId = activeThread?.id ?? null;
15421549 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 );
15431560 const runningTerminalIds = useThreadRunningTerminalIds({
15441561 environmentId: activeThread?.environmentId ?? null,
15451562 threadId: activeThreadId,
......@@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) {
15691586 for (const session of activeThreadKnownSessions) {
15701587 labels.set(
15711588 session.target.terminalId,
1572 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1589 activeTerminalCustomLabels[session.target.terminalId] ??
1590 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
15731591 );
15741592 }
1593 for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) {
1594 if (!labels.has(terminalId)) labels.set(terminalId, label);
1595 }
15751596 return labels;
1576 }, [activeThreadKnownSessions]);
1577 const activeThreadRef = useMemo(
1578 () =>
1579 activeThreadEnvironmentId && activeThreadId
1580 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1581 : null,
1582 [activeThreadEnvironmentId, activeThreadId],
1583 );
1597 }, [activeTerminalCustomLabels, activeThreadKnownSessions]);
15841598 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
15851599 const [timelineAnchor, setTimelineAnchor] = useState<{
15861600 readonly threadKey: string | null;
......@@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) {
28082822 },
28092823 [activeThreadRef, storeSetTerminalOpen],
28102824 );
2825 const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]);
28112826 const toggleTerminalVisibility = useCallback(() => {
28122827 if (!activeThreadRef) return;
28132828 const nextOpen = !terminalUiState.terminalOpen;
......@@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) {
61146129 ? "thread"
61156130 : "page"
61166131 }
6117 chromeVariant="collapse"
61186132 composerDraftTarget={composerDraftTarget}
61196133 onStateChange={handlePullRequestTabStatusChange}
61206134 />
......@@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) {
61606174 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
61616175 >
61626176 {/* Top bar */}
6163 <header
6177 <WorkspacePageHeader
61646178 data-chat-header
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 )}
6179 electron={isElectron}
6180 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
6181 className="relative bg-background"
61776182 >
61786183 {!rightPanelOpen ? panelLayoutControls : null}
61796184 <ChatHeader
......@@ -6204,7 +6209,7 @@ function ChatViewContent(props: ChatViewProps) {
62046209 onUpdateProjectScript={updateProjectScript}
62056210 onDeleteProjectScript={deleteProjectScript}
62066211 />
6207 </header>
6212 </WorkspacePageHeader>
62086213
62096214 <ThreadErrorBanner
62106215 error={visibleThreadError}
......@@ -6542,6 +6547,7 @@ function ChatViewContent(props: ChatViewProps) {
65426547 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
65436548 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
65446549 keybindings={keybindings}
6550 onHide={hideTerminal}
65456551 onAddTerminalContext={addTerminalContextToDraft}
65466552 />
65476553 ))}
apps/web/src/components/NoActiveThreadState.tsx+4-15
......@@ -1,26 +1,15 @@
11import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
22import { SidebarInset } from "./ui/sidebar";
33import { isElectron } from "../env";
4import { cn } from "~/lib/utils";
5import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
4import { WorkspacePageHeader } from "./WorkspacePageContainer";
65
76export function NoActiveThreadState() {
87 return (
98 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
109 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
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 >
10 <WorkspacePageHeader electron={isElectron} className="border-b border-border">
2011 {isElectron ? (
21 <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]">
22 No active thread
23 </span>
12 <span className="text-xs text-muted-foreground/50">No active thread</span>
2413 ) : (
2514 <div className="flex items-center gap-2">
2615 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
......@@ -28,7 +17,7 @@ export function NoActiveThreadState() {
2817 </span>
2918 </div>
3019 )}
31 </header>
20 </WorkspacePageHeader>
3221
3322 <Empty className="flex-1">
3423 <div className="w-full max-w-lg px-8 py-12">
apps/web/src/components/Sidebar.logic.ts+2-1
......@@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
299299export function shouldCreateNewThreadInCurrentProject(
300300 shiftKey: boolean,
301301 projectGroupCount: number,
302 hasProjectScope = false,
302303): boolean {
303 return shiftKey || projectGroupCount <= 1;
304 return hasProjectScope || shiftKey || projectGroupCount <= 1;
304305}
305306
306307export function orderItemsByPreferredIds<TItem, TId>(input: {
apps/web/src/components/Sidebar.tsx+159-157
......@@ -184,6 +184,7 @@ 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";
187188
188189function compactSidebarTimeLabel(label: string): string {
189190 if (label === "just now") return "now";
......@@ -366,26 +367,20 @@ function SnoozePopoverButton(props: {
366367 );
367368 return (
368369 <Popover open={open} onOpenChange={onOpenChange}>
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>
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>
389384 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
390385 {presets.map((preset) => (
391386 <button
......@@ -1199,9 +1194,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11991194 aria-label="Dismiss Woke notification"
12001195 title="Dismiss Woke notification"
12011196 onClick={handleAcknowledgeWokeClick}
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"
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"
12031198 >
1204 <AlarmClockIcon aria-hidden className="size-3" />
1199 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
12051200 <span role="status">Woke</span>
12061201 </button>
12071202 ) : (
......@@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12231218 isWoke && "group-hover/sidebar-row:static",
12241219 )}
12251220 >
1226 <AlarmClockOffIcon className="mb-px size-3" />
1221 <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
12271222 </button>
12281223 )
12291224 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
......@@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12361231 isWoke && "group-hover/sidebar-row:static",
12371232 )}
12381233 >
1239 <Undo2Icon className="mb-px size-3.5" />
1234 <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
12401235 </button>
12411236 ) : (
12421237 <button
......@@ -1248,7 +1243,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12481243 isWoke && "group-hover/sidebar-row:static",
12491244 )}
12501245 >
1251 <CheckIcon className="size-3" />
1246 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
12521247 </button>
12531248 )}
12541249 </span>
......@@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13171312 ) : (
13181313 <span className="flex-1" />
13191314 )}
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 }
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}
13321324 >
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. */}
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. */}
13531340 <span
13541341 className={cn(
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",
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",
13601344 )}
13611345 >
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 ? (
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. */}
14051349 <span
14061350 className={cn(
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",
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",
14141356 )}
14151357 >
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"
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}
14331384 />
1434 }
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"
14351429 >
1436 <CheckIcon className="size-3.5" />
1430 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
14371431 Settle
1438 </TooltipTrigger>
1439 <TooltipPopup>Settle thread</TooltipPopup>
1440 </Tooltip>
1441 ) : null}
1442 </span>
1443 ) : null}
1432 </button>
1433 ) : null}
1434 </span>
1435 ) : null}
1436 </span>
14441437 </span>
14451438 </div>
14461439 <div className="mt-1 flex min-w-0">
......@@ -3218,17 +3211,25 @@ export default function Sidebar() {
32183211 autoAnimate(node, { duration: 150, easing: "ease-out" });
32193212 }, []);
32203213
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.
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.
32253217 const handleNewThreadClick = useCallback(
32263218 (event?: ReactMouseEvent) => {
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)) {
3219 if (
3220 shouldCreateNewThreadInCurrentProject(
3221 event?.shiftKey ?? false,
3222 projectGroups.length,
3223 scopedProjectGroup !== null,
3224 )
3225 ) {
32313226 if (isMobile) setOpenMobile(false);
3227 if (scopedProjectGroup) {
3228 void newThreadContext.handleNewThread(
3229 scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id),
3230 );
3231 return;
3232 }
32323233 void startNewThreadFromContext({
32333234 activeDraftThread: newThreadContext.activeDraftThread,
32343235 activeThread: newThreadContext.activeThread ?? undefined,
......@@ -3240,20 +3241,19 @@ export default function Sidebar() {
32403241 if (isMobile) setOpenMobile(false);
32413242 openCommandPalette({ open: "new-thread-in" });
32423243 },
3243 [isMobile, newThreadContext, projectGroups.length, setOpenMobile],
3244 [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile],
32443245 );
32453246
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.
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.
32543250 const newThreadShortcutLabel =
3255 shortcutLabelForCommand(keybindings, "chat.new") ??
3256 (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
3251 scopedProjectGroup === null
3252 ? (shortcutLabelForCommand(keybindings, "chat.new") ??
3253 (projectGroups.length <= 1
3254 ? shortcutLabelForCommand(keybindings, "chat.newLocal")
3255 : undefined))
3256 : undefined;
32573257 const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
32583258 return (
32593259 <>
......@@ -3332,7 +3332,9 @@ export default function Sidebar() {
33323332 />
33333333 </TooltipTrigger>
33343334 <TooltipPopup side="right">
3335 {projectGroups.length > 1 ? (
3335 {scopedProjectGroup ? (
3336 `New thread in ${scopedProjectGroup.displayName}`
3337 ) : projectGroups.length > 1 ? (
33363338 <span className="flex flex-col gap-0.5">
33373339 <span>
33383340 {newThreadShortcutLabel
apps/web/src/components/ThreadTerminalDrawer.tsx+356-308
......@@ -5,11 +5,11 @@ import {
55} from "@t3tools/client-runtime/state/runtime";
66import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
77import {
8 PanelBottomCloseIcon,
89 Plus,
910 SquareSplitHorizontal,
1011 SquareSplitVertical,
1112 TerminalSquare,
12 Trash2,
1313 XIcon,
1414} from "lucide-react";
1515import {
......@@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
2121import * as Schema from "effect/Schema";
2222import {
2323 type PointerEvent as ReactPointerEvent,
24 type ReactNode,
2524 type SetStateAction,
2625 useCallback,
2726 useEffect,
......@@ -30,9 +29,9 @@ import {
3029 useRef,
3130 useState,
3231} from "react";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3432import { Button } from "~/components/ui/button";
3533import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
34import { useResizableWidth } from "~/hooks/useResizableWidth";
3635import { cn } from "~/lib/utils";
3736import { type TerminalContextSelection } from "~/lib/terminalContext";
3837import {
......@@ -60,6 +59,7 @@ import {
6059import { readLocalApi } from "~/localApi";
6160import { useClientSettings } from "../hooks/useSettings";
6261import { 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,10 +72,15 @@ import {
7272 resolveTerminalFontSizePreference,
7373 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
7474} from "../appearanceFonts";
75import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle";
7576
7677const MIN_DRAWER_HEIGHT = 180;
7778const MAX_DRAWER_HEIGHT_RATIO = 0.75;
7879const 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";
7984
8085function maxDrawerHeight(): number {
8186 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
......@@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp(
244249 return selectionGestureActive && button === 0;
245250}
246251
252export function shouldShowTerminalSidebar(terminalCount: number): boolean {
253 return terminalCount > 1;
254}
255
247256export function terminalSelectionLineRange(position: {
248257 start: { y: number };
249258 end: { y: number };
......@@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps {
876885 onSplitTerminal: () => void;
877886 onSplitTerminalVertical: () => void;
878887 onNewTerminal: () => void;
888 onHide?: () => void;
879889 splitShortcutLabel?: string | undefined;
880890 splitVerticalShortcutLabel?: string | undefined;
881891 newShortcutLabel?: string | undefined;
......@@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps {
891901 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
892902}
893903
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
923904export default function ThreadTerminalDrawer({
924905 mode = "drawer",
925906 threadRef,
......@@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({
937918 onSplitTerminal,
938919 onSplitTerminalVertical,
939920 onNewTerminal,
921 onHide,
940922 splitShortcutLabel,
941923 splitVerticalShortcutLabel,
942924 newShortcutLabel,
......@@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({
950932 terminalLaunchLocationsById,
951933}: ThreadTerminalDrawerProps) {
952934 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);
953950 const [advancedTypography] = useLocalStorage(
954951 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
955952 false,
......@@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({
10981095 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
10991096 const splitDirection =
11001097 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
1101 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
1098 const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length);
11021099 const isSplitView = visibleTerminalIds.length > 1;
1103 const showGroupHeaders =
1104 resolvedTerminalGroups.length > 1 ||
1105 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
11061100 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
1107 const terminalLabelById = useMemo(() => {
1101 const automaticTerminalLabelById = useMemo(() => {
11081102 const next = new Map<string, string>();
11091103 for (const terminalId of normalizedTerminalIds) {
11101104 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
11111105 }
11121106 return next;
11131107 }, [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]);
11141120 const resolveTerminalLaunchLocation = useCallback(
11151121 (terminalId: string): TerminalLaunchLocation => {
11161122 return (
......@@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({
11231129 },
11241130 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
11251131 );
1132 const newTerminalActionLabel = newShortcutLabel
1133 ? `New Terminal (${newShortcutLabel})`
1134 : "New Terminal";
11261135 const splitTerminalActionLabel = hasReachedSplitLimit
11271136 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
11281137 : splitShortcutLabel
......@@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({
11331142 : splitVerticalShortcutLabel
11341143 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
11351144 : "Split Terminal Vertically";
1136 const newTerminalActionLabel = newShortcutLabel
1137 ? `New Terminal (${newShortcutLabel})`
1138 : "New Terminal";
11391145 const closeTerminalActionLabel = closeShortcutLabel
11401146 ? `Close Terminal (${closeShortcutLabel})`
11411147 : "Close Terminal";
......@@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({
11471153 if (hasReachedSplitLimit) return;
11481154 onSplitTerminalVertical();
11491155 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1150 const onNewTerminalAction = useCallback(() => {
1151 onNewTerminal();
1152 }, [onNewTerminal]);
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]);
11531193
11541194 useEffect(() => {
11551195 onHeightChangeRef.current = onHeightChange;
......@@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({
12741314 ) : null}
12751315 <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">
12761316 <p>No terminal sessions for this thread yet.</p>
1277 <Button size="xs" variant="outline" onClick={onNewTerminalAction}>
1317 <Button size="xs" variant="outline" onClick={onNewTerminal}>
12781318 {newTerminalActionLabel}
12791319 </Button>
12801320 </div>
......@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({
12831323 }
12841324
12851325 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1286
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 );
12871392 return (
12881393 <aside
12891394 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
......@@ -1303,277 +1408,220 @@ export default function ThreadTerminalDrawer({
13031408 />
13041409 ) : null}
13051410
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>
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}
13481415 </div>
13491416 </div>
1350 )}
1417 ) : null}
13511418
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>
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 />
14111473 </div>
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 )}
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}
14411516 </div>
14421517
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)}
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 )}
15051555 >
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 }`}
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 }}
15261605 >
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>
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>
15771625 </div>
15781626 </aside>
15791627 );
apps/web/src/components/WorkspacePageContainer.tsx created+62
......@@ -0,0 +1,62 @@
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+11-12
......@@ -23,13 +23,9 @@ 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(
27 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden',
28 );
26 expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1');
2927 expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap');
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");
28 expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>');
3329 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');
3430 expect(markup).toContain("!size-[22px]");
3531 expect(markup).toContain("size-3");
......@@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => {
3834 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
3935 expect(markup).toContain("1 changed file");
4036 expect(markup).not.toContain("1 changed files");
37 expect(markup).not.toContain("Hide files");
38 expect(markup).not.toContain("ml-auto");
4139 });
4240
43 it("renders a scope and representative-file preview for a large latest change", () => {
41 it("renders a clean representative-file preview for a large latest change", () => {
4442 const markup = renderToStaticMarkup(
4543 <ChangedFilesCard
4644 turnId={TurnId.make("turn-1")}
......@@ -67,14 +65,15 @@ describe("ChangedFilesCard", () => {
6765
6866 expect(markup).toContain('data-changed-files-state="preview"');
6967 expect(markup).toContain('aria-expanded="false"');
70 expect(markup).toContain("apps");
71 expect(markup).toContain("2 files");
72 expect(markup).toContain("packages");
73 expect(markup).toContain("root");
68 expect(markup).toContain("apps/web/src/");
69 expect(markup).toContain("packages/shared/src/");
7470 expect(markup).toContain("App.tsx");
7571 expect(markup).toContain("git.ts");
7672 expect(markup).toContain("README.md");
77 expect(markup).toContain("Show all 4 files");
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"');
7877 expect(markup).not.toContain("App.test.tsx");
7978 });
8079
apps/web/src/components/chat/ChangedFilesTree.tsx+23-39
......@@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
1919import { PierreEntryIcon } from "./PierreEntryIcon";
2020import { Button } from "../ui/button";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import {
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
22import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation";
2723
2824const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2925
......@@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
5046 onOpenTurnDiff,
5147 } = props;
5248 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
5449 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
5550 const compactPreviewVisible = showCompactPreview && !expanded;
5651
5752 return (
5853 <div
59 className="@container/changed-files mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
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"
6055 data-changed-files-state={
6156 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
6257 }
......@@ -64,7 +59,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
6459 <div
6560 data-changed-files-header=""
6661 className={cn(
67 "flex items-center justify-between gap-2 rounded-xl px-1",
62 "flex items-center justify-between gap-2 rounded-lg px-1",
6863 expanded &&
6964 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
7065 )}
......@@ -73,7 +68,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
7368 type="button"
7469 aria-expanded={expanded}
7570 data-scroll-anchor-ignore
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"
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"
7772 onClick={() => onExpandedChange(!expanded)}
7873 >
7974 <ChevronRightIcon
......@@ -96,9 +91,6 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
9691 />
9792 )}
9893 </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>
10294 </button>
10395 <div className="flex shrink-0 items-center gap-1.5">
10496 {expanded ? (
......@@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
158150 onOpenTurnDiff={onOpenTurnDiff}
159151 />
160152 ) : compactPreviewVisible ? (
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">
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">
174155 {previewFiles.map((file) => (
175156 <button
176157 key={file.path}
177158 type="button"
178159 title={file.path}
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"
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"
180161 onClick={() => onOpenTurnDiff(turnId, file.path)}
181162 >
182163 <PierreEntryIcon
183164 pathValue={file.path}
184165 kind="file"
185166 theme={resolvedTheme}
186 className="size-3 shrink-0 text-muted-foreground/70"
167 className="size-3.5 shrink-0 text-muted-foreground/70"
187168 />
188 <span className="truncate">{changedFileName(file.path)}</span>
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}
189180 </button>
190181 ))}
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>
198182 </div>
199183 </div>
200184 ) : null}
......@@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
270254 ) : (
271255 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
272256 )}
273 <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
257 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
274258 {node.name}
275259 </span>
276260 {hasNonZeroStat(node.stat) && (
277 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
261 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">
278262 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
279263 </span>
280264 )}
......@@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
305289 theme={resolvedTheme}
306290 className="size-3.5 text-muted-foreground/70"
307291 />
308 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
292 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
309293 {node.name}
310294 </span>
311295 {node.stat && (
312 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
296 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">
313297 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
314298 </span>
315299 )}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+209-7
......@@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => {
531531
532532 expect(expandedRows.map((row) => row.id)).toEqual([
533533 "user-entry",
534 "turn-fold:turn-1",
535534 "assistant-thought-entry",
536 "work-entry-1",
535 "work-toggle:work-entry-1",
536 "turn-fold:turn-1",
537537 "assistant-final-entry",
538538 ]);
539539 expect(
......@@ -638,6 +638,84 @@ 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
641719 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
642720 const rows = deriveMessagesTimelineRows({
643721 timelineEntries: [
......@@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => {
771849 turnId: "turn-1" as never,
772850 label: "Ran command",
773851 tone: "tool" as const,
852 toolLifecycleStatus: "inProgress" as const,
774853 },
775854 },
776855 ],
......@@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => {
788867
789868 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
790869 expect(rows.map((row) => row.id)).toEqual([
870 "working-indicator-row",
791871 "assistant-thought-entry",
792 "work-entry-1",
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([
793927 "working-indicator-row",
928 "work-live:tool:call-1",
794929 ]);
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);
795997 });
796998
797999 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => {
8521054 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
8531055 "turn-1",
8541056 ]);
855 expect(rows.map((row) => row.id)).toContain("running-work-entry");
1057 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
8561058 });
8571059
8581060 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => {
9941196 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
9951197 });
9961198
997 expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
1199 expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]);
9981200 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
9991201 groupId: "work-group:work-entry-1",
1000 hiddenCount: 2,
1202 hiddenCount: 3,
10011203 expanded: false,
10021204 onlyToolEntries: true,
10031205 });
10041206 expect(expandedRows.map((row) => row.id)).toEqual([
1207 "work-toggle:work-entry-1",
10051208 "work-1",
10061209 "work-2",
10071210 "work-3",
1008 "work-toggle:work-entry-1",
10091211 ]);
10101212 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
10111213 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+382-30
......@@ -1,6 +1,7 @@
11import * as Equal from "effect/Equal";
22import {
33 formatDuration,
4 workEntryIndicatesToolFailure,
45 workEntryIndicatesToolNeutralStatus,
56 workLogEntryIsToolLike,
67 type TimelineEntry,
......@@ -166,6 +167,17 @@ export type MessagesTimelineRow =
166167 id: string;
167168 createdAt: string;
168169 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;
169181 }
170182 | {
171183 kind: "work-toggle";
......@@ -175,6 +187,9 @@ export type MessagesTimelineRow =
175187 hiddenCount: number;
176188 expanded: boolean;
177189 onlyToolEntries: boolean;
190 summary: string | null;
191 summaryKind: ToolGroupAction | "mixed" | null;
192 hasFailure: boolean;
178193 }
179194 | {
180195 kind: "turn-fold";
......@@ -208,7 +223,12 @@ export type MessagesTimelineRow =
208223 createdAt: string;
209224 turnPlan: TurnPlanEntry;
210225 }
211 | { kind: "working"; id: string; createdAt: string | null };
226 | {
227 kind: "working";
228 id: string;
229 createdAt: string | null;
230 showThinking: boolean;
231 };
212232
213233export interface StableMessagesTimelineRowsState {
214234 byId: Map<string, MessagesTimelineRow>;
......@@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string {
238258 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
239259}
240260
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
241345export function resolveAssistantMessageCopyState({
242346 text,
243347 showCopyButton,
......@@ -310,17 +414,34 @@ function deriveUnsettledTurnId(
310414 return isSettled ? null : latestTurn.turnId;
311415}
312416
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
313433/**
314434 * Settled turns fold their commentary and tool activity behind a
315 * "Worked for ..." row anchored at the turn's first foldable entry; the
316 * terminal assistant message stays visible below the fold.
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.
317438 */
318439function deriveTurnFolds(input: {
319440 timelineEntries: ReadonlyArray<TimelineEntry>;
320441 terminalAssistantMessageIds: ReadonlySet<string>;
321442 latestTurn: TimelineLatestTurn | null;
322443 unsettledTurnId: TurnId | null;
323}): ReadonlyMap<string, TurnFold> {
444}): ReadonlyMap<string, ReadonlyArray<TurnFold>> {
324445 interface TurnGroup {
325446 entries: Array<TimelineEntry>;
326447 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
......@@ -375,7 +496,7 @@ function deriveTurnFolds(input: {
375496 }
376497 }
377498
378 const foldsByAnchorEntryId = new Map<string, TurnFold>();
499 const foldsByAnchorEntryId = new Map<string, TurnFold[]>();
379500 for (const [turnId, group] of groupsByTurnId) {
380501 if (turnId === input.unsettledTurnId) {
381502 continue;
......@@ -405,6 +526,24 @@ function deriveTurnFolds(input: {
405526 if (!firstEntry || !lastEntry) {
406527 continue;
407528 }
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 }
408547
409548 const isLatestInterruptedTurn =
410549 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
......@@ -431,13 +570,16 @@ function deriveTurnFolds(input: {
431570 ? `Worked for ${duration}`
432571 : "Worked";
433572
434 foldsByAnchorEntryId.set(firstEntry.id, {
573 const fold = {
435574 turnId,
436 anchorEntryId: firstEntry.id,
437 createdAt: firstEntry.createdAt,
575 anchorEntryId: anchorEntry.id,
576 createdAt: anchorEntry.createdAt,
438577 hiddenEntryIds,
439578 label,
440 });
579 };
580 const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id);
581 if (anchoredFolds) anchoredFolds.push(fold);
582 else foldsByAnchorEntryId.set(anchorEntry.id, [fold]);
441583 }
442584 return foldsByAnchorEntryId;
443585}
......@@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: {
469611 unsettledTurnId,
470612 });
471613 const collapsedEntryIds = new Set<string>();
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);
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 }
476620 }
477621 }
478622 }
479623
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
480739 for (let index = 0; index < input.timelineEntries.length; index += 1) {
481740 const timelineEntry = input.timelineEntries[index];
482741 if (!timelineEntry) {
483742 continue;
484743 }
485744
486 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
487 if (turnFold) {
745 if (input.isWorking && index === activeTurnHeaderIndex) {
488746 nextRows.push({
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,
747 kind: "working",
748 id: "working-indicator-row",
749 createdAt: input.activeTurnStartedAt,
750 showThinking: !activeTurnHasVisibleContent,
495751 });
496752 }
497753
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
498768 if (collapsedEntryIds.has(timelineEntry.id)) {
499769 continue;
500770 }
501771
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
502792 if (timelineEntry.kind === "work") {
503793 const groupedEntries = [timelineEntry.entry];
504794 let cursor = index + 1;
......@@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: {
507797 if (
508798 !nextEntry ||
509799 nextEntry.kind !== "work" ||
800 activeWorkEntryIds.has(nextEntry.id) ||
510801 collapsedEntryIds.has(nextEntry.id) ||
511802 foldsByAnchorEntryId.has(nextEntry.id)
512803 ) {
......@@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: {
519810 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
520811 );
521812 if (visibleGroupedEntries.length > 0) {
522 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
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) {
523845 nextRows.push({
524846 kind: "work",
525847 id: timelineEntry.id,
526848 createdAt: timelineEntry.createdAt,
527849 groupedEntries: visibleGroupedEntries,
850 isExpandedToolGroupEntry: false,
851 isLastExpandedToolGroupEntry: false,
528852 });
529853 } else {
530 const groupId = `work-group:${timelineEntry.id}`;
854 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
531855 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
532856 // Agent-spawn CTA rows are always visible: a running fleet must
533857 // never hide behind a "+N tool calls" toggle. Selection is by
......@@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: {
551875 id: workEntry.id,
552876 createdAt: workEntry.createdAt,
553877 groupedEntries: [workEntry],
878 isExpandedToolGroupEntry: false,
879 isLastExpandedToolGroupEntry: false,
554880 });
555881 }
556882
......@@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: {
562888 groupId,
563889 hiddenCount: hiddenEntries.length,
564890 expanded,
565 onlyToolEntries: visibleGroupedEntries.every((entry) =>
566 workLogEntryIsToolLike(entry),
891 onlyToolEntries,
892 summary: null,
893 summaryKind: null,
894 hasFailure: visibleGroupedEntries.some((entry) =>
895 workEntryIndicatesToolFailure(entry),
567896 ),
568897 });
569898 }
......@@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: {
629958 });
630959 }
631960
632 if (input.isWorking) {
961 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
633962 nextRows.push({
634963 kind: "working",
635964 id: "working-indicator-row",
636965 createdAt: input.activeTurnStartedAt,
966 showThinking: !activeTurnHasVisibleContent,
637967 });
638968 }
639969
......@@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
666996
667997 switch (a.kind) {
668998 case "working":
669 return a.createdAt === (b as typeof a).createdAt;
999 return (
1000 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1001 );
6701002
6711003 case "turn-fold": {
6721004 const bf = b as typeof a;
......@@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6831015 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
6841016 }
6851017
686 case "work":
687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
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 }
6881037
6891038 case "work-toggle": {
6901039 const bw = b as typeof a;
......@@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6931042 a.groupId === bw.groupId &&
6941043 a.hiddenCount === bw.hiddenCount &&
6951044 a.expanded === bw.expanded &&
696 a.onlyToolEntries === bw.onlyToolEntries
1045 a.onlyToolEntries === bw.onlyToolEntries &&
1046 a.summary === bw.summary &&
1047 a.summaryKind === bw.summaryKind &&
1048 a.hasFailure === bw.hasFailure
6971049 );
6981050 }
6991051
apps/web/src/components/chat/MessagesTimeline.test.tsx+44-2
......@@ -554,7 +554,49 @@ describe("MessagesTimeline", () => {
554554 expect(markup).toContain("Work Log");
555555 });
556556
557 it("formats changed file paths from the workspace root", () => {
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", () => {
558600 const markup = renderToStaticMarkup(
559601 <MessagesTimeline
560602 {...buildProps()}
......@@ -576,7 +618,7 @@ describe("MessagesTimeline", () => {
576618 />,
577619 );
578620
579 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
621 expect(markup).toContain("Changed 1 file");
580622 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
581623 });
582624
apps/web/src/components/chat/MessagesTimeline.tsx+336-155
......@@ -35,7 +35,6 @@ import {
3535 deriveTimelineEntries,
3636 workEntryIndicatesToolFailure,
3737 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
3938 workLogEntryIsToolLike,
4039} from "../../session-logic";
4140import { type TurnDiffSummary } from "../../types";
......@@ -57,7 +56,6 @@ import {
5756 MessageCircleIcon,
5857 MousePointerClickIcon,
5958 PaintbrushIcon,
60 MinusIcon,
6159 SquarePenIcon,
6260 TerminalIcon,
6361 Undo2Icon,
......@@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
920918type TimelineRow = MessagesTimelineRow;
921919
922920const 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
923927 return (
924928 <div
925929 className={cn(
926930 // Commentary (non-terminal assistant) rows carry no metadata row, so
927931 // they sit closer to the work that follows them.
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",
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",
934949 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
935950 )}
936951 data-timeline-row-id={row.id}
......@@ -938,7 +953,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
938953 data-message-id={row.kind === "message" ? row.message.id : undefined}
939954 data-message-role={row.kind === "message" ? row.message.role : undefined}
940955 >
941 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
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}
942963 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
943964 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
944965 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
......@@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
10831104
10841105function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
10851106 const ctx = use(TimelineRowCtx);
1086 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
10871107
10881108 return (
10891109 <div className="border-b border-border/60 pb-2 pt-1">
......@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
10921112 aria-expanded={row.expanded}
10931113 data-scroll-anchor-ignore
10941114 onClick={() => ctx.onToggleTurnFold(row.turnId)}
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"
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"
10961116 >
10971117 <span>{row.label}</span>
1098 <Icon className="size-3.5" />
1118 <ChevronRightIcon
1119 className={cn("size-3.5 transition-transform", row.expanded && "-rotate-90")}
1120 />
10991121 </button>
11001122 </div>
11011123 );
......@@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
12781300});
12791301
12801302function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1281 const { workingStepLabel } = use(TimelineRowActivityCtx);
12821303 return (
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">
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">
12911307 {row.createdAt ? (
12921308 <>
12931309 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
12951311 ) : (
12961312 "Working..."
12971313 )}
1298 </span>
1299 {workingStepLabel ? (
1300 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1301 ) : null}
1314 </div>
13021315 </div>
1316 {row.showThinking ? (
1317 <div className="mt-2">
1318 <ThinkingActivityRow />
1319 </div>
1320 ) : null}
13031321 </div>
13041322 );
13051323}
......@@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
13401358/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
13411359const WorkGroupSection = memo(function WorkGroupSection({
13421360 groupedEntries,
1361 isExpandedToolGroupEntry,
13431362}: {
13441363 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1364 isExpandedToolGroupEntry: boolean;
13451365}) {
13461366 const { workspaceRoot } = use(TimelineRowCtx);
13471367 const nonEmptyEntries = useMemo(
......@@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({
13581378 if (nonEmptyEntries.length === 0) return null;
13591379
13601380 return (
1361 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
1381 <section
1382 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1383 aria-label={groupLabel}
1384 >
13621385 {!onlyToolEntries && (
13631386 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
13641387 )}
......@@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
13681391 key={workEntry.id}
13691392 workEntry={workEntry}
13701393 workspaceRoot={workspaceRoot}
1394 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
13711395 />
13721396 ))}
13731397 </div>
......@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({
13751399 );
13761400});
13771401
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
13781490function WorkGroupToggleTimelineRow({
13791491 row,
13801492}: {
13811493 row: Extract<TimelineRow, { kind: "work-toggle" }>;
13821494}) {
13831495 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 }
13841524 const labelNoun = row.onlyToolEntries
13851525 ? row.hiddenCount === 1
13861526 ? "tool call"
......@@ -2019,32 +2159,101 @@ function workEntryPreview(
20192159 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
20202160}
20212161
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;
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;
20282223 }
2029 return rawCommand === workEntry.command.trim() ? null : rawCommand;
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";
2237 }
2238
2239 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
20302240}
20312241
20322242function buildToolCallExpandedBody(
20332243 workEntry: TimelineWorkEntry,
20342244 workspaceRoot: string | undefined,
20352245): string | null {
2246 const command = workEntry.rawCommand?.trim() || workEntry.command?.trim();
20362247 const blocks: string[] = [];
2248 if (command) {
2249 blocks.push(command);
2250 }
20372251 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
20382252 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
20392253 }
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());
2254 const detail = workEntry.detail?.trim();
2255 if (detail && detail !== command) {
2256 blocks.push(detail);
20482257 }
20492258 const changedFiles = workEntry.changedFiles ?? [];
20502259 if (changedFiles.length > 0) {
......@@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
21802389 : "working"
21812390 : failed > 0
21822391 ? `${failed} failed`
2183 : "✓ completed";
2392 : "Completed";
21842393
21852394 return (
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>
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>
22052427 );
22062428});
22072429
22082430const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
22092431 workEntry: TimelineWorkEntry;
22102432 workspaceRoot: string | undefined;
2433 isExpandedToolGroupEntry: boolean;
22112434}) {
2212 const { workEntry, workspaceRoot } = props;
2435 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
22132436 // Before any hooks: spawn CTA rows render their own component.
22142437 if (workEntry.agentSpawn) {
22152438 return <AgentSpawnCtaRow workEntry={workEntry} />;
22162439 }
2217 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;
2440 return (
2441 <PlainWorkEntryRow
2442 workEntry={workEntry}
2443 workspaceRoot={workspaceRoot}
2444 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2445 />
2446 );
22182447});
22192448
22202449const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22212450 workEntry: TimelineWorkEntry;
22222451 workspaceRoot: string | undefined;
2452 isExpandedToolGroupEntry: boolean;
22232453}) {
2224 const { workEntry, workspaceRoot } = props;
2225 const activity = use(TimelineRowActivityCtx);
2454 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
22262455 const [expanded, setExpanded] = useState(false);
22272456 const iconConfig = workToneIcon(workEntry.tone);
22282457 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
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;
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);
22392466 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
22402467 const canExpand = expandedBody !== null;
2241 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
22422468 const showDestructiveRowStyle =
22432469 showFailedIndicator &&
22442470 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
22452471 const iconWrapperClass = cn(
2246 "flex size-5 shrink-0 items-center justify-center",
2247 showWarningIndicator
2472 "flex size-6 shrink-0 items-center justify-center",
2473 showWarningIndicator || showFailedIndicator
22482474 ? "text-destructive"
22492475 : showDestructiveRowStyle
22502476 ? "text-destructive"
......@@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22562482 ? "font-medium text-warning"
22572483 : showDestructiveRowStyle
22582484 ? "font-medium text-destructive"
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));
2485 : workLogEntryIsToolLike(workEntry)
2486 ? "text-secondary-label"
2487 : "text-foreground/80";
2488 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
22652489 const rowToggleProps = canExpand
22662490 ? {
22672491 role: "button" as const,
22682492 tabIndex: 0 as const,
22692493 "aria-label": displayText,
2494 "aria-expanded": expanded,
22702495 onClick: () => setExpanded((v) => !v),
22712496 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
22722497 if (e.key === "Enter" || e.key === " ") {
......@@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22802505 return (
22812506 <div
22822507 className={cn(
2283 "flex flex-col rounded-md px-0.5 py-0.5 transition-colors",
2508 "flex flex-col rounded-md px-0.5 transition-colors",
2509 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
22842510 canExpand &&
2285 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
2511 "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
22862512 )}
22872513 {...rowToggleProps}
22882514 >
22892515 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
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>
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}
22962527 <div className="flex min-w-0 flex-1 items-center gap-1.5">
22972528 <div className="min-w-0 flex-1 overflow-hidden">
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>
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",
23022533 )}
2303 </p>
2304 </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}
23092534 >
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>
2535 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
2536 </p>
23612537 </div>
23622538 </div>
23632539 </div>
23642540 {expanded && canExpand && expandedBody ? (
23652541 <div
2366 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
2542 className="mt-1 ms-[1.875rem] cursor-default pt-0.5"
23672543 onClick={stopRowToggle}
23682544 onPointerDown={stopRowToggle}
23692545 >
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">
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 >
23712552 {expandedBody}
23722553 </pre>
23732554 </div>
apps/web/src/components/chat/PanelLayoutControls.tsx+13-5
......@@ -1,6 +1,7 @@
11import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";
22import { memo } from "react";
33
4import { cn } from "../../lib/utils";
45import { Toggle } from "../ui/toggle";
56import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67
......@@ -12,6 +13,7 @@ interface PanelLayoutControlsProps {
1213 rightPanelAvailable: boolean;
1314 rightPanelOpen: boolean;
1415 rightPanelShortcutLabel: string | null;
16 rightPanelUnavailableLabel?: string;
1517 /** Running + waiting subagents in this thread; badges the right panel toggle. */
1618 liveAgentCount: number;
1719 onToggleTerminal: () => void;
......@@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
2628 rightPanelAvailable,
2729 rightPanelOpen,
2830 rightPanelShortcutLabel,
31 rightPanelUnavailableLabel = "Right panel is unavailable",
2932 liveAgentCount,
3033 onToggleTerminal,
3134 onToggleRightPanel,
......@@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
4043 <TooltipTrigger
4144 render={
4245 <Toggle
43 className="shrink-0 [-webkit-app-region:no-drag]"
46 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"
4447 pressed={terminalOpen}
4548 onPressedChange={onToggleTerminal}
4649 aria-label="Toggle terminal drawer"
......@@ -63,7 +66,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
6366 <TooltipTrigger
6467 render={
6568 <Toggle
66 className="shrink-0 [-webkit-app-region:no-drag]"
69 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag] disabled:opacity-100"
6770 pressed={rightPanelOpen}
6871 onPressedChange={onToggleRightPanel}
6972 aria-label={
......@@ -75,7 +78,12 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
7578 size="sm"
7679 disabled={!rightPanelAvailable}
7780 >
78 <PanelRightIcon className="size-4" />
81 <PanelRightIcon
82 className={cn(
83 "size-4",
84 !rightPanelAvailable && "text-muted-foreground opacity-100",
85 )}
86 />
7987 {liveAgentCount > 0 ? (
8088 <span
8189 aria-hidden
......@@ -94,7 +102,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
94102 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
95103 : ""
96104 }`
97 : "Right panel is unavailable"}
105 : rightPanelUnavailableLabel}
98106 </TooltipPopup>
99107 </Tooltip>
100108 </div>
......@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl
114122 <TooltipTrigger
115123 render={
116124 <Toggle
117 className="shrink-0 [-webkit-app-region:no-drag]"
125 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"
118126 pressed={maximized}
119127 onPressedChange={onToggle}
120128 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 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";
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";
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 = "size-[1.17em] shrink-0 opacity-85";
14export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME =
15 "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block";
1516
1617export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
1718
18export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
19export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
20 "block self-center truncate leading-none select-none";
1921
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]`;
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
2323
2424export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
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";
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";
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+285-332
......@@ -25,6 +25,7 @@ import {
2525 GitPullRequestDraftIcon,
2626 GitPullRequestIcon,
2727 HammerIcon,
28 LayersIcon,
2829 MessageCircleQuestionIcon,
2930 MessageSquareIcon,
3031 LinkIcon,
......@@ -50,6 +51,7 @@ import {
5051import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5152import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
5253import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
5355import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
5456import { cn } from "~/lib/utils";
5557import { readLocalApi } from "~/localApi";
......@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";
6062import { useLiveRefresh } from "~/hooks/useLiveRefresh";
6163import { pullRequestEnvironment } from "~/state/pullRequests";
6264import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
6366import { formatRelativeTimeLabel } from "~/timestampFormat";
6467
6568import {
......@@ -74,6 +77,7 @@ import {
7477import { Badge } from "../ui/badge";
7578import { Button } from "../ui/button";
7679import { Input } from "../ui/input";
80import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
7781import {
7882 Menu,
7983 MenuItem,
......@@ -116,6 +120,7 @@ import {
116120} from "./pullRequestProjectAssignment.logic";
117121import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
118122import {
123 PullRequestActorAvatar,
119124 PullRequestActorLabel,
120125 PullRequestDiffStat,
121126 PullRequestMetaLine,
......@@ -349,7 +354,6 @@ export function PullRequestDetailPanel({
349354 onClose,
350355 onStateChange,
351356 context = "page",
352 chromeVariant = "full",
353357 composerDraftTarget,
354358}: {
355359 environmentId: EnvironmentId;
......@@ -381,12 +385,6 @@ export function PullRequestDetailPanel({
381385 * again is at best a no-op and at worst git refusing a branch two checkouts.
382386 */
383387 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";
390388 /**
391389 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
392390 * land here instead of opening a new thread — the branch is already under the reader's feet.
......@@ -423,26 +421,13 @@ export function PullRequestDetailPanel({
423421 );
424422 }, [tab]);
425423 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.
431424 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
432425 useEffect(() => {
433426 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
434427 }, [tab]);
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.
428 const condensed = chromeCondensed;
441429 const scrollerRef = useRef<HTMLElement | null>(null);
442430 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.
446431 const condensedRowRef = useRef<HTMLDivElement | null>(null);
447432 const compensationRef = useRef<number | null>(null);
448433 useLayoutEffect(() => {
......@@ -463,7 +448,6 @@ export function PullRequestDetailPanel({
463448 target: "branch name",
464449 timeout: 1600,
465450 });
466
467451 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
468452 // clicked, so a reader who does click it lands on a chunk already in the module cache.
469453 useEffect(() => {
......@@ -502,6 +486,30 @@ export function PullRequestDetailPanel({
502486 },
503487 [activity, coreDetail],
504488 );
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);
505513 const activityPending = activityQuery.isPending && activity === null;
506514 const activityError = activity === null ? activityQuery.error : null;
507515 const refreshDetail = useCallback(() => {
......@@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({
10191027 const can = (action: PullRequestAction) =>
10201028 detail?.capabilities.actions.includes(action) === true &&
10211029 detail.viewerPermissions.actions.includes(action);
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.
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.
10241032 const primaryAction =
10251033 detail === null || detail.state !== "open"
10261034 ? null
1027 : detail.isDraft && can("ready")
1028 ? "ready"
1029 : !can("merge")
1030 ? null
1031 : conflicting
1032 ? "resolve"
1035 : conflicting
1036 ? "resolve"
1037 : detail.isDraft && can("ready")
1038 ? "ready"
1039 : !can("merge")
1040 ? null
10331041 : allowedMergeMethods.length > 0
10341042 ? "merge"
10351043 : null;
10361044 // The pull request number carries this state in the overview and the right-panel tab mirrors
1037 // it. Conflicts keep their own row below: an open pull request remains green there.
1045 // it. The conflict action is separate from this state: an open pull request remains green.
10381046 const statePresentation = detail
10391047 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
10401048 : null;
10411049 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
10421050 const checksState = detail ? pullRequestChecksState(detail.checks) : null;
10431051
1052 if (detailQuery.isPending && !detail) {
1053 return <PullRequestDetailGhost />;
1054 }
1055
10441056 return (
10451057 <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. */}
10491058 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
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">
1059 <div className="ml-4 grid h-7 min-w-0 items-center">
10531060 <div
10541061 aria-hidden={condensed}
10551062 inert={condensed}
10561063 className={cn(
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.
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",
10601065 condensed
1061 ? "pointer-events-none opacity-0 duration-100"
1062 : "opacity-100 delay-75 duration-150",
1066 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1067 : "translate-y-0 opacity-100 delay-50 duration-150",
10631068 )}
10641069 >
10651070 {detail && statePresentation ? (
10661071 <>
1067 <span className="min-w-0 truncate" title={detail.repository}>
1068 {detail.repository}
1069 </span>
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 )}
10701086 <button
10711087 type="button"
10721088 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
......@@ -1087,10 +1103,10 @@ export function PullRequestDetailPanel({
10871103 aria-hidden={!condensed}
10881104 inert={!condensed}
10891105 className={cn(
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",
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",
10911107 condensed
1092 ? "opacity-100 delay-75 duration-150"
1093 : "pointer-events-none opacity-0 duration-100",
1108 ? "translate-y-0 opacity-100 delay-50 duration-150"
1109 : "pointer-events-none translate-y-1 opacity-0 duration-100",
10941110 )}
10951111 >
10961112 {detail && statePresentation ? (
......@@ -1109,30 +1125,14 @@ export function PullRequestDetailPanel({
11091125 >
11101126 #{detail.number}
11111127 </button>
1112 <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
1128 <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
11131129 {detail.title}
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}
1130 </h1>
11311131 </>
11321132 ) : null}
11331133 </div>
11341134 </div>
1135 <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
1135 <div className="mr-4 flex h-7 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="size-6"
1143 className="order-last size-6"
11441144 size="icon-xs"
11451145 variant="ghost-muted"
11461146 />
......@@ -1312,16 +1312,15 @@ export function PullRequestDetailPanel({
13121312 <MenuTrigger
13131313 disabled={handoff !== null}
13141314 render={
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 )}
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>
13251324 </Button>
13261325 }
13271326 />
......@@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({
13671366 Auto-merge
13681367 </Badge>
13691368 ) : null}
1370 {primaryAction === "ready" ? (
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" ? (
13711385 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
13721386 Ready for review
13731387 </Button>
......@@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({
13941408 ) : null}
13951409 </div>
13961410
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 >
1411 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
14101412 <div
14111413 ref={condensedRowRef}
14121414 className={cn(
1413 "min-h-0 overflow-hidden",
1415 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
14141416 condensed
1415 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
1416 : "opacity-0",
1417 ? "translate-y-0 opacity-100 delay-50"
1418 : "translate-y-1 opacity-0 duration-100",
14171419 )}
14181420 inert={!condensed}
14191421 >
14201422 {detail ? (
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 )}
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 }`}
14361462 >
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"
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]"
14531470 />
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()}
14621471 </span>
1463 <PullRequestDiffStat
1464 additions={detail.additions}
1465 deletions={detail.deletions}
1466 className="shrink-0 font-mono text-[11px]"
1467 />
1468 </span>
1472 </div>
14691473 </div>
14701474 ) : null}
14711475 </div>
14721476 </div>
14731477
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 >
1478 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}>
14891479 <div
14901480 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.
14941481 className={cn(
1495 "min-h-0 overflow-hidden",
1482 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
14961483 condensed
1497 ? "opacity-0"
1498 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",
1484 ? "-translate-y-1 opacity-0 duration-100"
1485 : "translate-y-0 opacity-100 delay-50",
14991486 )}
15001487 inert={condensed}
15011488 >
15021489 {detail ? (
1503 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">
1490 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
15041491 {titleDraft === null ? (
15051492 <div className="group flex min-w-0 items-start gap-1">
15061493 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
......@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({
15671554 </PullRequestMetaLine>
15681555
15691556 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
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}
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}`}
15731560 >
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)}
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"
15821578 />
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}
1598 >
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 )}
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)}
16071587 >
1608 Copied
1609 </span>
1610 </button>
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>
16111607 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
16121608 <span className="inline-flex items-center gap-1.5 tabular-nums">
16131609 <FileDiffIcon className="size-3.5" />
......@@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({
16231619 </div>
16241620 </div>
16251621 ) : null}
1622 </div>
1623 </div>
16261624
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"
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)}
16321639 >
1633 <TriangleAlertIcon className="size-3.5" />
1634 Merge conflicts
1635 </Badge>
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 )}
1663 >
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>
16361699 <Button
16371700 size="xs"
16381701 variant="ghost"
1639 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"
1640 disabled={handoff !== null}
1641 onClick={startResolveConflicts}
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 }
16421711 >
1643 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}
1644 <ArrowUpRightIcon className="size-3.5 text-destructive" />
1712 <ArrowDownUpIcon aria-hidden className="size-3" />
1713 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
16451714 </Button>
16461715 </div>
16471716 ) : 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>
1717 </nav>
1718 ) : null}
17491719 </div>
17501720
17511721 <div
17521722 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.
17561723 onScrollCapture={(event) => {
1757 if (chromeVariant !== "collapse") return;
17581724 const scroller = event.target as HTMLElement;
17591725 scrollerRef.current = scroller;
17601726 const top = scroller.scrollTop;
17611727 setChromeCondensed((previous) => {
17621728 let next = previous;
1763 // `scrollHeight` reads the fold's natural height whichever state the track is in.
17641729 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.
17671730 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
17681731 if (previous) {
17691732 // The hard top reopens the chrome with no refund: the reader asked for the top,
......@@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({
17811744 });
17821745 }}
17831746 >
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 ? (
1747 {detailQuery.error && !detail ? (
17951748 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
17961749 ) : detail ? (
17971750 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+91-24
......@@ -45,13 +45,11 @@ 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
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
48 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
5149 </div>
5250 <div className="flex flex-col items-end gap-1.5">
5351 <GhostBar className="w-12" />
54 <GhostBar className="w-16 bg-muted-foreground/10" />
52 <GhostBar className="w-16" />
5553 </div>
5654 </div>
5755 ))}
......@@ -59,32 +57,101 @@ export function PullRequestListGhost({
5957 );
6058}
6159
62/** The summary's own shape: a title, a byline, the facts rows, the description. */
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 */
6365export function PullRequestDetailGhost() {
6466 return (
6567 <div
6668 role="status"
6769 aria-label="Loading pull request"
68 className="animate-ghost-pulse space-y-6 px-4 py-5"
70 className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background"
6971 >
70 <div className="space-y-2">
71 <GhostBar className="h-5 w-4/5" />
72 <GhostBar className="w-2/5 bg-muted-foreground/10" />
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>
73109 </div>
74 <div className="space-y-3">
75 {Array.from({ length: 4 }, (_, index) => (
76 <div key={index} className="flex items-center gap-3">
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" />
77146 <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]} />
80147 </div>
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" />
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>
88155 </div>
89156 </div>
90157 );
......@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
113180 <div key={index} className="relative pb-5">
114181 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
115182 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
116 <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" />
183 <GhostBar className="mt-1.5 w-16" />
117184 </div>
118185 ))}
119186 </div>
......@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
134201 <GhostBar className="size-5 shrink-0 rounded-full" />
135202 <div className="flex-1 space-y-1.5">
136203 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
137 <GhostBar className="w-full bg-muted-foreground/10" />
138 <GhostBar className="w-3/4 bg-muted-foreground/10" />
204 <GhostBar className="w-full" />
205 <GhostBar className="w-3/4" />
139206 </div>
140207 </div>
141208 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+9-6
......@@ -25,6 +25,7 @@ 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";
2829
2930import {
3031 Menu,
......@@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({
261262 return (
262263 <Menu>
263264 <MenuTrigger
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"
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 }
270273 >
271274 <ListFilterIcon className="size-4" />
272275 {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="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">
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">
201201 {icon}
202202 {label}
203203 </span>
204 <span className="min-w-0 flex-1 text-foreground">{children}</span>
204 <span className="min-w-0 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 className="max-w-6xl gap-10">
995 <SettingsPageContainer width="expanded" className="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 className="max-w-5xl">
1198 <SettingsPageContainer width="wide">
11991199 <SettingsSection
12001200 {...searchableSetting("keybindings")}
12011201 headerAction={
apps/web/src/components/settings/ProjectSettingsPanel.tsx+4-22
......@@ -81,8 +81,6 @@ import {
8181 type NewProjectScriptInput,
8282 type ProjectScriptEditorRequest,
8383} from "../projectScriptEditor";
84import { cn } from "../../lib/utils";
85import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
8684import { Button } from "../ui/button";
8785import { Input } from "../ui/input";
8886import {
......@@ -102,6 +100,7 @@ import {
102100 WorkspaceBreadcrumbItem,
103101 WorkspaceBreadcrumbSeparator,
104102} from "../WorkspaceBreadcrumb";
103import { WorkspacePageHeader } from "../WorkspacePageContainer";
105104import {
106105 SettingResetButton,
107106 SettingsPageContainer,
......@@ -174,26 +173,9 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
174173 return (
175174 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
176175 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
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 )}
176 <WorkspacePageHeader electron={isElectron}>
177 <ProjectSettingsBreadcrumb projectKey={projectKey} />
178 </WorkspacePageHeader>
197179 <ProjectSettingsPanel projectKey={projectKey} />
198180 </div>
199181 </SidebarInset>
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-22
......@@ -9,7 +9,6 @@ import {
99} from "react";
1010import {
1111 ArchiveIcon,
12 ArrowLeftIcon,
1312 BotIcon,
1413 GitBranchIcon,
1514 KeyboardIcon,
......@@ -19,7 +18,7 @@ import {
1918 Settings2Icon,
2019 XIcon,
2120} from "lucide-react";
22import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
21import { useLocation, useNavigate } from "@tanstack/react-router";
2322
2423import { Button } from "../ui/button";
2524import { Input } from "../ui/input";
......@@ -34,6 +33,7 @@ import {
3433 useSidebar,
3534} from "../ui/sidebar";
3635import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
36import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
3737import { scrollToSettingsTarget } from "./settingsLayout";
3838import {
3939 searchSettings,
......@@ -72,7 +72,6 @@ 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();
7675 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
7776 const searchInputRef = useRef<HTMLInputElement>(null);
7877 const [query, setQuery] = useState("");
......@@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
176175 },
177176 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
178177 );
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
190178 return (
191179 <>
192180 <SidebarContent className="overflow-x-hidden">
......@@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
296284 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
297285 <T3ConnectSidebarSignIn />
298286 <div className="flex items-center gap-1">
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>
287 <SidebarUtilityMenu />
307288 <T3ConnectSidebarAvatar />
308289 </div>
309290 </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%, 17rem), 1fr))" }}
771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
772772 >
773773 {STANDARD_THEME_CARDS.map((standardTheme) => (
774774 <ThemeLibraryCard
apps/web/src/components/settings/settingsLayout.tsx+6-3
......@@ -12,6 +12,7 @@ import {
1212} from "react";
1313
1414import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
1516import { Button } from "../ui/button";
1617import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1718
......@@ -233,9 +234,11 @@ export function SettingResetButton({
233234export function SettingsPageContainer({
234235 children,
235236 className,
237 width = "readable",
236238}: {
237239 children: ReactNode;
238240 className?: string;
241 width?: WorkspacePageWidth;
239242}) {
240243 const navigate = useNavigate();
241244 const hash = useLocation({ select: (location) => location.hash });
......@@ -247,12 +250,12 @@ export function SettingsPageContainer({
247250 return (
248251 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
249252 <div
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"
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]"
251254 data-settings-page-scroll
252255 >
253 <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}>
256 <WorkspacePageContainer width={width} className={cn("gap-12", className)}>
254257 {children}
255 </div>
258 </WorkspacePageContainer>
256259 </div>
257260 </SettingsSearchTargetProvider>
258261 );
apps/web/src/components/sidebar/SidebarChrome.tsx+78-68
......@@ -4,8 +4,9 @@ import {
44 GitPullRequestIcon,
55 SettingsIcon,
66} from "lucide-react";
7import type { ReactNode } from "react";
78import { memo, useCallback } from "react";
8import { Link, useLocation, useNavigate } from "@tanstack/react-router";
9import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
910
1011import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
1112import { cn } from "../../lib/utils";
......@@ -117,16 +118,44 @@ function T3Wordmark() {
117118 );
118119}
119120
120export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
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() {
121147 const navigate = useNavigate();
148 const canGoBack = useCanGoBack();
122149 const { isMobile, setOpenMobile } = useSidebar();
123150 const currentFooterPage = useLocation({
124151 select: (location) =>
125 location.pathname === "/usage"
126 ? "usage"
127 : location.pathname === "/pull-requests"
128 ? "pull-requests"
129 : null,
152 /^\/settings(?:\/|$)/.test(location.pathname)
153 ? "settings"
154 : location.pathname === "/usage"
155 ? "usage"
156 : location.pathname === "/pull-requests"
157 ? "pull-requests"
158 : null,
130159 });
131160 const { environments } = useEnvironments();
132161 // The page reads every connected server, so one of them offering pull requests is enough for
......@@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
157186
158187 const handleBackClick = useCallback(() => {
159188 closeMobileSidebar();
189 if (canGoBack) {
190 window.history.back();
191 return;
192 }
160193 void navigate({ to: "/" });
161 }, [closeMobileSidebar, navigate]);
194 }, [canGoBack, closeMobileSidebar, navigate]);
162195
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});
230
231export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
163232 return (
164233 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
165234 <SidebarProviderUpdatePill />
166235 <SidebarUpdateArchitectureWarning />
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>
236 <SidebarUtilityMenu />
227237 </SidebarFooter>
228238 );
229239});
apps/web/src/components/threadActionMenu.logic.test.ts+10-4
......@@ -19,6 +19,12 @@ 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
2228describe("buildThreadActionMenuItems", () => {
2329 it("hides lifecycle items when the environment lacks the capabilities", () => {
2430 expect(
......@@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => {
2632 ...baseState,
2733 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
2834 }),
29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);
35 ).toEqual(["rename", "mark-unread", "copy", "delete"]);
3036 });
3137
3238 it("includes branch items only for threads with a branch", () => {
33 const withBranch = ids({ ...baseState, branch: "feat/menu" });
39 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
3440 expect(withBranch).toContain("new-thread-on-branch");
3541 expect(withBranch).toContain("copy-branch");
36 expect(ids(baseState)).not.toContain("new-thread-on-branch");
37 expect(ids(baseState)).not.toContain("copy-branch");
42 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
43 expect(allIds(baseState)).not.toContain("copy-branch");
3844 });
3945
4046 it("flips lifecycle labels with thread state", () => {
apps/web/src/components/threadActionMenu.logic.ts+31-11
......@@ -18,6 +18,7 @@ export type ThreadActionMenuId =
1818 | "rename"
1919 | "regenerate-title"
2020 | "mark-unread"
21 | "copy"
2122 | "copy-path"
2223 | "copy-branch"
2324 | "copy-thread-id"
......@@ -53,14 +54,15 @@ export function buildThreadActionMenuItems(
5354 {
5455 id: "new-thread-on-branch" as const,
5556 label: `New thread on ${state.branch}`,
57 icon: "message-square-plus",
5658 },
5759 ]
5860 : []),
5961 ...(state.supports.pinning
6062 ? [
6163 state.isPinned
62 ? { id: "unpin" as const, label: "Unpin thread" }
63 : { id: "pin" as const, label: "Pin thread" },
64 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
65 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
6466 ]
6567 : []),
6668 // Both lifecycle actions stay available on pinned threads: settling
......@@ -69,17 +71,18 @@ export function buildThreadActionMenuItems(
6971 ...(state.supports.settlement
7072 ? [
7173 state.isSettled
72 ? { id: "unsettle" as const, label: "Un-settle thread" }
73 : { id: "settle" as const, label: "Settle thread" },
74 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
75 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
7476 ]
7577 : []),
7678 ...(state.supports.snooze
7779 ? [
7880 state.isSnoozed
79 ? { id: "unsnooze" as const, label: "Wake thread" }
81 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
8082 : {
8183 id: "snooze" as const,
8284 label: "Snooze",
85 icon: "clock",
8386 disabled: !state.canSnoozeNow,
8487 children: state.snoozePresets.map((preset) => ({
8588 id: `snooze:${preset.id}` as const,
......@@ -88,20 +91,37 @@ export function buildThreadActionMenuItems(
8891 },
8992 ]
9093 : []),
91 { id: "rename", label: "Rename thread" },
94 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
9295 ...(state.supports.titleRegeneration
9396 ? [
9497 {
9598 id: "regenerate-title" as const,
9699 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
100 icon: "refresh-cw",
97101 disabled: state.isRegeneratingTitle,
98102 },
99103 ]
100104 : []),
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" },
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 },
106126 ];
107127}
apps/web/src/components/ui/segmented-tabs.tsx created+40
......@@ -0,0 +1,40 @@
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,6 +18,10 @@ 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)]",
2125 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2226 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2327 },
......@@ -27,6 +31,8 @@ const toggleVariants = cva(
2731 "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",
2832 outline:
2933 "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",
3036 },
3137 },
3238 },
apps/web/src/components/usage/UsagePage.tsx+150-243
......@@ -19,13 +19,22 @@ import {
1919 formatUsd,
2020 makeWindow,
2121} from "@t3tools/shared/usageFormat";
22import { ScrollArea } from "../ui/scroll-area";
2322import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
2424import { SidebarInset } from "../ui/sidebar";
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";
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";
2938
3039const WINDOW_OPTIONS = [
3140 { days: 1, label: "Past 24h" },
......@@ -66,21 +75,6 @@ export function UsagePage() {
6675 [isPast24Hours, merged.daily, merged.hourly],
6776 );
6877
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;
8478 const selectWindow = (days: number) => {
8579 setWindowSelection({
8680 days,
......@@ -100,78 +94,66 @@ export function UsagePage() {
10094 setWindowSelection({ days: windowDays, window: nextWindow });
10195 }
10296 };
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 );
103143
104144 return (
105145 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
106146 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
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 )}
147 <WorkspacePageHeader electron={isElectron} className="h-auto py-2">
148 {topbarContent}
149 </WorkspacePageHeader>
132150
133151 <ScrollArea className="min-h-0 flex-1">
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
152 <WorkspacePageContainer width="wide">
171153 {settling ? (
172154 <>
173155 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
174 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
156 <UsageSkeleton />
175157 </>
176158 ) : (
177159 <>
......@@ -181,88 +163,62 @@ export function UsagePage() {
181163 staleEnvironments={merged.staleEnvironments}
182164 />
183165
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">
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">
189168 <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>
193169 <span className="text-4xl font-semibold text-foreground tabular-nums">
194170 {metric === "cost"
195 ? `${formatUsd(merged.costUsd)}*`
171 ? formatUsd(merged.costUsd)
196172 : formatTokens(merged.totalTokens)}
197173 </span>
198174 <span className="text-xs text-muted-foreground">
199175 {metric === "cost"
200 ? "* if billed at full API rate"
201 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
176 ? `${formatCount(merged.sessions)} sessions · API estimate`
177 : `${formatCount(merged.sessions)} sessions`}
202178 </span>
203179 </div>
204180
205 {orderedProviders.map((provider) => {
206 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
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 }`;
207189 return (
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]}
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>
213200 </span>
214 <span className="text-sm text-foreground tabular-nums">
201 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
215202 {metric === "cost"
216 ? formatUsd(provider.costUsd)
217 : formatTokens(provider.totalTokens)}
203 ? formatUsd(totals?.costUsd ?? 0)
204 : formatTokens(totals?.totalTokens ?? 0)}
218205 </span>
219206 </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>
229207 <span className="text-xs text-muted-foreground">
230208 {metric === "cost"
231 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
232 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
209 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
210 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
233211 </span>
234212 </div>
235213 );
236214 })}
237215 </div>
238216
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>
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>
266222 <UsageProviderChart
267223 days={days}
268224 daily={merged.daily}
......@@ -276,63 +232,42 @@ export function UsagePage() {
276232 </div>
277233 </section>
278234
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 />
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>
309250 </section>
310251
311252 <section className="flex flex-col gap-3">
312253 <div className="flex items-center justify-between gap-3">
313254 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
314 <div className="flex overflow-hidden rounded-md border border-border">
255 <SegmentedTabList aria-label="Usage breakdown">
315256 {(
316257 [
317 { value: "model", label: "model" },
318 { value: "time", label: isPast24Hours ? "hour" : "day" },
258 { value: "model", label: "Model" },
259 { value: "time", label: isPast24Hours ? "Hour" : "Day" },
319260 ] as const
320261 ).map((option) => (
321 <button
262 <SegmentedTab
322263 key={option.value}
323 type="button"
264 selected={option.value === breakdown}
324265 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 )}
331266 >
332267 {option.label}
333 </button>
268 </SegmentedTab>
334269 ))}
335 </div>
270 </SegmentedTabList>
336271 </div>
337272
338273 {breakdown === "model" ? (
......@@ -356,7 +291,7 @@ export function UsagePage() {
356291 merged.models.map((model) => (
357292 <tr
358293 key={`${model.provider}:${model.model}`}
359 className="border-b border-border/50"
294 className="hover:bg-muted/20"
360295 >
361296 <td className="py-2 text-foreground">
362297 <span className="flex items-center gap-2">
......@@ -403,7 +338,7 @@ export function UsagePage() {
403338 recentPeriods.map((period) => (
404339 <tr
405340 key={"hourStart" in period ? period.hourStart : period.day}
406 className="border-b border-border/50"
341 className="hover:bg-muted/20"
407342 >
408343 <td className="py-2 text-foreground">
409344 {"hourStart" in period
......@@ -433,7 +368,7 @@ export function UsagePage() {
433368 </section>
434369 </>
435370 )}
436 </div>
371 </WorkspacePageContainer>
437372 </ScrollArea>
438373 </div>
439374 </SidebarInset>
......@@ -452,20 +387,11 @@ function ProviderMark({
452387 return <Mark className={cn("shrink-0", className)} aria-hidden />;
453388}
454389
455function Metric({
456 label,
457 value,
458 detail,
459}: {
460 readonly label: string;
461 readonly value: string;
462 readonly detail: string;
463}) {
390function Metric({ label, value }: { readonly label: string; readonly value: string }) {
464391 return (
465 <div className="flex flex-col gap-0.5 bg-background px-4 py-3">
392 <div className="flex min-w-0 flex-col gap-0.5">
466393 <span className="text-xs text-muted-foreground">{label}</span>
467 <span className="text-lg text-foreground tabular-nums">{value}</span>
468 <span className="text-xs text-muted-foreground">{detail}</span>
394 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
469395 </div>
470396 );
471397}
......@@ -569,70 +495,51 @@ function UsageDeviceStrip({
569495 );
570496}
571497
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
575498/**
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.
499 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
500 * exactly once when the last device answers.
579501 */
580function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
502function UsageSkeleton() {
581503 return (
582504 <>
583 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
505 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
584506 <div className="flex flex-col gap-5">
585507 <div className="flex flex-col gap-1">
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" />
508 <div className="h-8 w-36 rounded-sm bg-muted" />
509 <div className="h-3 w-32 rounded-sm bg-muted" />
591510 </div>
592
593511 {PROVIDER_ORDER.map((provider) => (
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">
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">
597515 <ProviderMark provider={provider} className="size-4" />
598 {PROVIDER_LABEL[provider]}
516 <div className="h-3.5 w-20 rounded-sm bg-muted" />
599517 </span>
600518 <div className="h-3.5 w-14 rounded-sm bg-muted" />
601519 </div>
602 <div className="h-1 w-full rounded-full bg-muted" />
603520 <div className="h-3 w-36 rounded-sm bg-muted" />
604521 </div>
605522 ))}
606523 </div>
607524
608525 <div className="flex flex-col gap-3">
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>
526 <div className="h-4 w-24 rounded-sm bg-muted" />
527 <div className="h-56 rounded-sm bg-muted/35" />
623528 </div>
624529 </section>
625530
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 )}
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>
636543 </section>
637544 </>
638545 );
apps/web/src/components/usage/UsageProviderChart.tsx+72-65
......@@ -1,5 +1,5 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";
2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
33
44import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
55import {
......@@ -68,13 +68,7 @@ function buildPeriodColumns(
6868 });
6969}
7070
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 */
71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
7872function monotoneTangents(points: readonly Point[]): readonly number[] {
7973 const count = points.length;
8074 if (count < 2) return [0];
......@@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
115109 return tangents;
116110}
117111
118/** One cubic segment of a smoothed boundary. */
119112interface CurveSegment {
120113 readonly from: Point;
121114 readonly c1: Point;
......@@ -123,7 +116,6 @@ interface CurveSegment {
123116 readonly to: Point;
124117}
125118
126/** Smoothed polyline through `points`, as explicit cubic control points. */
127119function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
128120 if (points.length < 2) return [];
129121 const tangents = monotoneTangents(points);
......@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144136 return segments;
145137}
146138
147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
139function curvePath(segments: readonly CurveSegment[]): string {
148140 const first = segments[0];
149141 if (first === undefined) return "";
150 let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
142 let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
151143 for (const segment of segments) {
152144 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)}`;
153145 }
......@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179171/**
180172 * Turns the merged daily totals into one column per day.
181173 *
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.
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.
186176 *
187177 * The chart paths and the hover readout both consume this, so the number under
188178 * the cursor is by construction the number that was plotted rather than a
......@@ -216,42 +206,39 @@ export function UsageProviderChart({
216206 );
217207 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
218208 const plotRef = useRef<HTMLDivElement | null>(null);
209 const tooltipRef = useRef<HTMLDivElement | null>(null);
210 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
219211
220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
212 const { paths, series, stepX, ticks, toY } = useMemo(() => {
221213 if (periods.length === 0) {
222214 return {
223215 paths: [],
224 ticks: [0] as readonly number[],
216 series: [] as readonly DayColumn[],
225217 stepX: 0,
218 ticks: [0] as readonly number[],
226219 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
228220 };
229221 }
230222
231223 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.
236224 const peak = columns.reduce(
237225 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238226 0,
239227 );
240228 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241229 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.
244230 const toY = (value: number) =>
245231 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246232
247233 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
248 const curve = smoothCurve(
249 columns.map((column, dayIndex) => ({
250 x: dayIndex * step,
251 y: toY(column.bands[providerIndex]?.value ?? 0),
252 })),
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 ),
253241 );
254 const line = curvePath(curve, "M");
255242 return {
256243 provider,
257244 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
......@@ -260,30 +247,65 @@ export function UsageProviderChart({
260247 };
261248 });
262249
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 };
250 return {
251 paths: built.toSorted((a, b) => b.total - a.total),
252 series: columns,
253 stepX: step,
254 ticks: tickValues,
255 toY,
256 };
269257 }, [byPeriod, metric, periods]);
270258
271259 const format = metric === "tokens" ? formatTokens : formatUsd;
272260
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
273290 const handleMove = useCallback(
274291 (event: React.MouseEvent<HTMLDivElement>) => {
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;
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;
278299 const index = Math.round(fraction * (periods.length - 1));
300 hoverPositionRef.current = { x: localX, y: localY };
301 positionTooltip();
279302 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280303 },
281 [periods.length],
304 [periods.length, positionTooltip],
282305 );
283306
284307 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285308 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287309 const formatPeriod = (period: string) =>
288310 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289311 const formatTooltipPeriod = (period: string) =>
......@@ -311,7 +333,10 @@ export function UsageProviderChart({
311333 ref={plotRef}
312334 className="relative h-56 flex-1"
313335 onMouseMove={handleMove}
314 onMouseLeave={() => setHoverIndex(null)}
336 onMouseLeave={() => {
337 hoverPositionRef.current = null;
338 setHoverIndex(null);
339 }}
315340 >
316341 <svg
317342 className="h-full w-full"
......@@ -337,7 +362,6 @@ export function UsageProviderChart({
337362 );
338363 })}
339364
340 {/* Fills first, then every stroke, so no series covers another's line. */}
341365 {paths.map(({ provider, area }) => (
342366 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
343367 ))}
......@@ -368,10 +392,11 @@ export function UsageProviderChart({
368392
369393 {hoveredPeriod === undefined ? null : (
370394 <div
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"
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"
372397 style={{
373 left: `${hoverLeft}%`,
374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
398 left: "var(--usage-tooltip-left, 0px)",
399 top: "var(--usage-tooltip-top, 0px)",
375400 }}
376401 >
377402 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
......@@ -418,21 +443,3 @@ export function UsageProviderChart({
418443 </div>
419444 );
420445}
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+1-3
......@@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts";
33import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
55/**
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.
6 * Stable provider reading order across summaries, tables, and hover rows.
97 */
108export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
119
apps/web/src/contextMenuFallback.ts+92-6
......@@ -4,6 +4,15 @@ 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 ],
716 pencil: [
817 {
918 tag: "path",
......@@ -17,6 +26,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
1726 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
1827 { 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" } },
1928 ],
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 ],
2094 "folder-tree": [
2195 {
2296 tag: "path",
......@@ -44,7 +118,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
44118
45119function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
46120 const paths = ICON_PATHS[name];
47 if (!paths) {
121 if (!paths || typeof document.createElementNS !== "function") {
48122 return null;
49123 }
50124 const svg = document.createElementNS(SVG_NS, "svg");
......@@ -57,7 +131,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
57131 svg.setAttribute("stroke-linejoin", "round");
58132 svg.setAttribute(
59133 "class",
60 tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
134 tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground",
61135 );
62136 for (const node of paths) {
63137 const child = document.createElementNS(SVG_NS, node.tag);
......@@ -200,6 +274,16 @@ export function showContextMenuFallback<T extends string>(
200274 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
201275
202276 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
203287 if (item.header === true) {
204288 const header = document.createElement("div");
205289 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
......@@ -247,10 +331,12 @@ export function showContextMenuFallback<T extends string>(
247331 button.appendChild(label);
248332
249333 if (hasChildren) {
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);
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 }
254340 }
255341
256342 if (!isDisabled) {
apps/web/src/index.css+78-5
......@@ -241,6 +241,22 @@ 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 }
244260 @keyframes status-ping {
245261 /* Burst first (immediate feedback for click ripples), then hold
246262 invisible for the rest of the cycle. Mirrors animate-ping's
......@@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
431447 }
432448}
433449
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
434506@layer base {
435507 :root {
436508 /* Keep the original T3 Code artwork palettes as the defaults. Built-in
......@@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] {
13491421
13501422/* The panel layout toggles stay ghost: they render both inside the header and
13511423 in the titlebar strip, so filling them would make them change appearance as
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. */
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. */
13551428html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
13561429html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
13571430html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
13581431html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1359 --control-icon-color: var(--toolbar-foreground);
1360 color: var(--toolbar-foreground);
1432 --control-icon-color: var(--toolbar-control-foreground);
1433 color: var(--toolbar-control-foreground);
13611434}
13621435
13631436html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
apps/web/src/lib/openPullRequestLink.ts+11
......@@ -118,6 +118,17 @@ 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
121132function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
122133 const repository = match?.[1];
123134 const number = Number(match?.[2]);
apps/web/src/routes/-chatIndexTitlebar.test.ts+1-3
......@@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => {
1515
1616 const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd);
1717
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");
18 expect(onboardingHeader).toContain('<WorkspacePageHeader className="border-b border-border">');
2119 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);
2220 });
2321});
apps/web/src/routes/_chat.index.tsx+3-9
......@@ -8,6 +8,7 @@ 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";
1112import { useNewThreadHandler } from "../hooks/useHandleNewThread";
1213import {
1314 useAllEnvironmentShellsBootstrapped,
......@@ -17,8 +18,6 @@ import {
1718import { useEnvironments } from "../state/environments";
1819import { APP_DISPLAY_NAME } from "~/branding";
1920import { hasCloudPublicConfig } from "~/cloud/publicConfig";
20import { cn } from "~/lib/utils";
21import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
2221
2322function ChatIndexRouteView() {
2423 const { authGateState } = Route.useRouteContext();
......@@ -143,18 +142,13 @@ function HostedStaticOnboardingState() {
143142 return (
144143 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
145144 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
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 >
145 <WorkspacePageHeader className="border-b border-border">
152146 <div className="flex items-center gap-2">
153147 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
154148 {APP_DISPLAY_NAME}
155149 </span>
156150 </div>
157 </header>
151 </WorkspacePageHeader>
158152
159153 <Empty className="flex-1">
160154 <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+66-37
......@@ -74,6 +74,12 @@ import {
7474 WorkspaceBreadcrumbItem,
7575 WorkspaceBreadcrumbSeparator,
7676} from "../components/WorkspaceBreadcrumb";
77import {
78 WorkspacePageContainer,
79 WorkspacePageHeader,
80 WorkspacePageHeaderEdgeControl,
81} from "../components/WorkspacePageContainer";
82import { isElectron } from "../env";
7783import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
7884import { Button } from "../components/ui/button";
7985import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
......@@ -99,7 +105,6 @@ import {
99105import { useAtomCommand } from "../state/use-atom-command";
100106import { cn } from "~/lib/utils";
101107import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
102import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
103108
104109export interface PullRequestsSearch {
105110 readonly involvement: PullRequestInvolvement;
......@@ -1181,6 +1186,17 @@ function PullRequestsRouteView() {
11811186 : null,
11821187 [search.number, search.repository, selectedProject],
11831188 );
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;
11841200 useEffect(() => {
11851201 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
11861202 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
......@@ -1294,9 +1310,10 @@ function PullRequestsRouteView() {
12941310 terminalAvailable={false}
12951311 terminalOpen={false}
12961312 terminalShortcutLabel={null}
1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}
1313 rightPanelAvailable={rightPanelAvailable}
12981314 rightPanelOpen={rightPanelState.isOpen}
12991315 rightPanelShortcutLabel={null}
1316 rightPanelUnavailableLabel="Select a pull request first"
13001317 liveAgentCount={0}
13011318 onToggleTerminal={() => undefined}
13021319 onToggleRightPanel={toggleRightPanel}
......@@ -1603,7 +1620,6 @@ function PullRequestsRouteView() {
16031620 reviewingQuery.refresh();
16041621 }}
16051622 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
16071623 />
16081624 </RightPanelTabs>
16091625 ) : null}
......@@ -1827,18 +1843,10 @@ function PullRequestsColumn({
18271843 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
18281844 // content surface that lets it show reads as a different background than every thread.
18291845 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
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 >
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}>
18421850 {condensed ? (
18431851 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
18441852 {/* The page name remains the foreground anchor in both states; the live filters are
......@@ -1880,27 +1888,24 @@ function PullRequestsColumn({
18801888 )}
18811889 <div className="min-w-0 flex-1" />
18821890 {condensed ? (
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 />
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>
18931907 ) : null}
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>
1908 </WorkspacePageHeader>
19041909
19051910 <div
19061911 ref={scrollRef}
......@@ -1909,19 +1914,43 @@ function PullRequestsColumn({
19091914 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
19101915 settings page makes: at rest the controls sit fully below the mask, and only
19111916 content actually passing under the chrome fades. */}
1912 <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
1917 <WorkspacePageContainer className="gap-4">
19131918 <div className="flex flex-col gap-3">
19141919 <div ref={inFlowSearchRef} className="flex items-center gap-2">
19151920 {searchInput}
19161921 {filtersMenu}
1922 {!condensed ? (
1923 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1924 ) : null}
19171925 </div>
19181926 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
19191927 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
19201928 </div>
19211929
19221930 {listBody}
1923 </div>
1931 </WorkspacePageContainer>
19241932 </div>
19251933 </div>
19261934 );
19271935}
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+10-36
......@@ -13,9 +13,8 @@ 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";
1617import { isElectron } from "../env";
17import { cn } from "~/lib/utils";
18import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1918
2019function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
2120 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
......@@ -72,41 +71,16 @@ function SettingsContentLayout() {
7271 return (
7372 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
7473 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
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>
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}
10882 </div>
109 )}
83 </WorkspacePageHeader>
11084
11185 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
11286 <Outlet />
apps/web/src/session-logic.test.ts+127-6
......@@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => {
722722});
723723
724724describe("deriveWorkLogEntries", () => {
725 it("omits tool started entries and keeps completed entries", () => {
725 it("shows a command from its start event while it is still running", () => {
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 }),
727784 makeActivity({
728785 id: "tool-complete",
729786 createdAt: "2026-02-23T00:00:03.000Z",
730 summary: "Tool call complete",
787 summary: "Command run",
731788 kind: "tool.completed",
789 payload: {
790 itemType: "command_execution",
791 toolCallId: "call-1",
792 status: "completed",
793 title: "Command run",
794 },
732795 }),
733796 makeActivity({
734 id: "tool-start",
797 id: "other-tool-complete",
798 createdAt: "2026-02-23T00:00:04.000Z",
799 summary: "Other command",
800 kind: "tool.completed",
801 payload: {
802 itemType: "command_execution",
803 toolCallId: "call-2",
804 status: "completed",
805 title: "Other command",
806 },
807 }),
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[] = [
830 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",
735839 createdAt: "2026-02-23T00:00:02.000Z",
736 summary: "Tool call",
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",
737853 kind: "tool.started",
854 payload: { itemType: "search", title: "Search", status: "inProgress" },
738855 }),
739856 ];
740857
741 const entries = deriveWorkLogEntries(activities);
742 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
858 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([
859 "unkeyed-start-1",
860 "keyed-start",
861 "unkeyed-start-2",
862 ]);
743863 });
744864
745865 it("omits task.started but shows task.progress and task.completed", () => {
......@@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => {
12391359 expect(entries).toHaveLength(1);
12401360 expect(entries[0]).toMatchObject({
12411361 id: "grep-complete",
1362 toolCallId: "tool-grep-1",
12421363 toolTitle: "grep",
12431364 detail: "19 files",
12441365 itemType: "web_search",
apps/web/src/session-logic.ts+98-7
......@@ -65,6 +65,8 @@ 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;
6870 label: string;
6971 detail?: string;
7072 command?: string;
......@@ -748,7 +750,6 @@ export function deriveWorkLogEntries(
748750 const ordered = [...activities].toSorted(compareActivitiesByOrder);
749751 const entries: DerivedWorkLogEntry[] = [];
750752 for (const activity of ordered) {
751 if (activity.kind === "tool.started") continue;
752753 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
753754 // which is the batch key (completions of background subagents arrive
754755 // under later synthetic turns and must not start new batches). They
......@@ -757,8 +758,13 @@ export function deriveWorkLogEntries(
757758 if (activity.kind === "task.updated") continue;
758759 if (activity.kind === "tool.progress") continue;
759760 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;
760765 if (activity.summary === "Checkpoint captured") continue;
761766 if (isPlanBoundaryToolActivity(activity)) continue;
767 if (isCodexTerminalInteractionActivity(activity)) continue;
762768 if (isAgentInternalActivity(activity)) continue;
763769 entries.push(toDerivedWorkLogEntry(activity));
764770 }
......@@ -769,7 +775,11 @@ export function deriveWorkLogEntries(
769775}
770776
771777function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
772 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
778 if (
779 activity.kind !== "tool.started" &&
780 activity.kind !== "tool.updated" &&
781 activity.kind !== "tool.completed"
782 ) {
773783 return false;
774784 }
775785
......@@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
780790 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
781791}
782792
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
783815function extractWorkLogToolLifecycleStatus(
784816 payload: Record<string, unknown> | null,
785817): WorkLogToolLifecycleStatus | undefined {
......@@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
878910 entry.toolCallId = toolCallId;
879911 }
880912 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
913 if (!toolLifecycleStatus && activity.kind === "tool.started") {
914 toolLifecycleStatus = "inProgress";
915 }
881916 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
882917 toolLifecycleStatus = "completed";
883918 }
......@@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
933968 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
934969}
935970
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
936982function collapseDerivedWorkLogEntries(
937983 entries: ReadonlyArray<DerivedWorkLogEntry>,
938984): DerivedWorkLogEntry[] {
......@@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries(
949995 // own turn splintered one batch into a stream of "Kicked off N subagents"
950996 // rows (live-test finding, thread 7ac7ef05).
951997 const groupKeyByTaskId = new Map<string, string>();
998 const toolLifecycleRowIndex = new Map<string, number>();
952999 for (const entry of entries) {
9531000 const isTaskRow =
9541001 entry.taskId !== undefined &&
......@@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries(
9931040 });
9941041 continue;
9951042 }
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 }
9961060 const previous = collapsed.at(-1);
9971061 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
998 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(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 }
9991071 continue;
10001072 }
10011073 collapsed.push(entry);
1074 if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") {
1075 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1076 }
10021077 }
10031078 return collapsed;
10041079}
......@@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries(
10071082 previous: DerivedWorkLogEntry,
10081083 next: DerivedWorkLogEntry,
10091084): boolean {
1010 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
1085 if (
1086 previous.activityKind !== "tool.started" &&
1087 previous.activityKind !== "tool.updated" &&
1088 previous.activityKind !== "tool.completed"
1089 ) {
10111090 return false;
10121091 }
1013 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
1092 if (
1093 next.activityKind !== "tool.started" &&
1094 next.activityKind !== "tool.updated" &&
1095 next.activityKind !== "tool.completed"
1096 ) {
10141097 return false;
10151098 }
10161099 if (previous.activityKind === "tool.completed") {
......@@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
10801163 ) {
10811164 return `task${entry.taskId}`;
10821165 }
1083 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
1166 if (
1167 entry.activityKind !== "tool.started" &&
1168 entry.activityKind !== "tool.updated" &&
1169 entry.activityKind !== "tool.completed"
1170 ) {
10841171 return undefined;
10851172 }
10861173 if (entry.toolCallId) {
......@@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
12831370 const item = asRecord(data?.item);
12841371 const itemResult = asRecord(item?.result);
12851372 const itemInput = asRecord(item?.input);
1373 const dataInput = asRecord(data?.input);
1374 const stateInput = asRecord(asRecord(data?.state)?.input);
12861375 const itemType = asTrimmedString(payload?.itemType);
12871376 const detail = asTrimmedString(payload?.detail);
12881377 const candidates: unknown[] = [
......@@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
12901379 itemInput?.command,
12911380 itemResult?.command,
12921381 data?.command,
1382 dataInput?.command,
1383 stateInput?.command,
12931384 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
12941385 ];
12951386
......@@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
13161407
13171408function extractToolCallId(payload: Record<string, unknown> | null): string | null {
13181409 const data = asRecord(payload?.data);
1319 return asTrimmedString(data?.toolCallId);
1410 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
13201411}
13211412
13221413function normalizeInlinePreview(value: string): string {
apps/web/src/terminalUiStateStore.test.ts+8
......@@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => {
1818 useTerminalUiStateStore.persist.clearStorage();
1919 useTerminalUiStateStore.setState({
2020 terminalUiStateByThreadKey: {},
21 terminalCustomLabelsByThreadKey: {},
2122 suppressedTerminalIdsByThreadKey: {},
2223 });
2324 });
......@@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => {
248249 it("reconciles terminal ids from an external ordered list", () => {
249250 const store = useTerminalUiStateStore.getState();
250251 store.setTerminalOpen(THREAD_REF, true);
252 store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server");
253 store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task");
251254 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
252255
253256 const terminalUiState = selectThreadTerminalUiState(
......@@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => {
260263 { id: "group-term-a", terminalIds: ["term-a"] },
261264 { id: "group-term-b", terminalIds: ["term-b"] },
262265 ]);
266 expect(
267 useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[
268 scopedThreadKey(THREAD_REF)
269 ],
270 ).toEqual({ "term-a": "API server" });
263271 });
264272
265273 it("does not import a closed panel terminal from stale metadata", () => {
apps/web/src/terminalUiStateStore.ts+154-16
......@@ -32,8 +32,11 @@ 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>>;
3536}
3637
38const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({});
39
3740export function migratePersistedTerminalUiStateStoreState(
3841 persistedState: unknown,
3942 _version: number,
......@@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState(
5053 parseScopedThreadKey(threadKey),
5154 ),
5255 );
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 );
5375
54 return { terminalUiStateByThreadKey };
76 return {
77 terminalUiStateByThreadKey,
78 ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0
79 ? { terminalCustomLabelsByThreadKey }
80 : {}),
81 };
5582}
5683
5784function createTerminalUiStateStorage() {
......@@ -489,6 +516,18 @@ export function selectThreadTerminalUiState(
489516 );
490517}
491518
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
492531function updateTerminalUiStateByThreadKey(
493532 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
494533 threadRef: ScopedThreadRef,
......@@ -562,6 +601,7 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st
562601
563602interface TerminalUiStateStoreState {
564603 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
604 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>;
565605 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */
566606 suppressedTerminalIdsByThreadKey: Record<string, string[]>;
567607 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
......@@ -575,6 +615,11 @@ interface TerminalUiStateStoreState {
575615 options?: { open?: boolean; active?: boolean },
576616 ) => void;
577617 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
618 setTerminalCustomLabel: (
619 threadRef: ScopedThreadRef,
620 terminalId: string,
621 label: string | null,
622 ) => void;
578623 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
579624 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
580625 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
......@@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
591636 state: ThreadTerminalUiState,
592637 suppressedTerminalIds: readonly string[],
593638 ) => ThreadTerminalUiState,
594 suppression?: { terminalId: string; suppressed: boolean },
639 suppression?: {
640 terminalId: string;
641 suppressed: boolean;
642 clearCustomLabel?: boolean;
643 },
644 pruneCustomLabels = false,
595645 ) => {
596646 set((state) => {
597647 const threadKey = terminalThreadKey(threadRef);
......@@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
609659 suppression.suppressed,
610660 )
611661 : 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 }
612695 if (
613696 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
614 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey
697 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey &&
698 nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey
615699 ) {
616700 return state;
617701 }
618702 return {
619703 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
620704 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
705 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
621706 };
622707 });
623708 };
624709
625710 return {
626711 terminalUiStateByThreadKey: {},
712 terminalCustomLabelsByThreadKey: {},
627713 suppressedTerminalIdsByThreadKey: {},
628714 setTerminalOpen: (threadRef, open) => {
629715 const terminalState = selectThreadTerminalUiState(
......@@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
682768 ),
683769 setActiveTerminal: (threadRef, terminalId) =>
684770 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 }),
685799 closeTerminal: (threadRef, terminalId) =>
686800 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {
687801 terminalId,
688802 suppressed: true,
803 clearCustomLabel: true,
689804 }),
690805 reconcileTerminalIds: (threadRef, nextIds) =>
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 }),
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 ),
701821 clearTerminalUiState: (threadRef) =>
702822 set((state) => {
703823 const threadKey = terminalThreadKey(threadRef);
......@@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
708828 );
709829 const hadSuppressedTerminalIds =
710830 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
831 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
711832 if (
712833 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
713 !hadSuppressedTerminalIds
834 !hadSuppressedTerminalIds &&
835 !hadCustomLabels
714836 ) {
715837 return state;
716838 }
717839 return {
718840 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
841 terminalCustomLabelsByThreadKey: removeRecordEntry(
842 state.terminalCustomLabelsByThreadKey,
843 threadKey,
844 ),
719845 suppressedTerminalIdsByThreadKey: removeRecordEntry(
720846 state.suppressedTerminalIdsByThreadKey,
721847 threadKey,
......@@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
728854 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
729855 const hadSuppressedTerminalIds =
730856 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
731 if (!hadTerminalUiState && !hadSuppressedTerminalIds) {
857 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
858 if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) {
732859 return state;
733860 }
734861 return {
......@@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
736863 state.terminalUiStateByThreadKey,
737864 threadKey,
738865 ),
866 terminalCustomLabelsByThreadKey: removeRecordEntry(
867 state.terminalCustomLabelsByThreadKey,
868 threadKey,
869 ),
739870 suppressedTerminalIdsByThreadKey: removeRecordEntry(
740871 state.suppressedTerminalIdsByThreadKey,
741872 threadKey,
......@@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
747878 const orphanedIds = new Set(
748879 [
749880 ...Object.keys(state.terminalUiStateByThreadKey),
881 ...Object.keys(state.terminalCustomLabelsByThreadKey),
750882 ...Object.keys(state.suppressedTerminalIdsByThreadKey),
751883 ].filter((key) => !activeThreadKeys.has(key)),
752884 );
......@@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
757889 const nextSuppressedTerminalIdsByThreadKey = {
758890 ...state.suppressedTerminalIdsByThreadKey,
759891 };
892 const nextTerminalCustomLabelsByThreadKey = {
893 ...state.terminalCustomLabelsByThreadKey,
894 };
760895 for (const id of orphanedIds) {
761896 delete nextTerminalUiStateByThreadKey[id];
897 delete nextTerminalCustomLabelsByThreadKey[id];
762898 delete nextSuppressedTerminalIdsByThreadKey[id];
763899 }
764900 return {
765901 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
902 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
766903 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
767904 };
768905 }),
......@@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
770907 },
771908 {
772909 name: TERMINAL_UI_STATE_STORAGE_KEY,
773 version: 4,
910 version: 5,
774911 storage: createJSONStorage(createTerminalUiStateStorage),
775912 migrate: migratePersistedTerminalUiStateStoreState,
776913 partialize: (state) => ({
777914 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
915 terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey,
778916 }),
779917 },
780918 ),
packages/contracts/src/ipc.ts+4
......@@ -111,6 +111,8 @@ 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;
114116 children?: readonly ContextMenuItem<T>[];
115117}
116118
......@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {
121123 readonly disabled?: boolean;
122124 readonly header?: boolean;
123125 readonly icon?: string;
126 readonly separatorBefore?: boolean;
124127 readonly children?: readonly ContextMenuItemSchemaType[];
125128}
126129
......@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
131134 disabled: Schema.optionalKey(Schema.Boolean),
132135 header: Schema.optionalKey(Schema.Boolean),
133136 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
134138 children: Schema.optionalKey(
135139 Schema.Array(
136140 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/shared/src/usageMerge.test.ts+1
......@@ -248,6 +248,7 @@ describe("mergeUsage", () => {
248248 );
249249
250250 expect(merged.sessions).toBe(1);
251 expect(merged.providers[0]?.sessions).toBe(1);
251252 });
252253
253254 it("returns empty totals with no environments", () => {
packages/shared/src/usageMerge.ts+29-11
......@@ -25,6 +25,7 @@ export interface ProviderTotals {
2525 readonly costUsd: number;
2626 readonly totalTokens: number;
2727 readonly records: number;
28 readonly sessions: number;
2829 readonly costShare: number;
2930 readonly tokenShare: number;
3031}
......@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
135136function ownedContribution(
136137 environment: EnvironmentUsage,
137138 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
138): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
139): {
140 readonly buckets: readonly UsageBucket[];
141 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
142} {
139143 const ownedProviders = new Set<UsageProviderKind>();
140 let sessions = 0;
144 const sessionsByProvider = new Map<UsageProviderKind, number>();
141145 for (const source of environment.summary.sources) {
142146 if (source.status === "missing") continue;
143147 const key = fingerprintKey(source.fingerprint);
144148 if (ownerByFingerprint.get(key) === environment.environmentId) {
145 ownedProviders.add(source.fingerprint.provider);
149 const provider = source.fingerprint.provider;
150 ownedProviders.add(provider);
146151 // Distinct within a directory. Summing per-bucket session counts instead
147152 // would count a session once per day and model it spans.
148 sessions += source.distinctSessions;
153 sessionsByProvider.set(
154 provider,
155 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
156 );
149157 }
150158 }
151159 return {
152160 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
153 sessions,
161 sessionsByProvider,
154162 };
155163}
156164
......@@ -228,7 +236,7 @@ export function mergeUsage(
228236
229237 const providerAccumulator = new Map<
230238 UsageProviderKind,
231 { costUsd: number; totalTokens: number; records: number }
239 { costUsd: number; totalTokens: number; records: number; sessions: number }
232240 >();
233241 const modelAccumulator = new Map<
234242 string,
......@@ -255,12 +263,20 @@ export function mergeUsage(
255263 const contributingEnvironments: EnvironmentId[] = [];
256264
257265 for (const environment of current) {
258 const { buckets, sessions: environmentSessions } = ownedContribution(
259 environment,
260 ownerByFingerprint,
261 );
266 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
262267 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
263 sessions += environmentSessions;
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 }
264280
265281 for (const bucket of buckets) {
266282 const tokens = bucketTokens(bucket);
......@@ -280,6 +296,7 @@ export function mergeUsage(
280296 costUsd: 0,
281297 totalTokens: 0,
282298 records: 0,
299 sessions: 0,
283300 };
284301 provider.costUsd += bucket.costUsd;
285302 provider.totalTokens += tokens;
......@@ -341,6 +358,7 @@ export function mergeUsage(
341358 costUsd: totals.costUsd,
342359 totalTokens: totals.totalTokens,
343360 records: totals.records,
361 sessions: totals.sessions,
344362 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
345363 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
346364 }))