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", () => {...@@ -98,7 +98,10 @@ describe("ElectronMenu", () => {
98 const electronMenu = yield* ElectronMenu.ElectronMenu;98 const electronMenu = yield* ElectronMenu.ElectronMenu;
99 const selectedItemId = yield* electronMenu.showContextMenu({99 const selectedItemId = yield* electronMenu.showContextMenu({
100 window: makeWindow(2),100 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 ],
102 position: Option.some({ x: 10.8, y: 20.2 }),105 position: Option.some({ x: 10.8, y: 20.2 }),
103 });106 });
104107
...@@ -110,6 +113,12 @@ describe("ElectronMenu", () => {...@@ -110,6 +113,12 @@ describe("ElectronMenu", () => {
110 enabled: true,113 enabled: true,
111 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
112 });115 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
113 }).pipe(Effect.provide(TestLayer)),122 }).pipe(Effect.provide(TestLayer)),
114 );123 );
115124
apps/desktop/src/electron/ElectronMenu.ts+9-1
...@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM...@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
78 label: sourceItem.label,78 label: sourceItem.label,
79 destructive: sourceItem.destructive === true,79 destructive: sourceItem.destructive === true,
80 disabled: sourceItem.disabled === true,80 disabled: sourceItem.disabled === true,
81 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
81 };82 };
8283
83 if (sourceItem.children) {84 if (sourceItem.children) {
...@@ -141,10 +142,17 @@ export const make = Effect.gen(function* () {...@@ -141,10 +142,17 @@ export const make = Effect.gen(function* () {
141 ): Electron.MenuItemConstructorOptions[] => {142 ): Electron.MenuItemConstructorOptions[] => {
142 const template: Electron.MenuItemConstructorOptions[] = [];143 const template: Electron.MenuItemConstructorOptions[] = [];
143 let hasInsertedDestructiveSeparator = false;144 let hasInsertedDestructiveSeparator = false;
145 const appendSeparator = () => {
146 if (template.length === 0 || template.at(-1)?.type === "separator") return;
147 template.push({ type: "separator" });
148 };
144149
145 for (const item of entries) {150 for (const item of entries) {
151 if (item.separatorBefore) {
152 appendSeparator();
153 }
146 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {154 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
147 template.push({ type: "separator" });155 appendSeparator();
148 hasInsertedDestructiveSeparator = true;156 hasInsertedDestructiveSeparator = true;
149 }157 }
150158
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+40-1
...@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity...@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
20 * If slimming ever moves to an allowlist over the whole payload, these20 * If slimming ever moves to an allowlist over the whole payload, these
21 * assertions are the tripwire.21 * assertions are the tripwire.
22 */22 */
23describe("projectActivityPayload agent-field survival", () => {23describe("projectActivityPayload", () => {
24 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {24 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
25 const projected = projectActivityPayload(25 const projected = projectActivityPayload(
26 activity({26 activity({
...@@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => {...@@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => {
44 expect(data.somethingClientNeverReads).toBeUndefined();44 expect(data.somethingClientNeverReads).toBeUndefined();
45 });45 });
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
47 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {86 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
48 const projected = projectActivityPayload(87 const projected = projectActivityPayload(
49 activity({88 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+27-7
...@@ -104,6 +104,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno...@@ -104,6 +104,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
104 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;104 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
105}105}
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
107function summarizeToolTextOutput(value: string): string | null {125function summarizeToolTextOutput(value: string): string | null {
108 const lines: string[] = [];126 const lines: string[] = [];
109 for (const rawLine of value.split(/\r?\n/u)) {127 for (const rawLine of value.split(/\r?\n/u)) {
...@@ -287,8 +305,9 @@ export function projectActivityPayload(...@@ -287,8 +305,9 @@ export function projectActivityPayload(
287 if (item) {305 if (item) {
288 projectedData.item = item;306 projectedData.item = item;
289 }307 }
290 if ("command" in data) {308 const command = projectCommandValue(data);
291 projectedData.command = data.command;309 if (command !== undefined) {
310 projectedData.command = command;
292 }311 }
293312
294 const changedFiles: string[] = [];313 const changedFiles: string[] = [];
...@@ -368,10 +387,10 @@ function dropStaleContextWindowActivities(...@@ -368,10 +387,10 @@ function dropStaleContextWindowActivities(
368/**387/**
369 * Identity both clients use to fold a tool lifecycle row into the call it388 * Identity both clients use to fold a tool lifecycle row into the call it
370 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and389 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
371 * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter390 * mobile's `threadActivity`): the runtime item id ingestion stamps as
372 * emits one, otherwise the itemType/title/detail triple. Returns null for rows391 * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple.
373 * with no identity at all — those never collapse on the client either, so they392 * Returns null for rows with no identity at all — those never collapse on the
374 * must not be dropped here.393 * client either, so they must not be dropped here.
375 */394 */
376function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {395function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
377 const payload = asRecord(activity.payload);396 const payload = asRecord(activity.payload);
...@@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |...@@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |
379 return null;398 return null;
380 }399 }
381400
382 const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);401 const toolCallId =
402 asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
383 if (toolCallId) {403 if (toolCallId) {
384 return `id:${toolCallId}`;404 return `id:${toolCallId}`;
385 }405 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+22-8
...@@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => {
2811 createdAt: now,2811 createdAt: now,
2812 threadId: asThreadId("thread-1"),2812 threadId: asThreadId("thread-1"),
2813 turnId: asTurnId("turn-9"),2813 turnId: asTurnId("turn-9"),
2814 itemId: asItemId("tool-call-9"),
2814 payload: {2815 payload: {
2815 itemType: "command_execution",2816 itemType: "command_execution",
2816 status: "in_progress",2817 status: "inProgress",
2817 title: "Read file",2818 title: "Command run",
2818 detail: "/tmp/file.ts",2819 detail: "Bash: vp test run",
2820 data: {
2821 toolName: "Bash",
2822 input: { command: "vp test run" },
2823 },
2819 },2824 },
2820 });2825 });
28212826
...@@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => {
2830 );2835 );
28312836
2832 expect(thread.session?.status).toBe("ready");2837 expect(thread.session?.status).toBe("ready");
2833 expect(2838 const activity = thread.activities.find(
2834 thread.activities.some(2839 (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started",
2835 (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",2840 );
2836 ),2841 const payload = activity?.payload as Record<string, unknown> | undefined;
2837 ).toBe(true);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 });
2838 });2852 });
28392853
2840 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {2854 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(...@@ -794,6 +794,7 @@ export function runtimeEventToActivities(
794 summary: event.payload.title ?? "Tool updated",794 summary: event.payload.title ?? "Tool updated",
795 payload: {795 payload: {
796 itemType: event.payload.itemType,796 itemType: event.payload.itemType,
797 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
797 ...(event.payload.status ? { status: event.payload.status } : {}),798 ...(event.payload.status ? { status: event.payload.status } : {}),
798 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),799 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
799 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),800 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
...@@ -821,6 +822,8 @@ export function runtimeEventToActivities(...@@ -821,6 +822,8 @@ export function runtimeEventToActivities(
821 summary: event.payload.title ?? "Tool",822 summary: event.payload.title ?? "Tool",
822 payload: {823 payload: {
823 itemType: event.payload.itemType,824 itemType: event.payload.itemType,
825 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
826 ...(event.payload.status ? { status: event.payload.status } : {}),
824 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),827 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
825 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),828 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
826 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),829 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
...@@ -847,7 +850,10 @@ export function runtimeEventToActivities(...@@ -847,7 +850,10 @@ export function runtimeEventToActivities(
847 summary: `${event.payload.title ?? "Tool"} started`,850 summary: `${event.payload.title ?? "Tool"} started`,
848 payload: {851 payload: {
849 itemType: event.payload.itemType,852 itemType: event.payload.itemType,
853 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
854 ...(event.payload.status ? { status: event.payload.status } : {}),
850 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),855 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
856 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
851 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),857 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
852 ...(event.payload.parentToolUseId858 ...(event.payload.parentToolUseId
853 ? { parentToolUseId: event.payload.parentToolUseId }859 ? { parentToolUseId: event.payload.parentToolUseId }
apps/web/src/components/ChatView.tsx+32-26
...@@ -167,7 +167,6 @@ import {...@@ -167,7 +167,6 @@ import {
167 WifiOffIcon,167 WifiOffIcon,
168} from "lucide-react";168} from "lucide-react";
169import { cn, randomHex } from "~/lib/utils";169import { cn, randomHex } from "~/lib/utils";
170import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
171import { stackedThreadToast, toastManager } from "./ui/toast";170import { stackedThreadToast, toastManager } from "./ui/toast";
172import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";171import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
173import { type NewProjectScriptInput } from "./ProjectScriptsControl";172import { type NewProjectScriptInput } from "./ProjectScriptsControl";
...@@ -220,7 +219,11 @@ import {...@@ -220,7 +219,11 @@ import {
220import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";219import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
221import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";220import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
222import { environmentCatalog } from "../connection/catalog";221import { environmentCatalog } from "../connection/catalog";
223import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";222import {
223 selectThreadTerminalCustomLabels,
224 selectThreadTerminalUiState,
225 useTerminalUiStateStore,
226} from "../terminalUiStateStore";
224import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";227import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
225import { projectEnvironment } from "../state/projects";228import { projectEnvironment } from "../state/projects";
226import { useEnvironmentQuery } from "../state/query";229import { useEnvironmentQuery } from "../state/query";
...@@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader";...@@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader";
256import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";259import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
257import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";260import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
258import { NoActiveThreadState } from "./NoActiveThreadState";261import { NoActiveThreadState } from "./NoActiveThreadState";
262import { WorkspacePageHeader } from "./WorkspacePageContainer";
259import {263import {
260 resolveEffectiveEnvMode,264 resolveEffectiveEnvMode,
261 resolveLocalCheckoutBranchMismatch,265 resolveLocalCheckoutBranchMismatch,
...@@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps {...@@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps {
653 newShortcutLabel: string | undefined;657 newShortcutLabel: string | undefined;
654 closeShortcutLabel: string | undefined;658 closeShortcutLabel: string | undefined;
655 keybindings: ResolvedKeybindingsConfig;659 keybindings: ResolvedKeybindingsConfig;
660 onHide: () => void;
656 onAddTerminalContext: (selection: TerminalContextSelection) => void;661 onAddTerminalContext: (selection: TerminalContextSelection) => void;
657}662}
658663
...@@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
667 newShortcutLabel,672 newShortcutLabel,
668 closeShortcutLabel,673 closeShortcutLabel,
669 keybindings,674 keybindings,
675 onHide,
670 onAddTerminalContext,676 onAddTerminalContext,
671}: PersistentThreadTerminalDrawerProps) {677}: PersistentThreadTerminalDrawerProps) {
672 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");678 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
...@@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
990 onSplitTerminal={splitTerminal}996 onSplitTerminal={splitTerminal}
991 onSplitTerminalVertical={splitTerminalVertical}997 onSplitTerminalVertical={splitTerminalVertical}
992 onNewTerminal={createNewTerminal}998 onNewTerminal={createNewTerminal}
999 onHide={onHide}
993 splitShortcutLabel={visible ? splitShortcutLabel : undefined}1000 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
994 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}1001 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
995 newShortcutLabel={visible ? newShortcutLabel : undefined}1002 newShortcutLabel={visible ? newShortcutLabel : undefined}
...@@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) {
1540 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1547 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1541 const activeThreadId = activeThread?.id ?? null;1548 const activeThreadId = activeThread?.id ?? null;
1542 const activeThreadEnvironmentId = activeThread?.environmentId ?? null;1549 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 );
1543 const runningTerminalIds = useThreadRunningTerminalIds({1560 const runningTerminalIds = useThreadRunningTerminalIds({
1544 environmentId: activeThread?.environmentId ?? null,1561 environmentId: activeThread?.environmentId ?? null,
1545 threadId: activeThreadId,1562 threadId: activeThreadId,
...@@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) {
1569 for (const session of activeThreadKnownSessions) {1586 for (const session of activeThreadKnownSessions) {
1570 labels.set(1587 labels.set(
1571 session.target.terminalId,1588 session.target.terminalId,
1572 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),1589 activeTerminalCustomLabels[session.target.terminalId] ??
1590 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1573 );1591 );
1574 }1592 }
1593 for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) {
1594 if (!labels.has(terminalId)) labels.set(terminalId, label);
1595 }
1575 return labels;1596 return labels;
1576 }, [activeThreadKnownSessions]);1597 }, [activeTerminalCustomLabels, activeThreadKnownSessions]);
1577 const activeThreadRef = useMemo(
1578 () =>
1579 activeThreadEnvironmentId && activeThreadId
1580 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1581 : null,
1582 [activeThreadEnvironmentId, activeThreadId],
1583 );
1584 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1598 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1585 const [timelineAnchor, setTimelineAnchor] = useState<{1599 const [timelineAnchor, setTimelineAnchor] = useState<{
1586 readonly threadKey: string | null;1600 readonly threadKey: string | null;
...@@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) {
2808 },2822 },
2809 [activeThreadRef, storeSetTerminalOpen],2823 [activeThreadRef, storeSetTerminalOpen],
2810 );2824 );
2825 const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]);
2811 const toggleTerminalVisibility = useCallback(() => {2826 const toggleTerminalVisibility = useCallback(() => {
2812 if (!activeThreadRef) return;2827 if (!activeThreadRef) return;
2813 const nextOpen = !terminalUiState.terminalOpen;2828 const nextOpen = !terminalUiState.terminalOpen;
...@@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) {
6114 ? "thread"6129 ? "thread"
6115 : "page"6130 : "page"
6116 }6131 }
6117 chromeVariant="collapse"
6118 composerDraftTarget={composerDraftTarget}6132 composerDraftTarget={composerDraftTarget}
6119 onStateChange={handlePullRequestTabStatusChange}6133 onStateChange={handlePullRequestTabStatusChange}
6120 />6134 />
...@@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) {
6160 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}6174 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
6161 >6175 >
6162 {/* Top bar */}6176 {/* Top bar */}
6163 <header6177 <WorkspacePageHeader
6164 data-chat-header6178 data-chat-header
6165 className={cn(6179 electron={isElectron}
6166 "bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",6180 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
6167 isElectron6181 className="relative bg-background"
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 )}
6177 >6182 >
6178 {!rightPanelOpen ? panelLayoutControls : null}6183 {!rightPanelOpen ? panelLayoutControls : null}
6179 <ChatHeader6184 <ChatHeader
...@@ -6204,7 +6209,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6204,7 +6209,7 @@ function ChatViewContent(props: ChatViewProps) {
6204 onUpdateProjectScript={updateProjectScript}6209 onUpdateProjectScript={updateProjectScript}
6205 onDeleteProjectScript={deleteProjectScript}6210 onDeleteProjectScript={deleteProjectScript}
6206 />6211 />
6207 </header>6212 </WorkspacePageHeader>
62086213
6209 <ThreadErrorBanner6214 <ThreadErrorBanner
6210 error={visibleThreadError}6215 error={visibleThreadError}
...@@ -6542,6 +6547,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6542,6 +6547,7 @@ function ChatViewContent(props: ChatViewProps) {
6542 newShortcutLabel={newTerminalShortcutLabel ?? undefined}6547 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
6543 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}6548 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
6544 keybindings={keybindings}6549 keybindings={keybindings}
6550 onHide={hideTerminal}
6545 onAddTerminalContext={addTerminalContextToDraft}6551 onAddTerminalContext={addTerminalContextToDraft}
6546 />6552 />
6547 ))}6553 ))}
apps/web/src/components/NoActiveThreadState.tsx+4-15
...@@ -1,26 +1,15 @@...@@ -1,26 +1,15 @@
1import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";1import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
2import { SidebarInset } from "./ui/sidebar";2import { SidebarInset } from "./ui/sidebar";
3import { isElectron } from "../env";3import { isElectron } from "../env";
4import { cn } from "~/lib/utils";4import { WorkspacePageHeader } from "./WorkspacePageContainer";
5import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
65
7export function NoActiveThreadState() {6export function NoActiveThreadState() {
8 return (7 return (
9 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">8 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
10 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">9 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
11 <header10 <WorkspacePageHeader electron={isElectron} className="border-b border-border">
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 >
20 {isElectron ? (11 {isElectron ? (
21 <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]">12 <span className="text-xs text-muted-foreground/50">No active thread</span>
22 No active thread
23 </span>
24 ) : (13 ) : (
25 <div className="flex items-center gap-2">14 <div className="flex items-center gap-2">
26 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">15 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
...@@ -28,7 +17,7 @@ export function NoActiveThreadState() {...@@ -28,7 +17,7 @@ export function NoActiveThreadState() {
28 </span>17 </span>
29 </div>18 </div>
30 )}19 )}
31 </header>20 </WorkspacePageHeader>
3221
33 <Empty className="flex-1">22 <Empty className="flex-1">
34 <div className="w-full max-w-lg px-8 py-12">23 <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 {...@@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
299export function shouldCreateNewThreadInCurrentProject(299export function shouldCreateNewThreadInCurrentProject(
300 shiftKey: boolean,300 shiftKey: boolean,
301 projectGroupCount: number,301 projectGroupCount: number,
302 hasProjectScope = false,
302): boolean {303): boolean {
303 return shiftKey || projectGroupCount <= 1;304 return hasProjectScope || shiftKey || projectGroupCount <= 1;
304}305}
305306
306export function orderItemsByPreferredIds<TItem, TId>(input: {307export function orderItemsByPreferredIds<TItem, TId>(input: {
apps/web/src/components/Sidebar.tsx+159-157
...@@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25;...@@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25;
184// Keep the v2 key so existing preferences survive the v2-to-default rename.184// Keep the v2 key so existing preferences survive the v2-to-default rename.
185const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded";185const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded";
186const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded";186const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded";
187const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0";
187188
188function compactSidebarTimeLabel(label: string): string {189function compactSidebarTimeLabel(label: string): string {
189 if (label === "just now") return "now";190 if (label === "just now") return "now";
...@@ -366,26 +367,20 @@ function SnoozePopoverButton(props: {...@@ -366,26 +367,20 @@ function SnoozePopoverButton(props: {
366 );367 );
367 return (368 return (
368 <Popover open={open} onOpenChange={onOpenChange}>369 <Popover open={open} onOpenChange={onOpenChange}>
369 <Tooltip>370 <PopoverTrigger
370 <TooltipTrigger371 render={
371 render={372 <Button
372 <PopoverTrigger373 size="icon-micro"
373 render={374 variant="ghost-muted"
374 <button375 aria-label="Snooze thread"
375 type="button"376 title="Snooze thread"
376 aria-label="Snooze thread"377 onClick={(event) => event.stopPropagation()}
377 onClick={(event) => event.stopPropagation()}378 onDoubleClick={(event) => event.stopPropagation()}
378 onDoubleClick={(event) => event.stopPropagation()}379 />
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 }
380 />381 >
381 }382 <ClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
382 />383 </PopoverTrigger>
383 }
384 >
385 <ClockIcon className="size-3" />
386 </TooltipTrigger>
387 <TooltipPopup>Snooze thread</TooltipPopup>
388 </Tooltip>
389 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">384 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
390 {presets.map((preset) => (385 {presets.map((preset) => (
391 <button386 <button
...@@ -1199,9 +1194,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1199,9 +1194,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1199 aria-label="Dismiss Woke notification"1194 aria-label="Dismiss Woke notification"
1200 title="Dismiss Woke notification"1195 title="Dismiss Woke notification"
1201 onClick={handleAcknowledgeWokeClick}1196 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"
1203 >1198 >
1204 <AlarmClockIcon aria-hidden className="size-3" />1199 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1205 <span role="status">Woke</span>1200 <span role="status">Woke</span>
1206 </button>1201 </button>
1207 ) : (1202 ) : (
...@@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1223 isWoke && "group-hover/sidebar-row:static",1218 isWoke && "group-hover/sidebar-row:static",
1224 )}1219 )}
1225 >1220 >
1226 <AlarmClockOffIcon className="mb-px size-3" />1221 <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1227 </button>1222 </button>
1228 )1223 )
1229 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (1224 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
...@@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1236 isWoke && "group-hover/sidebar-row:static",1231 isWoke && "group-hover/sidebar-row:static",
1237 )}1232 )}
1238 >1233 >
1239 <Undo2Icon className="mb-px size-3.5" />1234 <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1240 </button>1235 </button>
1241 ) : (1236 ) : (
1242 <button1237 <button
...@@ -1248,7 +1243,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1248,7 +1243,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1248 isWoke && "group-hover/sidebar-row:static",1243 isWoke && "group-hover/sidebar-row:static",
1249 )}1244 )}
1250 >1245 >
1251 <CheckIcon className="size-3" />1246 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1252 </button>1247 </button>
1253 )}1248 )}
1254 </span>1249 </span>
...@@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1317 ) : (1312 ) : (
1318 <span className="flex-1" />1313 <span className="flex-1" />
1319 )}1314 )}
1320 {props.isPinned ? (1315 <span className="ml-auto flex h-5 shrink-0 items-center">
1321 props.pinningSupported ? (1316 {props.isPinned ? (
1322 <Tooltip>1317 props.pinningSupported ? (
1323 <TooltipTrigger1318 <Button
1324 render={1319 size="icon-micro"
1325 <button1320 variant="ghost-muted"
1326 type="button"1321 aria-label="Unpin thread"
1327 aria-label="Unpin thread"1322 title="Unpin thread"
1328 onClick={handleUnpinClick}1323 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 }
1332 >1324 >
1333 <PinIcon aria-hidden className="size-3 shrink-0" />1325 <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1334 </TooltipTrigger>1326 </Button>
1335 <TooltipPopup>Unpin thread</TooltipPopup>1327 ) : (
1336 </Tooltip>1328 <span className="inline-flex size-5 shrink-0 items-center justify-center">
1337 ) : (1329 <PinIcon
1338 <PinIcon1330 aria-label="Pinned"
1339 aria-label="Pinned"1331 role="img"
1340 role="img"1332 className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")}
1341 className="size-3 shrink-0 text-muted-foreground/65"1333 />
1342 />1334 </span>
1343 )1335 )
1344 ) : null}1336 ) : null}
1345 {/* The visible state owns this slot's width: status at rest,1337 {/* Only the visible state owns this slot's width: the pin stays
1346 actions on hover/keyboard focus or while the popover is open. Keeping1338 directly beside the idle status and beside the first action
1347 the hidden state out of flow lets the project label reclaim1339 when the hover controls replace it. */}
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. */}
1353 <span1340 <span
1354 className={cn(1341 className={cn(
1355 isWokeStatus1342 "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs",
1356 ? "pointer-events-auto"1343 props.isPinned ? "min-w-0" : "min-w-8",
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",
1360 )}1344 )}
1361 >1345 >
1362 {topStatus ? (1346 {/* Read-only status labels yield to the hover actions. Woke is
1363 isWokeStatus ? (1347 itself an action, so it stays pointer-enabled and visible
1364 <button1348 while the other controls appear beside it. */}
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 ? (
1405 <span1349 <span
1406 className={cn(1350 className={cn(
1407 // focus-visible, not focus-within: a mouse click leaves1351 isWokeStatus
1408 // the Settle button focused, and a plain focus-within1352 ? "pointer-events-auto"
1409 // would keep the controls pinned over the status label1353 : "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",
1410 // once the pointer moves away (e.g. after a failed1354 "self-center tabular-nums text-secondary-label transition-opacity",
1411 // settle) instead of cross-fading back.1355 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
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",
1414 )}1356 )}
1415 >1357 >
1416 {showSnoozeButton ? (1358 {topStatus ? (
1417 <SnoozePopoverButton1359 isWokeStatus ? (
1418 open={snoozeMenuOpen}1360 <button
1419 onOpenChange={setSnoozeMenuOpen}1361 type="button"
1420 onSnooze={handleSnoozePreset}1362 aria-label="Dismiss Woke notification"
1421 timestampFormat={props.timestampFormat}1363 title="Dismiss Woke notification"
1422 />1364 onClick={handleAcknowledgeWokeClick}
1423 ) : null}1365 className={cn(
1424 {props.settlementSupported ? (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",
1425 <Tooltip>1367 topStatus.className,
1426 <TooltipTrigger1368 )}
1427 render={1369 >
1428 <button1370 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1429 type="button"1371 <span role="status">{topStatus.label}</span>
1430 aria-label="Settle thread"1372 </button>
1431 onClick={handleSettleClick}1373 ) : (
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"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}
1433 />1384 />
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"
1435 >1429 >
1436 <CheckIcon className="size-3.5" />1430 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />
1437 Settle1431 Settle
1438 </TooltipTrigger>1432 </button>
1439 <TooltipPopup>Settle thread</TooltipPopup>1433 ) : null}
1440 </Tooltip>1434 </span>
1441 ) : null}1435 ) : null}
1442 </span>1436 </span>
1443 ) : null}
1444 </span>1437 </span>
1445 </div>1438 </div>
1446 <div className="mt-1 flex min-w-0">1439 <div className="mt-1 flex min-w-0">
...@@ -3218,17 +3211,25 @@ export default function Sidebar() {...@@ -3218,17 +3211,25 @@ export default function Sidebar() {
3218 autoAnimate(node, { duration: 150, easing: "ease-out" });3211 autoAnimate(node, { duration: 150, easing: "ease-out" });
3219 }, []);3212 }, []);
32203213
3221 // New thread defaults to the project you're in (active thread's project,3214 // A selected project scope owns creation: users should not have to choose
3222 // falling back to the top project) — same resolution the command palette3215 // the same project twice. "All projects" keeps the picker in multi-project
3223 // uses. The command palette already offers a "New thread in..." submenu3216 // setups, while Shift+click retains the direct-create shortcut.
3224 // for multi-project setups.
3225 const handleNewThreadClick = useCallback(3217 const handleNewThreadClick = useCallback(
3226 (event?: ReactMouseEvent) => {3218 (event?: ReactMouseEvent) => {
3227 // One project: nothing to pick, create immediately. Shift+click creates3219 if (
3228 // directly in the current project even with several projects, skipping3220 shouldCreateNewThreadInCurrentProject(
3229 // the palette picker.3221 event?.shiftKey ?? false,
3230 if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {3222 projectGroups.length,
3223 scopedProjectGroup !== null,
3224 )
3225 ) {
3231 if (isMobile) setOpenMobile(false);3226 if (isMobile) setOpenMobile(false);
3227 if (scopedProjectGroup) {
3228 void newThreadContext.handleNewThread(
3229 scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id),
3230 );
3231 return;
3232 }
3232 void startNewThreadFromContext({3233 void startNewThreadFromContext({
3233 activeDraftThread: newThreadContext.activeDraftThread,3234 activeDraftThread: newThreadContext.activeDraftThread,
3234 activeThread: newThreadContext.activeThread ?? undefined,3235 activeThread: newThreadContext.activeThread ?? undefined,
...@@ -3240,20 +3241,19 @@ export default function Sidebar() {...@@ -3240,20 +3241,19 @@ export default function Sidebar() {
3240 if (isMobile) setOpenMobile(false);3241 if (isMobile) setOpenMobile(false);
3241 openCommandPalette({ open: "new-thread-in" });3242 openCommandPalette({ open: "new-thread-in" });
3242 },3243 },
3243 [isMobile, newThreadContext, projectGroups.length, setOpenMobile],3244 [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile],
3244 );3245 );
32453246
3246 // The button mirrors chat.new: in multi-project setups both route through3247 // With no explicit scope the button mirrors chat.new. A scoped button has
3247 // the command palette's "New thread in..." picker, and in single-project3248 // intentionally more specific behavior, so it does not advertise the
3248 // setups both create immediately. In multi-project setups the label is only3249 // broader command's shortcut.
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.
3254 const newThreadShortcutLabel =3250 const newThreadShortcutLabel =
3255 shortcutLabelForCommand(keybindings, "chat.new") ??3251 scopedProjectGroup === null
3256 (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);3252 ? (shortcutLabelForCommand(keybindings, "chat.new") ??
3253 (projectGroups.length <= 1
3254 ? shortcutLabelForCommand(keybindings, "chat.newLocal")
3255 : undefined))
3256 : undefined;
3257 const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");3257 const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
3258 return (3258 return (
3259 <>3259 <>
...@@ -3332,7 +3332,9 @@ export default function Sidebar() {...@@ -3332,7 +3332,9 @@ export default function Sidebar() {
3332 />3332 />
3333 </TooltipTrigger>3333 </TooltipTrigger>
3334 <TooltipPopup side="right">3334 <TooltipPopup side="right">
3335 {projectGroups.length > 1 ? (3335 {scopedProjectGroup ? (
3336 `New thread in ${scopedProjectGroup.displayName}`
3337 ) : projectGroups.length > 1 ? (
3336 <span className="flex flex-col gap-0.5">3338 <span className="flex flex-col gap-0.5">
3337 <span>3339 <span>
3338 {newThreadShortcutLabel3340 {newThreadShortcutLabel
apps/web/src/components/ThreadTerminalDrawer.tsx+356-308
...@@ -5,11 +5,11 @@ import {...@@ -5,11 +5,11 @@ import {
5} from "@t3tools/client-runtime/state/runtime";5} from "@t3tools/client-runtime/state/runtime";
6import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";6import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
7import {7import {
8 PanelBottomCloseIcon,
8 Plus,9 Plus,
9 SquareSplitHorizontal,10 SquareSplitHorizontal,
10 SquareSplitVertical,11 SquareSplitVertical,
11 TerminalSquare,12 TerminalSquare,
12 Trash2,
13 XIcon,13 XIcon,
14} from "lucide-react";14} from "lucide-react";
15import {15import {
...@@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";...@@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21import * as Schema from "effect/Schema";21import * as Schema from "effect/Schema";
22import {22import {
23 type PointerEvent as ReactPointerEvent,23 type PointerEvent as ReactPointerEvent,
24 type ReactNode,
25 type SetStateAction,24 type SetStateAction,
26 useCallback,25 useCallback,
27 useEffect,26 useEffect,
...@@ -30,9 +29,9 @@ import {...@@ -30,9 +29,9 @@ import {
30 useRef,29 useRef,
31 useState,30 useState,
32} from "react";31} from "react";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
34import { Button } from "~/components/ui/button";32import { Button } from "~/components/ui/button";
35import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";33import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
34import { useResizableWidth } from "~/hooks/useResizableWidth";
36import { cn } from "~/lib/utils";35import { cn } from "~/lib/utils";
37import { type TerminalContextSelection } from "~/lib/terminalContext";36import { type TerminalContextSelection } from "~/lib/terminalContext";
38import {37import {
...@@ -60,6 +59,7 @@ import {...@@ -60,6 +59,7 @@ import {
60import { readLocalApi } from "~/localApi";59import { readLocalApi } from "~/localApi";
61import { useClientSettings } from "../hooks/useSettings";60import { useClientSettings } from "../hooks/useSettings";
62import { useLocalStorage } from "../hooks/useLocalStorage";61import { useLocalStorage } from "../hooks/useLocalStorage";
62import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore";
63import { useAttachedTerminalSession } from "../state/terminalSessions";63import { useAttachedTerminalSession } from "../state/terminalSessions";
64import { serverEnvironment } from "../state/server";64import { serverEnvironment } from "../state/server";
65import { previewEnvironment } from "../state/preview";65import { previewEnvironment } from "../state/preview";
...@@ -72,10 +72,15 @@ import {...@@ -72,10 +72,15 @@ import {
72 resolveTerminalFontSizePreference,72 resolveTerminalFontSizePreference,
73 TYPOGRAPHY_ADVANCED_STORAGE_KEY,73 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
74} from "../appearanceFonts";74} from "../appearanceFonts";
75import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle";
7576
76const MIN_DRAWER_HEIGHT = 180;77const MIN_DRAWER_HEIGHT = 180;
77const MAX_DRAWER_HEIGHT_RATIO = 0.75;78const MAX_DRAWER_HEIGHT_RATIO = 0.75;
78const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;79const 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
80function maxDrawerHeight(): number {85function maxDrawerHeight(): number {
81 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;86 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
...@@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp(...@@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp(
244 return selectionGestureActive && button === 0;249 return selectionGestureActive && button === 0;
245}250}
246251
252export function shouldShowTerminalSidebar(terminalCount: number): boolean {
253 return terminalCount > 1;
254}
255
247export function terminalSelectionLineRange(position: {256export function terminalSelectionLineRange(position: {
248 start: { y: number };257 start: { y: number };
249 end: { y: number };258 end: { y: number };
...@@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps {...@@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps {
876 onSplitTerminal: () => void;885 onSplitTerminal: () => void;
877 onSplitTerminalVertical: () => void;886 onSplitTerminalVertical: () => void;
878 onNewTerminal: () => void;887 onNewTerminal: () => void;
888 onHide?: () => void;
879 splitShortcutLabel?: string | undefined;889 splitShortcutLabel?: string | undefined;
880 splitVerticalShortcutLabel?: string | undefined;890 splitVerticalShortcutLabel?: string | undefined;
881 newShortcutLabel?: string | undefined;891 newShortcutLabel?: string | undefined;
...@@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps {...@@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps {
891 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;901 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
892}902}
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
923export default function ThreadTerminalDrawer({904export default function ThreadTerminalDrawer({
924 mode = "drawer",905 mode = "drawer",
925 threadRef,906 threadRef,
...@@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({...@@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({
937 onSplitTerminal,918 onSplitTerminal,
938 onSplitTerminalVertical,919 onSplitTerminalVertical,
939 onNewTerminal,920 onNewTerminal,
921 onHide,
940 splitShortcutLabel,922 splitShortcutLabel,
941 splitVerticalShortcutLabel,923 splitVerticalShortcutLabel,
942 newShortcutLabel,924 newShortcutLabel,
...@@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({...@@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({
950 terminalLaunchLocationsById,932 terminalLaunchLocationsById,
951}: ThreadTerminalDrawerProps) {933}: ThreadTerminalDrawerProps) {
952 const isPanel = mode === "panel";934 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);
953 const [advancedTypography] = useLocalStorage(950 const [advancedTypography] = useLocalStorage(
954 TYPOGRAPHY_ADVANCED_STORAGE_KEY,951 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
955 false,952 false,
...@@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({...@@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({
1098 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);1095 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
1099 const splitDirection =1096 const splitDirection =
1100 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";1097 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
1101 const hasTerminalSidebar = normalizedTerminalIds.length > 1;1098 const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length);
1102 const isSplitView = visibleTerminalIds.length > 1;1099 const isSplitView = visibleTerminalIds.length > 1;
1103 const showGroupHeaders =
1104 resolvedTerminalGroups.length > 1 ||
1105 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
1106 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;1100 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
1107 const terminalLabelById = useMemo(() => {1101 const automaticTerminalLabelById = useMemo(() => {
1108 const next = new Map<string, string>();1102 const next = new Map<string, string>();
1109 for (const terminalId of normalizedTerminalIds) {1103 for (const terminalId of normalizedTerminalIds) {
1110 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));1104 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
1111 }1105 }
1112 return next;1106 return next;
1113 }, [normalizedTerminalIds, terminalLabelsById]);1107 }, [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]);
1114 const resolveTerminalLaunchLocation = useCallback(1120 const resolveTerminalLaunchLocation = useCallback(
1115 (terminalId: string): TerminalLaunchLocation => {1121 (terminalId: string): TerminalLaunchLocation => {
1116 return (1122 return (
...@@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({...@@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({
1123 },1129 },
1124 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],1130 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
1125 );1131 );
1132 const newTerminalActionLabel = newShortcutLabel
1133 ? `New Terminal (${newShortcutLabel})`
1134 : "New Terminal";
1126 const splitTerminalActionLabel = hasReachedSplitLimit1135 const splitTerminalActionLabel = hasReachedSplitLimit
1127 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`1136 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
1128 : splitShortcutLabel1137 : splitShortcutLabel
...@@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({...@@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({
1133 : splitVerticalShortcutLabel1142 : splitVerticalShortcutLabel
1134 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`1143 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
1135 : "Split Terminal Vertically";1144 : "Split Terminal Vertically";
1136 const newTerminalActionLabel = newShortcutLabel
1137 ? `New Terminal (${newShortcutLabel})`
1138 : "New Terminal";
1139 const closeTerminalActionLabel = closeShortcutLabel1145 const closeTerminalActionLabel = closeShortcutLabel
1140 ? `Close Terminal (${closeShortcutLabel})`1146 ? `Close Terminal (${closeShortcutLabel})`
1141 : "Close Terminal";1147 : "Close Terminal";
...@@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({...@@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({
1147 if (hasReachedSplitLimit) return;1153 if (hasReachedSplitLimit) return;
1148 onSplitTerminalVertical();1154 onSplitTerminalVertical();
1149 }, [hasReachedSplitLimit, onSplitTerminalVertical]);1155 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1150 const onNewTerminalAction = useCallback(() => {1156 const startTerminalRename = useCallback(
1151 onNewTerminal();1157 (terminalId: string) => {
1152 }, [onNewTerminal]);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
1154 useEffect(() => {1194 useEffect(() => {
1155 onHeightChangeRef.current = onHeightChange;1195 onHeightChangeRef.current = onHeightChange;
...@@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({...@@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({
1274 ) : null}1314 ) : null}
1275 <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">1315 <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">
1276 <p>No terminal sessions for this thread yet.</p>1316 <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}>
1278 {newTerminalActionLabel}1318 {newTerminalActionLabel}
1279 </Button>1319 </Button>
1280 </div>1320 </div>
...@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({...@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({
1283 }1323 }
12841324
1285 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);1325 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
12861326 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 );
1287 return (1392 return (
1288 <aside1393 <aside
1289 data-terminal-owner={isPanel ? "right-panel" : "drawer"}1394 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
...@@ -1303,277 +1408,220 @@ export default function ThreadTerminalDrawer({...@@ -1303,277 +1408,220 @@ export default function ThreadTerminalDrawer({
1303 />1408 />
1304 ) : null}1409 ) : null}
13051410
1306 {!hasTerminalSidebar && (1411 {!hasTerminalSidebar ? (
1307 <div className="pointer-events-none absolute right-2 top-2 z-20">1412 <div className="pointer-events-none absolute top-2 right-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">1413 <div className="pointer-events-auto flex h-8 items-center rounded-lg border border-border/70 bg-background p-0.5 shadow-xs">
1309 <TerminalActionButton1414 {compactTerminalToolbar}
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>
1348 </div>1415 </div>
1349 </div>1416 </div>
1350 )}1417 ) : null}
13511418
1352 <div className="min-h-0 w-full flex-1">1419 <div className="flex min-h-0 w-full flex-1">
1353 <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}>1420 <div className="min-w-0 flex-1">
1354 <div className="min-w-0 flex-1">1421 {isSplitView ? (
1355 {isSplitView ? (1422 <div
1356 <div1423 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1357 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"1424 style={
1358 style={1425 splitDirection === "vertical"
1359 splitDirection === "vertical"1426 ? {
1360 ? {1427 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1361 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1428 }
1362 }1429 : {
1363 : {1430 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1364 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1431 }
1365 }1432 }
1366 }1433 >
1367 >1434 {visibleTerminalIds.map((terminalId) => {
1368 {visibleTerminalIds.map((terminalId) => {1435 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1369 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);1436 const isActive = terminalId === resolvedActiveTerminalId;
1370 return (1437 return (
1371 <div1438 <div
1372 key={terminalId}1439 key={terminalId}
1373 className={`min-h-0 min-w-0 ${1440 className={cn(
1374 splitDirection === "vertical"1441 "min-h-0 min-w-0",
1375 ? "border-t first:border-t-0"1442 splitDirection === "vertical"
1376 : "border-l first:border-l-0"1443 ? "border-t first:border-t-0"
1377 } ${1444 : "border-l first:border-l-0",
1378 terminalId === resolvedActiveTerminalId1445 isActive ? "border-border" : "border-border/70",
1379 ? "border-border"1446 )}
1380 : "border-border/70"1447 onMouseDown={() => {
1381 }`}1448 if (!isActive) onActiveTerminalChange(terminalId);
1382 onMouseDown={() => {1449 }}
1383 if (terminalId !== resolvedActiveTerminalId) {1450 >
1384 onActiveTerminalChange(terminalId);1451 <div className="h-full p-1">
1385 }1452 <TerminalViewport
1386 }}1453 advancedTypography={advancedTypography}
1387 >1454 threadRef={threadRef}
1388 <div className="h-full p-1">1455 threadId={threadId}
1389 <TerminalViewport1456 terminalId={terminalId}
1390 advancedTypography={advancedTypography}1457 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1391 threadRef={threadRef}1458 cwd={terminalLaunchLocation.cwd}
1392 threadId={threadId}1459 {...(terminalLaunchLocation.worktreePath !== undefined
1393 terminalId={terminalId}1460 ? { worktreePath: terminalLaunchLocation.worktreePath }
1394 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1461 : {})}
1395 cwd={terminalLaunchLocation.cwd}1462 {...(terminalLaunchLocation.runtimeEnv
1396 {...(terminalLaunchLocation.worktreePath !== undefined1463 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1397 ? { worktreePath: terminalLaunchLocation.worktreePath }1464 : {})}
1398 : {})}1465 onSessionExited={() => onCloseTerminal(terminalId)}
1399 {...(terminalLaunchLocation.runtimeEnv1466 onAddTerminalContext={onAddTerminalContext}
1400 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }1467 focusRequestId={focusRequestId}
1401 : {})}1468 autoFocus={isActive}
1402 onSessionExited={() => onCloseTerminal(terminalId)}1469 resizeEpoch={resizeEpoch}
1403 onAddTerminalContext={onAddTerminalContext}1470 drawerHeight={drawerHeight}
1404 focusRequestId={focusRequestId}1471 keybindings={keybindings}
1405 autoFocus={terminalId === resolvedActiveTerminalId}1472 />
1406 resizeEpoch={resizeEpoch}
1407 drawerHeight={drawerHeight}
1408 keybindings={keybindings}
1409 />
1410 </div>
1411 </div>1473 </div>
1412 );1474 </div>
1413 })}1475 );
1414 </div>1476 })}
1415 ) : (1477 </div>
1416 <div className="h-full p-1">1478 ) : (
1417 <TerminalViewport1479 <div className="h-full p-1">
1418 advancedTypography={advancedTypography}1480 <TerminalViewport
1419 key={resolvedActiveTerminalId}1481 advancedTypography={advancedTypography}
1420 threadRef={threadRef}1482 key={resolvedActiveTerminalId}
1421 threadId={threadId}1483 threadRef={threadRef}
1422 terminalId={resolvedActiveTerminalId}1484 threadId={threadId}
1423 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1485 terminalId={resolvedActiveTerminalId}
1424 cwd={activeTerminalLaunchLocation.cwd}1486 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1425 {...(activeTerminalLaunchLocation.worktreePath !== undefined1487 cwd={activeTerminalLaunchLocation.cwd}
1426 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }1488 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1427 : {})}1489 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1428 {...(activeTerminalLaunchLocation.runtimeEnv1490 : {})}
1429 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }1491 {...(activeTerminalLaunchLocation.runtimeEnv
1430 : {})}1492 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1431 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1493 : {})}
1432 onAddTerminalContext={onAddTerminalContext}1494 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1433 focusRequestId={focusRequestId}1495 onAddTerminalContext={onAddTerminalContext}
1434 autoFocus1496 focusRequestId={focusRequestId}
1435 resizeEpoch={resizeEpoch}1497 autoFocus
1436 drawerHeight={drawerHeight}1498 resizeEpoch={resizeEpoch}
1437 keybindings={keybindings}1499 drawerHeight={drawerHeight}
1438 />1500 keybindings={keybindings}
1439 </div>1501 />
1440 )}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}
1441 </div>1516 </div>
14421517
1443 {hasTerminalSidebar && (1518 <div className="min-h-0 flex-1 overflow-y-auto p-1">
1444 <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">1519 {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {
1445 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">1520 const isSplitGroup = terminalGroup.terminalIds.length > 1;
1446 <div className="inline-flex h-full items-stretch">1521 const splitVertically = terminalGroup.splitDirection === "vertical";
1447 <TerminalActionButton1522 const SplitIcon = splitVertically ? SquareSplitVertical : SquareSplitHorizontal;
1448 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${1523 return (
1449 hasReachedSplitLimit1524 <div
1450 ? "cursor-not-allowed opacity-45 hover:bg-transparent"1525 key={terminalGroup.id}
1451 : "hover:bg-accent/70"1526 className={cn(
1452 }`}1527 groupIndex > 0 && "mt-1",
1453 onClick={onSplitTerminalAction}1528 isSplitGroup && "rounded-lg border border-border/80 bg-muted/20 p-1",
1454 label={splitTerminalActionLabel}1529 )}
1455 >1530 >
1456 <SquareSplitHorizontal className="size-3.25" />1531 {isSplitGroup ? (
1457 </TerminalActionButton>1532 <div className="flex h-5 items-center gap-1 px-1 text-[10px] font-medium text-muted-foreground">
1458 <TerminalActionButton1533 <SplitIcon className="size-3" />
1459 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${1534 <span>{splitVertically ? "Split down" : "Split right"}</span>
1460 hasReachedSplitLimit1535 </div>
1461 ? "cursor-not-allowed opacity-45 hover:bg-transparent"1536 ) : null}
1462 : "hover:bg-accent/70"1537 <div className={cn(isSplitGroup && "space-y-0.5")}>
1463 }`}1538 {terminalGroup.terminalIds.map((terminalId) => {
1464 onClick={onSplitTerminalVerticalAction}1539 const isActive = terminalId === resolvedActiveTerminalId;
1465 label={splitTerminalVerticalActionLabel}1540 const automaticLabel =
1466 >1541 automaticTerminalLabelById.get(terminalId) ?? "Terminal";
1467 <SquareSplitVertical className="size-3.25" />1542 const customLabel = terminalCustomLabels[terminalId]?.trim() ?? "";
1468 </TerminalActionButton>1543 const displayLabel = terminalLabelById.get(terminalId) ?? automaticLabel;
1469 <TerminalActionButton1544 const isRenaming = renamingTerminalId === terminalId;
1470 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1545 return (
1471 onClick={onNewTerminalAction}1546 <div
1472 label={newTerminalActionLabel}1547 key={terminalId}
1473 >1548 className={cn(
1474 <Plus className="size-3.25" />1549 "group/tab flex h-6 items-center gap-0.5 rounded-md pr-2 text-xs",
1475 </TerminalActionButton>1550 isSplitGroup ? "pl-0.5" : "pl-1.5",
1476 <TerminalActionButton1551 isActive
1477 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1552 ? "bg-accent text-foreground"
1478 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}1553 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
1479 label={closeTerminalActionLabel}1554 )}
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)}
1505 >1555 >
1506 Group {groupIndex + 1}1556 <button
1507 </button>1557 type="button"
1508 )}1558 className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
15091559 aria-label={`Close ${displayLabel}`}
1510 <div1560 onClick={() => onCloseTerminal(terminalId)}
1511 className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""}1561 >
1512 >1562 <TerminalSquare className="size-3 shrink-0 group-hover/tab:hidden group-focus-visible/close:hidden pointer-coarse:hidden" />
1513 {terminalGroup.terminalIds.map((terminalId) => {1563 <XIcon className="hidden size-3 group-hover/tab:block group-focus-visible/close:block pointer-coarse:block" />
1514 const isActive = terminalId === resolvedActiveTerminalId;1564 </button>
1515 const closeTerminalLabel = `Close ${1565 {isRenaming ? (
1516 terminalLabelById.get(terminalId) ?? "terminal"1566 <input
1517 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;1567 autoFocus
1518 return (1568 value={terminalRenameDraft}
1519 <div1569 aria-label="Rename terminal"
1520 key={terminalId}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"
1521 className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${1571 onFocus={(event) => event.currentTarget.select()}
1522 isActive1572 onChange={(event) =>
1523 ? "bg-accent text-foreground"1573 setTerminalRenameDraft(event.currentTarget.value)
1524 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"1574 }
1525 }`}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 }}
1526 >1605 >
1527 {showGroupHeaders && (1606 <span className="min-w-0 flex-1 truncate">
1528 <span className="text-[10px] text-muted-foreground/80">└</span>1607 {displayLabel}
1529 )}1608 {customLabel && customLabel !== automaticLabel ? (
1530 <button1609 <span className="font-normal text-muted-foreground/80">
1531 type="button"1610 {` · ${automaticLabel}`}
1532 className="flex min-w-0 flex-1 items-center gap-1 text-left"1611 </span>
1533 onClick={() => onActiveTerminalChange(terminalId)}1612 ) : null}
1534 >1613 </span>
1535 <TerminalSquare className="size-3 shrink-0" />1614 </button>
1536 <span className="truncate">1615 )}
1537 {terminalLabelById.get(terminalId) ?? "Terminal"}1616 </div>
1538 </span>1617 );
1539 </button>1618 })}
1540 {normalizedTerminalIds.length > 1 && (1619 </div>
1541 <Popover>1620 </div>
1542 <PopoverTrigger1621 );
1543 openOnHover1622 })}
1544 render={1623 </div>
1545 <button1624 </aside>
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>
1577 </div>1625 </div>
1578 </aside>1626 </aside>
1579 );1627 );
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", () => {...@@ -23,13 +23,9 @@ describe("ChangedFilesCard", () => {
23 expect(markup).toContain('data-changed-files-state="expanded"');23 expect(markup).toContain('data-changed-files-state="expanded"');
24 expect(markup).toContain('aria-expanded="true"');24 expect(markup).toContain('aria-expanded="true"');
25 expect(markup).toContain("whitespace-nowrap");25 expect(markup).toContain("whitespace-nowrap");
26 expect(markup).toContain(26 expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1');
27 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden',
28 );
29 expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap');27 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');28 expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>');
31 expect(markup).toContain("@[24rem]/changed-files:inline");
32 expect(markup).not.toContain("sm:inline");
33 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');29 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');
34 expect(markup).toContain("!size-[22px]");30 expect(markup).toContain("!size-[22px]");
35 expect(markup).toContain("size-3");31 expect(markup).toContain("size-3");
...@@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => {...@@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => {
38 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');34 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
39 expect(markup).toContain("1 changed file");35 expect(markup).toContain("1 changed file");
40 expect(markup).not.toContain("1 changed files");36 expect(markup).not.toContain("1 changed files");
37 expect(markup).not.toContain("Hide files");
38 expect(markup).not.toContain("ml-auto");
41 });39 });
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", () => {
44 const markup = renderToStaticMarkup(42 const markup = renderToStaticMarkup(
45 <ChangedFilesCard43 <ChangedFilesCard
46 turnId={TurnId.make("turn-1")}44 turnId={TurnId.make("turn-1")}
...@@ -67,14 +65,15 @@ describe("ChangedFilesCard", () => {...@@ -67,14 +65,15 @@ describe("ChangedFilesCard", () => {
6765
68 expect(markup).toContain('data-changed-files-state="preview"');66 expect(markup).toContain('data-changed-files-state="preview"');
69 expect(markup).toContain('aria-expanded="false"');67 expect(markup).toContain('aria-expanded="false"');
70 expect(markup).toContain("apps");68 expect(markup).toContain("apps/web/src/");
71 expect(markup).toContain("2 files");69 expect(markup).toContain("packages/shared/src/");
72 expect(markup).toContain("packages");
73 expect(markup).toContain("root");
74 expect(markup).toContain("App.tsx");70 expect(markup).toContain("App.tsx");
75 expect(markup).toContain("git.ts");71 expect(markup).toContain("git.ts");
76 expect(markup).toContain("README.md");72 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"');
78 expect(markup).not.toContain("App.test.tsx");77 expect(markup).not.toContain("App.test.tsx");
79 });78 });
8079
apps/web/src/components/chat/ChangedFilesTree.tsx+23-39
...@@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
19import { PierreEntryIcon } from "./PierreEntryIcon";19import { PierreEntryIcon } from "./PierreEntryIcon";
20import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import {22import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation";
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
2723
28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};24const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2925
...@@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
50 onOpenTurnDiff,46 onOpenTurnDiff,
51 } = props;47 } = props;
52 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);48 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
54 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);49 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
55 const compactPreviewVisible = showCompactPreview && !expanded;50 const compactPreviewVisible = showCompactPreview && !expanded;
5651
57 return (52 return (
58 <div53 <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"
60 data-changed-files-state={55 data-changed-files-state={
61 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"56 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
62 }57 }
...@@ -64,7 +59,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -64,7 +59,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
64 <div59 <div
65 data-changed-files-header=""60 data-changed-files-header=""
66 className={cn(61 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",
68 expanded &&63 expanded &&
69 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",64 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
70 )}65 )}
...@@ -73,7 +68,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -73,7 +68,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
73 type="button"68 type="button"
74 aria-expanded={expanded}69 aria-expanded={expanded}
75 data-scroll-anchor-ignore70 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"
77 onClick={() => onExpandedChange(!expanded)}72 onClick={() => onExpandedChange(!expanded)}
78 >73 >
79 <ChevronRightIcon74 <ChevronRightIcon
...@@ -96,9 +91,6 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -96,9 +91,6 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
96 />91 />
97 )}92 )}
98 </span>93 </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>
102 </button>94 </button>
103 <div className="flex shrink-0 items-center gap-1.5">95 <div className="flex shrink-0 items-center gap-1.5">
104 {expanded ? (96 {expanded ? (
...@@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
158 onOpenTurnDiff={onOpenTurnDiff}150 onOpenTurnDiff={onOpenTurnDiff}
159 />151 />
160 ) : compactPreviewVisible ? (152 ) : compactPreviewVisible ? (
161 <div className="px-2 pb-1.5 pt-1">153 <div className="px-1.5 pb-1 pt-0.5">
162 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">154 <div className="flex min-w-0 items-center gap-x-3 overflow-hidden">
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">
174 {previewFiles.map((file) => (155 {previewFiles.map((file) => (
175 <button156 <button
176 key={file.path}157 key={file.path}
177 type="button"158 type="button"
178 title={file.path}159 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"
180 onClick={() => onOpenTurnDiff(turnId, file.path)}161 onClick={() => onOpenTurnDiff(turnId, file.path)}
181 >162 >
182 <PierreEntryIcon163 <PierreEntryIcon
183 pathValue={file.path}164 pathValue={file.path}
184 kind="file"165 kind="file"
185 theme={resolvedTheme}166 theme={resolvedTheme}
186 className="size-3 shrink-0 text-muted-foreground/70"167 className="size-3.5 shrink-0 text-muted-foreground/70"
187 />168 />
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}
189 </button>180 </button>
190 ))}181 ))}
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>
198 </div>182 </div>
199 </div>183 </div>
200 ) : null}184 ) : null}
...@@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
270 ) : (254 ) : (
271 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />255 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
272 )}256 )}
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">
274 {node.name}258 {node.name}
275 </span>259 </span>
276 {hasNonZeroStat(node.stat) && (260 {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">
278 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />262 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
279 </span>263 </span>
280 )}264 )}
...@@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
305 theme={resolvedTheme}289 theme={resolvedTheme}
306 className="size-3.5 text-muted-foreground/70"290 className="size-3.5 text-muted-foreground/70"
307 />291 />
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">
309 {node.name}293 {node.name}
310 </span>294 </span>
311 {node.stat && (295 {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">
313 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />297 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
314 </span>298 </span>
315 )}299 )}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+209-7
...@@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => {...@@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => {
531531
532 expect(expandedRows.map((row) => row.id)).toEqual([532 expect(expandedRows.map((row) => row.id)).toEqual([
533 "user-entry",533 "user-entry",
534 "turn-fold:turn-1",
535 "assistant-thought-entry",534 "assistant-thought-entry",
536 "work-entry-1",535 "work-toggle:work-entry-1",
536 "turn-fold:turn-1",
537 "assistant-final-entry",537 "assistant-final-entry",
538 ]);538 ]);
539 expect(539 expect(
...@@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => {...@@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => {
638 expect(foldRow?.label).toBe("Worked for 12s");638 expect(foldRow?.label).toBe("Worked for 12s");
639 });639 });
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
641 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {719 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
642 const rows = deriveMessagesTimelineRows({720 const rows = deriveMessagesTimelineRows({
643 timelineEntries: [721 timelineEntries: [
...@@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => {
771 turnId: "turn-1" as never,849 turnId: "turn-1" as never,
772 label: "Ran command",850 label: "Ran command",
773 tone: "tool" as const,851 tone: "tool" as const,
852 toolLifecycleStatus: "inProgress" as const,
774 },853 },
775 },854 },
776 ],855 ],
...@@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => {...@@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => {
788867
789 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);868 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
790 expect(rows.map((row) => row.id)).toEqual([869 expect(rows.map((row) => row.id)).toEqual([
870 "working-indicator-row",
791 "assistant-thought-entry",871 "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([
793 "working-indicator-row",927 "working-indicator-row",
928 "work-live:tool:call-1",
794 ]);929 ]);
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);
795 });997 });
796998
797 it("does not fold the session's running turn when latestTurn regresses", () => {999 it("does not fold the session's running turn when latestTurn regresses", () => {
...@@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => {
852 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([1054 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
853 "turn-1",1055 "turn-1",
854 ]);1056 ]);
855 expect(rows.map((row) => row.id)).toContain("running-work-entry");1057 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
856 });1058 });
8571059
858 it("only shows assistant metadata on the terminal assistant message", () => {1060 it("only shows assistant metadata on the terminal assistant message", () => {
...@@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => {
994 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),1196 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
995 });1197 });
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"]);
998 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1200 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
999 groupId: "work-group:work-entry-1",1201 groupId: "work-group:work-entry-1",
1000 hiddenCount: 2,1202 hiddenCount: 3,
1001 expanded: false,1203 expanded: false,
1002 onlyToolEntries: true,1204 onlyToolEntries: true,
1003 });1205 });
1004 expect(expandedRows.map((row) => row.id)).toEqual([1206 expect(expandedRows.map((row) => row.id)).toEqual([
1207 "work-toggle:work-entry-1",
1005 "work-1",1208 "work-1",
1006 "work-2",1209 "work-2",
1007 "work-3",1210 "work-3",
1008 "work-toggle:work-entry-1",
1009 ]);1211 ]);
1010 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1212 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1011 expanded: true,1213 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+382-30
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import {2import {
3 formatDuration,3 formatDuration,
4 workEntryIndicatesToolFailure,
4 workEntryIndicatesToolNeutralStatus,5 workEntryIndicatesToolNeutralStatus,
5 workLogEntryIsToolLike,6 workLogEntryIsToolLike,
6 type TimelineEntry,7 type TimelineEntry,
...@@ -166,6 +167,17 @@ export type MessagesTimelineRow =...@@ -166,6 +167,17 @@ export type MessagesTimelineRow =
166 id: string;167 id: string;
167 createdAt: string;168 createdAt: string;
168 groupedEntries: WorkLogEntry[];169 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;
169 }181 }
170 | {182 | {
171 kind: "work-toggle";183 kind: "work-toggle";
...@@ -175,6 +187,9 @@ export type MessagesTimelineRow =...@@ -175,6 +187,9 @@ export type MessagesTimelineRow =
175 hiddenCount: number;187 hiddenCount: number;
176 expanded: boolean;188 expanded: boolean;
177 onlyToolEntries: boolean;189 onlyToolEntries: boolean;
190 summary: string | null;
191 summaryKind: ToolGroupAction | "mixed" | null;
192 hasFailure: boolean;
178 }193 }
179 | {194 | {
180 kind: "turn-fold";195 kind: "turn-fold";
...@@ -208,7 +223,12 @@ export type MessagesTimelineRow =...@@ -208,7 +223,12 @@ export type MessagesTimelineRow =
208 createdAt: string;223 createdAt: string;
209 turnPlan: TurnPlanEntry;224 turnPlan: TurnPlanEntry;
210 }225 }
211 | { kind: "working"; id: string; createdAt: string | null };226 | {
227 kind: "working";
228 id: string;
229 createdAt: string | null;
230 showThinking: boolean;
231 };
212232
213export interface StableMessagesTimelineRowsState {233export interface StableMessagesTimelineRowsState {
214 byId: Map<string, MessagesTimelineRow>;234 byId: Map<string, MessagesTimelineRow>;
...@@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string {
238 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();258 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
239}259}
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
241export function resolveAssistantMessageCopyState({345export function resolveAssistantMessageCopyState({
242 text,346 text,
243 showCopyButton,347 showCopyButton,
...@@ -310,17 +414,34 @@ function deriveUnsettledTurnId(...@@ -310,17 +414,34 @@ function deriveUnsettledTurnId(
310 return isSettled ? null : latestTurn.turnId;414 return isSettled ? null : latestTurn.turnId;
311}415}
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
313/**433/**
314 * Settled turns fold their commentary and tool activity behind a434 * Settled turns fold their commentary and tool activity behind a
315 * "Worked for ..." row anchored at the turn's first foldable entry; the435 * "Worked for ..." row placed immediately before the next terminal assistant
316 * terminal assistant message stays visible below the fold.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.
317 */438 */
318function deriveTurnFolds(input: {439function deriveTurnFolds(input: {
319 timelineEntries: ReadonlyArray<TimelineEntry>;440 timelineEntries: ReadonlyArray<TimelineEntry>;
320 terminalAssistantMessageIds: ReadonlySet<string>;441 terminalAssistantMessageIds: ReadonlySet<string>;
321 latestTurn: TimelineLatestTurn | null;442 latestTurn: TimelineLatestTurn | null;
322 unsettledTurnId: TurnId | null;443 unsettledTurnId: TurnId | null;
323}): ReadonlyMap<string, TurnFold> {444}): ReadonlyMap<string, ReadonlyArray<TurnFold>> {
324 interface TurnGroup {445 interface TurnGroup {
325 entries: Array<TimelineEntry>;446 entries: Array<TimelineEntry>;
326 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;447 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
...@@ -375,7 +496,7 @@ function deriveTurnFolds(input: {...@@ -375,7 +496,7 @@ function deriveTurnFolds(input: {
375 }496 }
376 }497 }
377498
378 const foldsByAnchorEntryId = new Map<string, TurnFold>();499 const foldsByAnchorEntryId = new Map<string, TurnFold[]>();
379 for (const [turnId, group] of groupsByTurnId) {500 for (const [turnId, group] of groupsByTurnId) {
380 if (turnId === input.unsettledTurnId) {501 if (turnId === input.unsettledTurnId) {
381 continue;502 continue;
...@@ -405,6 +526,24 @@ function deriveTurnFolds(input: {...@@ -405,6 +526,24 @@ function deriveTurnFolds(input: {
405 if (!firstEntry || !lastEntry) {526 if (!firstEntry || !lastEntry) {
406 continue;527 continue;
407 }528 }
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
409 const isLatestInterruptedTurn =548 const isLatestInterruptedTurn =
410 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";549 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
...@@ -431,13 +570,16 @@ function deriveTurnFolds(input: {...@@ -431,13 +570,16 @@ function deriveTurnFolds(input: {
431 ? `Worked for ${duration}`570 ? `Worked for ${duration}`
432 : "Worked";571 : "Worked";
433572
434 foldsByAnchorEntryId.set(firstEntry.id, {573 const fold = {
435 turnId,574 turnId,
436 anchorEntryId: firstEntry.id,575 anchorEntryId: anchorEntry.id,
437 createdAt: firstEntry.createdAt,576 createdAt: anchorEntry.createdAt,
438 hiddenEntryIds,577 hiddenEntryIds,
439 label,578 label,
440 });579 };
580 const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id);
581 if (anchoredFolds) anchoredFolds.push(fold);
582 else foldsByAnchorEntryId.set(anchorEntry.id, [fold]);
441 }583 }
442 return foldsByAnchorEntryId;584 return foldsByAnchorEntryId;
443}585}
...@@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: {...@@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: {
469 unsettledTurnId,611 unsettledTurnId,
470 });612 });
471 const collapsedEntryIds = new Set<string>();613 const collapsedEntryIds = new Set<string>();
472 for (const fold of foldsByAnchorEntryId.values()) {614 for (const folds of foldsByAnchorEntryId.values()) {
473 if (!input.expandedTurnIds?.has(fold.turnId)) {615 for (const fold of folds) {
474 for (const entryId of fold.hiddenEntryIds) {616 if (!input.expandedTurnIds?.has(fold.turnId)) {
475 collapsedEntryIds.add(entryId);617 for (const entryId of fold.hiddenEntryIds) {
618 collapsedEntryIds.add(entryId);
619 }
476 }620 }
477 }621 }
478 }622 }
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
480 for (let index = 0; index < input.timelineEntries.length; index += 1) {739 for (let index = 0; index < input.timelineEntries.length; index += 1) {
481 const timelineEntry = input.timelineEntries[index];740 const timelineEntry = input.timelineEntries[index];
482 if (!timelineEntry) {741 if (!timelineEntry) {
483 continue;742 continue;
484 }743 }
485744
486 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);745 if (input.isWorking && index === activeTurnHeaderIndex) {
487 if (turnFold) {
488 nextRows.push({746 nextRows.push({
489 kind: "turn-fold",747 kind: "working",
490 id: `turn-fold:${turnFold.turnId}`,748 id: "working-indicator-row",
491 createdAt: turnFold.createdAt,749 createdAt: input.activeTurnStartedAt,
492 turnId: turnFold.turnId,750 showThinking: !activeTurnHasVisibleContent,
493 label: turnFold.label,
494 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
495 });751 });
496 }752 }
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
498 if (collapsedEntryIds.has(timelineEntry.id)) {768 if (collapsedEntryIds.has(timelineEntry.id)) {
499 continue;769 continue;
500 }770 }
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
502 if (timelineEntry.kind === "work") {792 if (timelineEntry.kind === "work") {
503 const groupedEntries = [timelineEntry.entry];793 const groupedEntries = [timelineEntry.entry];
504 let cursor = index + 1;794 let cursor = index + 1;
...@@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: {
507 if (797 if (
508 !nextEntry ||798 !nextEntry ||
509 nextEntry.kind !== "work" ||799 nextEntry.kind !== "work" ||
800 activeWorkEntryIds.has(nextEntry.id) ||
510 collapsedEntryIds.has(nextEntry.id) ||801 collapsedEntryIds.has(nextEntry.id) ||
511 foldsByAnchorEntryId.has(nextEntry.id)802 foldsByAnchorEntryId.has(nextEntry.id)
512 ) {803 ) {
...@@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: {...@@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: {
519 (entry) => !workEntryIndicatesToolNeutralStatus(entry),810 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
520 );811 );
521 if (visibleGroupedEntries.length > 0) {812 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) {
523 nextRows.push({845 nextRows.push({
524 kind: "work",846 kind: "work",
525 id: timelineEntry.id,847 id: timelineEntry.id,
526 createdAt: timelineEntry.createdAt,848 createdAt: timelineEntry.createdAt,
527 groupedEntries: visibleGroupedEntries,849 groupedEntries: visibleGroupedEntries,
850 isExpandedToolGroupEntry: false,
851 isLastExpandedToolGroupEntry: false,
528 });852 });
529 } else {853 } else {
530 const groupId = `work-group:${timelineEntry.id}`;854 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
531 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;855 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
532 // Agent-spawn CTA rows are always visible: a running fleet must856 // Agent-spawn CTA rows are always visible: a running fleet must
533 // never hide behind a "+N tool calls" toggle. Selection is by857 // never hide behind a "+N tool calls" toggle. Selection is by
...@@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: {
551 id: workEntry.id,875 id: workEntry.id,
552 createdAt: workEntry.createdAt,876 createdAt: workEntry.createdAt,
553 groupedEntries: [workEntry],877 groupedEntries: [workEntry],
878 isExpandedToolGroupEntry: false,
879 isLastExpandedToolGroupEntry: false,
554 });880 });
555 }881 }
556882
...@@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: {
562 groupId,888 groupId,
563 hiddenCount: hiddenEntries.length,889 hiddenCount: hiddenEntries.length,
564 expanded,890 expanded,
565 onlyToolEntries: visibleGroupedEntries.every((entry) =>891 onlyToolEntries,
566 workLogEntryIsToolLike(entry),892 summary: null,
893 summaryKind: null,
894 hasFailure: visibleGroupedEntries.some((entry) =>
895 workEntryIndicatesToolFailure(entry),
567 ),896 ),
568 });897 });
569 }898 }
...@@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: {...@@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: {
629 });958 });
630 }959 }
631960
632 if (input.isWorking) {961 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
633 nextRows.push({962 nextRows.push({
634 kind: "working",963 kind: "working",
635 id: "working-indicator-row",964 id: "working-indicator-row",
636 createdAt: input.activeTurnStartedAt,965 createdAt: input.activeTurnStartedAt,
966 showThinking: !activeTurnHasVisibleContent,
637 });967 });
638 }968 }
639969
...@@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
666996
667 switch (a.kind) {997 switch (a.kind) {
668 case "working":998 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
671 case "turn-fold": {1003 case "turn-fold": {
672 const bf = b as typeof a;1004 const bf = b as typeof a;
...@@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
683 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;1015 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
684 }1016 }
6851017
686 case "work":1018 case "work": {
687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);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
689 case "work-toggle": {1038 case "work-toggle": {
690 const bw = b as typeof a;1039 const bw = b as typeof a;
...@@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
693 a.groupId === bw.groupId &&1042 a.groupId === bw.groupId &&
694 a.hiddenCount === bw.hiddenCount &&1043 a.hiddenCount === bw.hiddenCount &&
695 a.expanded === bw.expanded &&1044 a.expanded === bw.expanded &&
696 a.onlyToolEntries === bw.onlyToolEntries1045 a.onlyToolEntries === bw.onlyToolEntries &&
1046 a.summary === bw.summary &&
1047 a.summaryKind === bw.summaryKind &&
1048 a.hasFailure === bw.hasFailure
697 );1049 );
698 }1050 }
6991051
apps/web/src/components/chat/MessagesTimeline.test.tsx+44-2
...@@ -554,7 +554,49 @@ describe("MessagesTimeline", () => {...@@ -554,7 +554,49 @@ describe("MessagesTimeline", () => {
554 expect(markup).toContain("Work Log");554 expect(markup).toContain("Work Log");
555 });555 });
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", () => {
558 const markup = renderToStaticMarkup(600 const markup = renderToStaticMarkup(
559 <MessagesTimeline601 <MessagesTimeline
560 {...buildProps()}602 {...buildProps()}
...@@ -576,7 +618,7 @@ describe("MessagesTimeline", () => {...@@ -576,7 +618,7 @@ describe("MessagesTimeline", () => {
576 />,618 />,
577 );619 );
578620
579 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");621 expect(markup).toContain("Changed 1 file");
580 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");622 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
581 });623 });
582624
apps/web/src/components/chat/MessagesTimeline.tsx+336-155
...@@ -35,7 +35,6 @@ import {...@@ -35,7 +35,6 @@ import {
35 deriveTimelineEntries,35 deriveTimelineEntries,
36 workEntryIndicatesToolFailure,36 workEntryIndicatesToolFailure,
37 workEntryIndicatesToolNeutralStatus,37 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
39 workLogEntryIsToolLike,38 workLogEntryIsToolLike,
40} from "../../session-logic";39} from "../../session-logic";
41import { type TurnDiffSummary } from "../../types";40import { type TurnDiffSummary } from "../../types";
...@@ -57,7 +56,6 @@ import {...@@ -57,7 +56,6 @@ import {
57 MessageCircleIcon,56 MessageCircleIcon,
58 MousePointerClickIcon,57 MousePointerClickIcon,
59 PaintbrushIcon,58 PaintbrushIcon,
60 MinusIcon,
61 SquarePenIcon,59 SquarePenIcon,
62 TerminalIcon,60 TerminalIcon,
63 Undo2Icon,61 Undo2Icon,
...@@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped...@@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
920type TimelineRow = MessagesTimelineRow;918type TimelineRow = MessagesTimelineRow;
921919
922const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {920const 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
923 return (927 return (
924 <div928 <div
925 className={cn(929 className={cn(
926 // Commentary (non-terminal assistant) rows carry no metadata row, so930 // Commentary (non-terminal assistant) rows carry no metadata row, so
927 // they sit closer to the work that follows them.931 // they sit closer to the work that follows them.
928 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||932 isExpandedToolGroupEntry
929 row.kind === "work" ||933 ? isLastExpandedToolGroupEntry
930 row.kind === "work-toggle" ||934 ? "pb-1"
931 row.kind === "turn-plan"935 : "pb-0"
932 ? "pb-2"936 : isExpandedToolGroupHeader
933 : "pb-4",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",
934 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,949 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
935 )}950 )}
936 data-timeline-row-id={row.id}951 data-timeline-row-id={row.id}
...@@ -938,7 +953,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -938,7 +953,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
938 data-message-id={row.kind === "message" ? row.message.id : undefined}953 data-message-id={row.kind === "message" ? row.message.id : undefined}
939 data-message-role={row.kind === "message" ? row.message.role : undefined}954 data-message-role={row.kind === "message" ? row.message.role : undefined}
940 >955 >
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}
942 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}963 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
943 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}964 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
944 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}965 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
...@@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {...@@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
10831104
1084function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {1105function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
1085 const ctx = use(TimelineRowCtx);1106 const ctx = use(TimelineRowCtx);
1086 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
10871107
1088 return (1108 return (
1089 <div className="border-b border-border/60 pb-2 pt-1">1109 <div className="border-b border-border/60 pb-2 pt-1">
...@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-...@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
1092 aria-expanded={row.expanded}1112 aria-expanded={row.expanded}
1093 data-scroll-anchor-ignore1113 data-scroll-anchor-ignore
1094 onClick={() => ctx.onToggleTurnFold(row.turnId)}1114 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"
1096 >1116 >
1097 <span>{row.label}</span>1117 <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 />
1099 </button>1121 </button>
1100 </div>1122 </div>
1101 );1123 );
...@@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1278});1300});
12791301
1280function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1302function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1281 const { workingStepLabel } = use(TimelineRowActivityCtx);
1282 return (1303 return (
1283 <div className="py-0.5 pl-1.5">1304 <div>
1284 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">1305 <div className="border-b border-border/60 pb-2 pt-1">
1285 <span className="inline-flex items-center gap-[3px]">1306 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
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">
1291 {row.createdAt ? (1307 {row.createdAt ? (
1292 <>1308 <>
1293 Working for <WorkingTimer createdAt={row.createdAt} />1309 Working for <WorkingTimer createdAt={row.createdAt} />
...@@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1295 ) : (1311 ) : (
1296 "Working..."1312 "Working..."
1297 )}1313 )}
1298 </span>1314 </div>
1299 {workingStepLabel ? (
1300 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1301 ) : null}
1302 </div>1315 </div>
1316 {row.showThinking ? (
1317 <div className="mt-2">
1318 <ThinkingActivityRow />
1319 </div>
1320 ) : null}
1303 </div>1321 </div>
1304 );1322 );
1305}1323}
...@@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
1340/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */1358/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
1341const WorkGroupSection = memo(function WorkGroupSection({1359const WorkGroupSection = memo(function WorkGroupSection({
1342 groupedEntries,1360 groupedEntries,
1361 isExpandedToolGroupEntry,
1343}: {1362}: {
1344 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];1363 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1364 isExpandedToolGroupEntry: boolean;
1345}) {1365}) {
1346 const { workspaceRoot } = use(TimelineRowCtx);1366 const { workspaceRoot } = use(TimelineRowCtx);
1347 const nonEmptyEntries = useMemo(1367 const nonEmptyEntries = useMemo(
...@@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({
1358 if (nonEmptyEntries.length === 0) return null;1378 if (nonEmptyEntries.length === 0) return null;
13591379
1360 return (1380 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 >
1362 {!onlyToolEntries && (1385 {!onlyToolEntries && (
1363 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>1386 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1364 )}1387 )}
...@@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
1368 key={workEntry.id}1391 key={workEntry.id}
1369 workEntry={workEntry}1392 workEntry={workEntry}
1370 workspaceRoot={workspaceRoot}1393 workspaceRoot={workspaceRoot}
1394 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
1371 />1395 />
1372 ))}1396 ))}
1373 </div>1397 </div>
...@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({
1375 );1399 );
1376});1400});
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
1378function WorkGroupToggleTimelineRow({1490function WorkGroupToggleTimelineRow({
1379 row,1491 row,
1380}: {1492}: {
1381 row: Extract<TimelineRow, { kind: "work-toggle" }>;1493 row: Extract<TimelineRow, { kind: "work-toggle" }>;
1382}) {1494}) {
1383 const ctx = use(TimelineRowCtx);1495 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 }
1384 const labelNoun = row.onlyToolEntries1524 const labelNoun = row.onlyToolEntries
1385 ? row.hiddenCount === 11525 ? row.hiddenCount === 1
1386 ? "tool call"1526 ? "tool call"
...@@ -2019,32 +2159,101 @@ function workEntryPreview(...@@ -2019,32 +2159,101 @@ function workEntryPreview(
2019 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;2159 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
2020}2160}
20212161
2022function workEntryRawCommand(2162type CommandWrapper = "env" | "sudo";
2023 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,2163
2024): string | null {2164const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
2025 const rawCommand = workEntry.rawCommand?.trim();2165 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
2026 if (!rawCommand || !workEntry.command) {2166 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
2027 return null;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;
2028 }2223 }
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);
2030}2240}
20312241
2032function buildToolCallExpandedBody(2242function buildToolCallExpandedBody(
2033 workEntry: TimelineWorkEntry,2243 workEntry: TimelineWorkEntry,
2034 workspaceRoot: string | undefined,2244 workspaceRoot: string | undefined,
2035): string | null {2245): string | null {
2246 const command = workEntry.rawCommand?.trim() || workEntry.command?.trim();
2036 const blocks: string[] = [];2247 const blocks: string[] = [];
2248 if (command) {
2249 blocks.push(command);
2250 }
2037 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {2251 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
2038 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);2252 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
2039 }2253 }
2040 const raw = workEntryRawCommand(workEntry);2254 const detail = workEntry.detail?.trim();
2041 if (raw?.trim()) {2255 if (detail && detail !== command) {
2042 blocks.push(raw.trim());2256 blocks.push(detail);
2043 } else if (workEntry.command?.trim()) {
2044 blocks.push(workEntry.command.trim());
2045 }
2046 if (workEntry.detail?.trim()) {
2047 blocks.push(workEntry.detail.trim());
2048 }2257 }
2049 const changedFiles = workEntry.changedFiles ?? [];2258 const changedFiles = workEntry.changedFiles ?? [];
2050 if (changedFiles.length > 0) {2259 if (changedFiles.length > 0) {
...@@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
2180 : "working"2389 : "working"
2181 : failed > 02390 : failed > 0
2182 ? `${failed} failed`2391 ? `${failed} failed`
2183 : "✓ completed";2392 : "Completed";
21842393
2185 return (2394 return (
2186 <button2395 <div className="-mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32">
2187 type="button"2396 <div className="flex items-center justify-between gap-2 rounded-xl px-1">
2188 onClick={onOpenAgents}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">
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"2398 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2190 >2399 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2191 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />2400 <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>
2192 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />2401 {workflowName ? (
2193 <span className="min-w-0 truncate">2402 <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground md:inline">
2194 <span className="font-medium">{lead}</span>2403 {workflowName}
2195 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}2404 </span>
2196 </span>2405 ) : null}
2197 <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">2406 <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground sm:flex">
2198 <span>{status}</span>2407 <span>{status}</span>
2199 {totalTokens > 0 ? (2408 {totalTokens > 0 ? (
2200 <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>2409 <span className="font-mono tabular-nums">
2201 ) : null}2410 Σ {formatSubagentTokenCount(totalTokens)}
2202 <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>2411 </span>
2203 </span>2412 ) : null}
2204 </button>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>
2205 );2427 );
2206});2428});
22072429
2208const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {2430const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
2209 workEntry: TimelineWorkEntry;2431 workEntry: TimelineWorkEntry;
2210 workspaceRoot: string | undefined;2432 workspaceRoot: string | undefined;
2433 isExpandedToolGroupEntry: boolean;
2211}) {2434}) {
2212 const { workEntry, workspaceRoot } = props;2435 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2213 // Before any hooks: spawn CTA rows render their own component.2436 // Before any hooks: spawn CTA rows render their own component.
2214 if (workEntry.agentSpawn) {2437 if (workEntry.agentSpawn) {
2215 return <AgentSpawnCtaRow workEntry={workEntry} />;2438 return <AgentSpawnCtaRow workEntry={workEntry} />;
2216 }2439 }
2217 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;2440 return (
2441 <PlainWorkEntryRow
2442 workEntry={workEntry}
2443 workspaceRoot={workspaceRoot}
2444 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2445 />
2446 );
2218});2447});
22192448
2220const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {2449const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2221 workEntry: TimelineWorkEntry;2450 workEntry: TimelineWorkEntry;
2222 workspaceRoot: string | undefined;2451 workspaceRoot: string | undefined;
2452 isExpandedToolGroupEntry: boolean;
2223}) {2453}) {
2224 const { workEntry, workspaceRoot } = props;2454 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2225 const activity = use(TimelineRowActivityCtx);
2226 const [expanded, setExpanded] = useState(false);2455 const [expanded, setExpanded] = useState(false);
2227 const iconConfig = workToneIcon(workEntry.tone);2456 const iconConfig = workToneIcon(workEntry.tone);
2228 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2457 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2229 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);2458 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2230 const heading = toolWorkEntryHeading(workEntry);2459 const entryIconName =
2231 const rawPreview = workEntryPreview(workEntry, workspaceRoot);2460 showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);
2232 const preview =2461 const isCommandEntry =
2233 rawPreview &&2462 workEntry.requestKind === "command" ||
2234 normalizeCompactToolLabel(rawPreview).toLowerCase() ===2463 workEntry.itemType === "command_execution" ||
2235 normalizeCompactToolLabel(heading).toLowerCase()2464 Boolean(workEntry.command);
2236 ? null2465 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2237 : rawPreview;
2238 const displayText = preview ? `${heading} - ${preview}` : heading;
2239 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2466 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
2240 const canExpand = expandedBody !== null;2467 const canExpand = expandedBody !== null;
2241 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2242 const showDestructiveRowStyle =2468 const showDestructiveRowStyle =
2243 showFailedIndicator &&2469 showFailedIndicator &&
2244 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));2470 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
2245 const iconWrapperClass = cn(2471 const iconWrapperClass = cn(
2246 "flex size-5 shrink-0 items-center justify-center",2472 "flex size-6 shrink-0 items-center justify-center",
2247 showWarningIndicator2473 showWarningIndicator || showFailedIndicator
2248 ? "text-destructive"2474 ? "text-destructive"
2249 : showDestructiveRowStyle2475 : showDestructiveRowStyle
2250 ? "text-destructive"2476 ? "text-destructive"
...@@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2256 ? "font-medium text-warning"2482 ? "font-medium text-warning"
2257 : showDestructiveRowStyle2483 : showDestructiveRowStyle
2258 ? "font-medium text-destructive"2484 ? "font-medium text-destructive"
2259 : "font-medium text-foreground";2485 : workLogEntryIsToolLike(workEntry)
2260 const turnSettled = !activity.activeTurnInProgress;2486 ? "text-secondary-label"
2261 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);2487 : "text-foreground/80";
2262 const showSuccessIndicator =2488 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
2263 workEntryIndicatesToolSuccess(workEntry) ||
2264 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
2265 const rowToggleProps = canExpand2489 const rowToggleProps = canExpand
2266 ? {2490 ? {
2267 role: "button" as const,2491 role: "button" as const,
2268 tabIndex: 0 as const,2492 tabIndex: 0 as const,
2269 "aria-label": displayText,2493 "aria-label": displayText,
2494 "aria-expanded": expanded,
2270 onClick: () => setExpanded((v) => !v),2495 onClick: () => setExpanded((v) => !v),
2271 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {2496 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
2272 if (e.key === "Enter" || e.key === " ") {2497 if (e.key === "Enter" || e.key === " ") {
...@@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2280 return (2505 return (
2281 <div2506 <div
2282 className={cn(2507 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",
2284 canExpand &&2510 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",
2286 )}2512 )}
2287 {...rowToggleProps}2513 {...rowToggleProps}
2288 >2514 >
2289 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">2515 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
2290 <span className={iconWrapperClass}>2516 {showEntryIcon ? (
2291 <WorkEntryIconSvg2517 <span
2292 name={entryIconName}2518 className={iconWrapperClass}
2293 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"2519 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
2294 />2520 >
2295 </span>2521 <WorkEntryIconSvg
2522 name={entryIconName}
2523 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
2524 />
2525 </span>
2526 ) : null}
2296 <div className="flex min-w-0 flex-1 items-center gap-1.5">2527 <div className="flex min-w-0 flex-1 items-center gap-1.5">
2297 <div className="min-w-0 flex-1 overflow-hidden">2528 <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">2529 <p
2299 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>2530 className={cn(
2300 {preview && (2531 "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed",
2301 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>2532 isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm",
2302 )}2533 )}
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}
2309 >2534 >
2310 {canExpand ? (2535 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
2311 <ChevronDownIcon2536 </p>
2312 className={cn(
2313 "size-3 shrink-0 opacity-70 transition-transform duration-200",
2314 expanded && "rotate-180",
2315 )}
2316 aria-hidden
2317 />
2318 ) : null}
2319 </span>
2320 <span className="flex size-4 shrink-0 items-center justify-center">
2321 {showFailedIndicator ? (
2322 <Tooltip>
2323 <TooltipTrigger
2324 render={
2325 <span
2326 className="flex size-4 items-center justify-center"
2327 aria-label="Tool call failed"
2328 />
2329 }
2330 >
2331 <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden />
2332 </TooltipTrigger>
2333 <TooltipPopup>Failed</TooltipPopup>
2334 </Tooltip>
2335 ) : showSuccessIndicator ? (
2336 <Tooltip>
2337 <TooltipTrigger
2338 render={<span className="flex size-4 items-center justify-center" />}
2339 >
2340 <span className="inline-flex size-4 items-center justify-center">
2341 <CheckIcon
2342 className="block size-3 shrink-0 stroke-current"
2343 stroke="currentColor"
2344 aria-hidden
2345 />
2346 </span>
2347 </TooltipTrigger>
2348 <TooltipPopup>Completed</TooltipPopup>
2349 </Tooltip>
2350 ) : showNeutralIndicator ? (
2351 <Tooltip>
2352 <TooltipTrigger
2353 render={<span className="flex size-4 items-center justify-center" />}
2354 >
2355 <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden />
2356 </TooltipTrigger>
2357 <TooltipPopup>Empty</TooltipPopup>
2358 </Tooltip>
2359 ) : null}
2360 </span>
2361 </div>2537 </div>
2362 </div>2538 </div>
2363 </div>2539 </div>
2364 {expanded && canExpand && expandedBody ? (2540 {expanded && canExpand && expandedBody ? (
2365 <div2541 <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"
2367 onClick={stopRowToggle}2543 onClick={stopRowToggle}
2368 onPointerDown={stopRowToggle}2544 onPointerDown={stopRowToggle}
2369 >2545 >
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 >
2371 {expandedBody}2552 {expandedBody}
2372 </pre>2553 </pre>
2373 </div>2554 </div>
apps/web/src/components/chat/PanelLayoutControls.tsx+13-5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";1import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";
2import { memo } from "react";2import { memo } from "react";
33
4import { cn } from "../../lib/utils";
4import { Toggle } from "../ui/toggle";5import { Toggle } from "../ui/toggle";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67
...@@ -12,6 +13,7 @@ interface PanelLayoutControlsProps {...@@ -12,6 +13,7 @@ interface PanelLayoutControlsProps {
12 rightPanelAvailable: boolean;13 rightPanelAvailable: boolean;
13 rightPanelOpen: boolean;14 rightPanelOpen: boolean;
14 rightPanelShortcutLabel: string | null;15 rightPanelShortcutLabel: string | null;
16 rightPanelUnavailableLabel?: string;
15 /** Running + waiting subagents in this thread; badges the right panel toggle. */17 /** Running + waiting subagents in this thread; badges the right panel toggle. */
16 liveAgentCount: number;18 liveAgentCount: number;
17 onToggleTerminal: () => void;19 onToggleTerminal: () => void;
...@@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
26 rightPanelAvailable,28 rightPanelAvailable,
27 rightPanelOpen,29 rightPanelOpen,
28 rightPanelShortcutLabel,30 rightPanelShortcutLabel,
31 rightPanelUnavailableLabel = "Right panel is unavailable",
29 liveAgentCount,32 liveAgentCount,
30 onToggleTerminal,33 onToggleTerminal,
31 onToggleRightPanel,34 onToggleRightPanel,
...@@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
40 <TooltipTrigger43 <TooltipTrigger
41 render={44 render={
42 <Toggle45 <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]"
44 pressed={terminalOpen}47 pressed={terminalOpen}
45 onPressedChange={onToggleTerminal}48 onPressedChange={onToggleTerminal}
46 aria-label="Toggle terminal drawer"49 aria-label="Toggle terminal drawer"
...@@ -63,7 +66,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -63,7 +66,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
63 <TooltipTrigger66 <TooltipTrigger
64 render={67 render={
65 <Toggle68 <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"
67 pressed={rightPanelOpen}70 pressed={rightPanelOpen}
68 onPressedChange={onToggleRightPanel}71 onPressedChange={onToggleRightPanel}
69 aria-label={72 aria-label={
...@@ -75,7 +78,12 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -75,7 +78,12 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
75 size="sm"78 size="sm"
76 disabled={!rightPanelAvailable}79 disabled={!rightPanelAvailable}
77 >80 >
78 <PanelRightIcon className="size-4" />81 <PanelRightIcon
82 className={cn(
83 "size-4",
84 !rightPanelAvailable && "text-muted-foreground opacity-100",
85 )}
86 />
79 {liveAgentCount > 0 ? (87 {liveAgentCount > 0 ? (
80 <span88 <span
81 aria-hidden89 aria-hidden
...@@ -94,7 +102,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -94,7 +102,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
94 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`102 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
95 : ""103 : ""
96 }`104 }`
97 : "Right panel is unavailable"}105 : rightPanelUnavailableLabel}
98 </TooltipPopup>106 </TooltipPopup>
99 </Tooltip>107 </Tooltip>
100 </div>108 </div>
...@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl...@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl
114 <TooltipTrigger122 <TooltipTrigger
115 render={123 render={
116 <Toggle124 <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]"
118 pressed={maximized}126 pressed={maximized}
119 onPressedChange={onToggle}127 onPressedChange={onToggle}
120 aria-label={label}128 aria-label={label}
apps/web/src/components/composerInlineChip.ts+7-7
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2// composer honors the prompt font-size preference). The chat variant pins the2// composer honors the prompt font-size preference). The chat variant pins the
3// original 12px, where every em value resolves to the same pixels as before.3// original 12px, where every em value resolves to the same pixels as before.
4const INLINE_CHIP_CLASS_NAME =4const 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
7export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;7export 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-[...@@ -11,18 +11,18 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[
11export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =11export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
12 "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-['']";12 "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
16export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";17export 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 its22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
21// glyphs without moving the pill box or changing the editor's line height.
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`;
2323
24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =24export 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
27export 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>`;27export 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 {...@@ -25,6 +25,7 @@ import {
25 GitPullRequestDraftIcon,25 GitPullRequestDraftIcon,
26 GitPullRequestIcon,26 GitPullRequestIcon,
27 HammerIcon,27 HammerIcon,
28 LayersIcon,
28 MessageCircleQuestionIcon,29 MessageCircleQuestionIcon,
29 MessageSquareIcon,30 MessageSquareIcon,
30 LinkIcon,31 LinkIcon,
...@@ -50,6 +51,7 @@ import {...@@ -50,6 +51,7 @@ import {
50import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";51import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
51import { useNewThreadHandler } from "~/hooks/useHandleNewThread";52import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
52import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";53import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
53import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";55import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
54import { cn } from "~/lib/utils";56import { cn } from "~/lib/utils";
55import { readLocalApi } from "~/localApi";57import { readLocalApi } from "~/localApi";
...@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";
60import { useLiveRefresh } from "~/hooks/useLiveRefresh";62import { useLiveRefresh } from "~/hooks/useLiveRefresh";
61import { pullRequestEnvironment } from "~/state/pullRequests";63import { pullRequestEnvironment } from "~/state/pullRequests";
62import { useAtomCommand } from "~/state/use-atom-command";64import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
63import { formatRelativeTimeLabel } from "~/timestampFormat";66import { formatRelativeTimeLabel } from "~/timestampFormat";
6467
65import {68import {
...@@ -74,6 +77,7 @@ import {...@@ -74,6 +77,7 @@ import {
74import { Badge } from "../ui/badge";77import { Badge } from "../ui/badge";
75import { Button } from "../ui/button";78import { Button } from "../ui/button";
76import { Input } from "../ui/input";79import { Input } from "../ui/input";
80import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
77import {81import {
78 Menu,82 Menu,
79 MenuItem,83 MenuItem,
...@@ -116,6 +120,7 @@ import {...@@ -116,6 +120,7 @@ import {
116} from "./pullRequestProjectAssignment.logic";120} from "./pullRequestProjectAssignment.logic";
117import { PullRequestChecksPopover } from "./PullRequestChecksPopover";121import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
118import {122import {
123 PullRequestActorAvatar,
119 PullRequestActorLabel,124 PullRequestActorLabel,
120 PullRequestDiffStat,125 PullRequestDiffStat,
121 PullRequestMetaLine,126 PullRequestMetaLine,
...@@ -349,7 +354,6 @@ export function PullRequestDetailPanel({...@@ -349,7 +354,6 @@ export function PullRequestDetailPanel({
349 onClose,354 onClose,
350 onStateChange,355 onStateChange,
351 context = "page",356 context = "page",
352 chromeVariant = "full",
353 composerDraftTarget,357 composerDraftTarget,
354}: {358}: {
355 environmentId: EnvironmentId;359 environmentId: EnvironmentId;
...@@ -381,12 +385,6 @@ export function PullRequestDetailPanel({...@@ -381,12 +385,6 @@ export function PullRequestDetailPanel({
381 * again is at best a no-op and at worst git refusing a branch two checkouts.385 * again is at best a no-op and at worst git refusing a branch two checkouts.
382 */386 */
383 context?: "page" | "thread";387 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";
390 /**388 /**
391 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs389 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
392 * land here instead of opening a new thread — the branch is already under the reader's feet.390 * land here instead of opening a new thread — the branch is already under the reader's feet.
...@@ -423,26 +421,13 @@ export function PullRequestDetailPanel({...@@ -423,26 +421,13 @@ export function PullRequestDetailPanel({
423 );421 );
424 }, [tab]);422 }, [tab]);
425 const [chromeCondensed, setChromeCondensed] = useState(false);423 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.
431 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});424 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
432 useEffect(() => {425 useEffect(() => {
433 setChromeCondensed(chromeStateByTab.current[tab] ?? false);426 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
434 }, [tab]);427 }, [tab]);
435 const condensed = chromeVariant === "collapse" && chromeCondensed;428 const condensed = 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.
441 const scrollerRef = useRef<HTMLElement | null>(null);429 const scrollerRef = useRef<HTMLElement | null>(null);
442 const foldRef = useRef<HTMLDivElement | null>(null);430 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.
446 const condensedRowRef = useRef<HTMLDivElement | null>(null);431 const condensedRowRef = useRef<HTMLDivElement | null>(null);
447 const compensationRef = useRef<number | null>(null);432 const compensationRef = useRef<number | null>(null);
448 useLayoutEffect(() => {433 useLayoutEffect(() => {
...@@ -463,7 +448,6 @@ export function PullRequestDetailPanel({...@@ -463,7 +448,6 @@ export function PullRequestDetailPanel({
463 target: "branch name",448 target: "branch name",
464 timeout: 1600,449 timeout: 1600,
465 });450 });
466
467 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be451 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
468 // clicked, so a reader who does click it lands on a chunk already in the module cache.452 // clicked, so a reader who does click it lands on a chunk already in the module cache.
469 useEffect(() => {453 useEffect(() => {
...@@ -502,6 +486,30 @@ export function PullRequestDetailPanel({...@@ -502,6 +486,30 @@ export function PullRequestDetailPanel({
502 },486 },
503 [activity, coreDetail],487 [activity, coreDetail],
504 );488 );
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);
505 const activityPending = activityQuery.isPending && activity === null;513 const activityPending = activityQuery.isPending && activity === null;
506 const activityError = activity === null ? activityQuery.error : null;514 const activityError = activity === null ? activityQuery.error : null;
507 const refreshDetail = useCallback(() => {515 const refreshDetail = useCallback(() => {
...@@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({...@@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({
1019 const can = (action: PullRequestAction) =>1027 const can = (action: PullRequestAction) =>
1020 detail?.capabilities.actions.includes(action) === true &&1028 detail?.capabilities.actions.includes(action) === true &&
1021 detail.viewerPermissions.actions.includes(action);1029 detail.viewerPermissions.actions.includes(action);
1022 // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes1030 // One live action holds the slot. Conflicts take priority because every other completion action
1023 // to the thing that would help instead of a Merge button that only ever says no.1031 // depends on resolving them first, even for a reader who cannot merge on the host themselves.
1024 const primaryAction =1032 const primaryAction =
1025 detail === null || detail.state !== "open"1033 detail === null || detail.state !== "open"
1026 ? null1034 ? null
1027 : detail.isDraft && can("ready")1035 : conflicting
1028 ? "ready"1036 ? "resolve"
1029 : !can("merge")1037 : detail.isDraft && can("ready")
1030 ? null1038 ? "ready"
1031 : conflicting1039 : !can("merge")
1032 ? "resolve"1040 ? null
1033 : allowedMergeMethods.length > 01041 : allowedMergeMethods.length > 0
1034 ? "merge"1042 ? "merge"
1035 : null;1043 : null;
1036 // The pull request number carries this state in the overview and the right-panel tab mirrors1044 // 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.
1038 const statePresentation = detail1046 const statePresentation = detail
1039 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })1047 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
1040 : null;1048 : null;
1041 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;1049 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
1042 const checksState = detail ? pullRequestChecksState(detail.checks) : null;1050 const checksState = detail ? pullRequestChecksState(detail.checks) : null;
10431051
1052 if (detailQuery.isPending && !detail) {
1053 return <PullRequestDetailGhost />;
1054 }
1055
1044 return (1056 return (
1045 <div className="flex h-full min-h-0 w-full flex-col bg-background">1057 <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. */}
1049 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">1058 <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 rows1059 <div className="ml-4 grid h-7 min-w-0 items-center">
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">
1053 <div1060 <div
1054 aria-hidden={condensed}1061 aria-hidden={condensed}
1055 inert={condensed}1062 inert={condensed}
1056 className={cn(1063 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",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",
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.
1060 condensed1065 condensed
1061 ? "pointer-events-none opacity-0 duration-100"1066 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1062 : "opacity-100 delay-75 duration-150",1067 : "translate-y-0 opacity-100 delay-50 duration-150",
1063 )}1068 )}
1064 >1069 >
1065 {detail && statePresentation ? (1070 {detail && statePresentation ? (
1066 <>1071 <>
1067 <span className="min-w-0 truncate" title={detail.repository}>1072 {repositoryUrl ? (
1068 {detail.repository}1073 <button
1069 </span>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 )}
1070 <button1086 <button
1071 type="button"1087 type="button"
1072 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1088 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
...@@ -1087,10 +1103,10 @@ export function PullRequestDetailPanel({...@@ -1087,10 +1103,10 @@ export function PullRequestDetailPanel({
1087 aria-hidden={!condensed}1103 aria-hidden={!condensed}
1088 inert={!condensed}1104 inert={!condensed}
1089 className={cn(1105 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",
1091 condensed1107 condensed
1092 ? "opacity-100 delay-75 duration-150"1108 ? "translate-y-0 opacity-100 delay-50 duration-150"
1093 : "pointer-events-none opacity-0 duration-100",1109 : "pointer-events-none translate-y-1 opacity-0 duration-100",
1094 )}1110 )}
1095 >1111 >
1096 {detail && statePresentation ? (1112 {detail && statePresentation ? (
...@@ -1109,30 +1125,14 @@ export function PullRequestDetailPanel({...@@ -1109,30 +1125,14 @@ export function PullRequestDetailPanel({
1109 >1125 >
1110 #{detail.number}1126 #{detail.number}
1111 </button>1127 </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}>
1113 {detail.title}1129 {detail.title}
1114 </span>1130 </h1>
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}
1131 </>1131 </>
1132 ) : null}1132 ) : null}
1133 </div>1133 </div>
1134 </div>1134 </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">
1136 {detail ? (1136 {detail ? (
1137 <>1137 <>
1138 <Menu>1138 <Menu>
...@@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({...@@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({
1140 render={1140 render={
1141 <Button1141 <Button
1142 aria-label="More pull request actions"1142 aria-label="More pull request actions"
1143 className="size-6"1143 className="order-last size-6"
1144 size="icon-xs"1144 size="icon-xs"
1145 variant="ghost-muted"1145 variant="ghost-muted"
1146 />1146 />
...@@ -1312,16 +1312,15 @@ export function PullRequestDetailPanel({...@@ -1312,16 +1312,15 @@ export function PullRequestDetailPanel({
1312 <MenuTrigger1312 <MenuTrigger
1313 disabled={handoff !== null}1313 disabled={handoff !== null}
1314 render={1314 render={
1315 <Button size="xs" variant="outline">1315 <Button size="xs" variant="outline" className="gap-0 p-0">
1316 {handoff?.startsWith("checkout") ? (1316 <span className="inline-flex h-full items-center gap-1.5 ps-[8.5px] pe-2">
1317 "Checking out..."1317 <GitBranchIcon aria-hidden className="size-3.5" />
1318 ) : (1318 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1319 <>1319 </span>
1320 <GitBranchIcon className="size-3" />1320 <span aria-hidden className="h-4 w-px shrink-0 bg-input" />
1321 Check out1321 <span className="inline-flex h-full w-6 shrink-0 items-center justify-center">
1322 <ChevronDownIcon className="size-3 text-muted-foreground" />1322 <ChevronDownIcon aria-hidden className="size-4 text-muted-foreground" />
1323 </>1323 </span>
1324 )}
1325 </Button>1324 </Button>
1326 }1325 }
1327 />1326 />
...@@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({...@@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({
1367 Auto-merge1366 Auto-merge
1368 </Badge>1367 </Badge>
1369 ) : null}1368 ) : 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" ? (
1371 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>1385 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
1372 Ready for review1386 Ready for review
1373 </Button>1387 </Button>
...@@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({...@@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({
1394 ) : null}1408 ) : null}
1395 </div>1409 </div>
13961410
1397 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,1411 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
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 >
1410 <div1412 <div
1411 ref={condensedRowRef}1413 ref={condensedRowRef}
1412 className={cn(1414 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",
1414 condensed1416 condensed
1415 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"1417 ? "translate-y-0 opacity-100 delay-50"
1416 : "opacity-0",1418 : "translate-y-1 opacity-0 duration-100",
1417 )}1419 )}
1418 inert={!condensed}1420 inert={!condensed}
1419 >1421 >
1420 {detail ? (1422 {detail ? (
1421 <div className="flex min-w-0 items-center gap-1 px-4 pb-2">1423 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
1422 <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">1424 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1423 {visibleTabs.map((item) => (1425 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
1424 <button1426 <PullRequestActorAvatar actor={detail.author} className="shrink-0" />
1425 key={item.value}1427 <span className="sr-only">{detail.author?.login ?? "ghost"}</span>
1426 type="button"1428 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
1427 tabIndex={condensed ? 0 : -1}1429 </span>
1428 aria-pressed={tab === item.value}1430 <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
1429 onClick={() => setTab(item.value)}1431 <span
1430 className={cn(1432 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65"
1431 "rounded-md px-2 py-1 text-[11px] transition-colors",1433 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1432 tab === item.value1434 >
1433 ? "bg-accent text-foreground"1435 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1434 : "text-muted-foreground hover:text-foreground",1436 {isStackedPullRequest ? (
1435 )}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 }`}
1436 >1462 >
1437 {item.label}1463 <FileDiffIcon aria-hidden className="size-3" />
1438 </button>1464 {detail.changedFiles.toLocaleString()}
1439 ))}1465 </span>
1440 </nav>1466 <PullRequestDiffStat
1441 <span1467 additions={detail.additions}
1442 className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"1468 deletions={detail.deletions}
1443 title={`${detail.baseBranch} ← ${detail.headBranch}`}1469 className="shrink-0 font-mono text-[11px]"
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"
1453 />1470 />
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()}
1462 </span>1471 </span>
1463 <PullRequestDiffStat1472 </div>
1464 additions={detail.additions}
1465 deletions={detail.deletions}
1466 className="shrink-0 font-mono text-[11px]"
1467 />
1468 </span>
1469 </div>1473 </div>
1470 ) : null}1474 ) : null}
1471 </div>1475 </div>
1472 </div>1476 </div>
14731477
1474 {/* Folding is a grid track going to zero: the rows below stay mounted, the track1478 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}>
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 >
1489 <div1479 <div
1490 ref={foldRef}1480 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.
1494 className={cn(1481 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",
1496 condensed1483 condensed
1497 ? "opacity-0"1484 ? "-translate-y-1 opacity-0 duration-100"
1498 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",1485 : "translate-y-0 opacity-100 delay-50",
1499 )}1486 )}
1500 inert={condensed}1487 inert={condensed}
1501 >1488 >
1502 {detail ? (1489 {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">
1504 {titleDraft === null ? (1491 {titleDraft === null ? (
1505 <div className="group flex min-w-0 items-start gap-1">1492 <div className="group flex min-w-0 items-start gap-1">
1506 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">1493 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
...@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({...@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({
1567 </PullRequestMetaLine>1554 </PullRequestMetaLine>
15681555
1569 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">1556 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1570 <code1557 <span
1571 className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"1558 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"
1572 title={detail.baseBranch}1559 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1573 >1560 >
1574 {detail.baseBranch}1561 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1575 </code>1562 {isStackedPullRequest ? (
1576 {freshness ? (1563 <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" />
1577 <PullRequestBaseFreshnessWarning1564 ) : null}
1578 baseBranch={detail.baseBranch}1565 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1579 freshness={freshness}1566 </span>
1580 pending={actionPending}1567 {freshness ? (
1581 onUpdate={(method) => void perform("update-branch", undefined, method)}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"
1582 />1578 />
1583 ) : null}1579 <button
1584 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />1580 type="button"
1585 <button1581 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"
1586 type="button"1582 aria-label={
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"1583 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1588 aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"}1584 }
1589 title={isBranchCopied ? "Copied" : "Copy pull request branch"}1585 title={isBranchCopied ? "Copied" : "Copy pull request branch"}
1590 onClick={() => copyBranchToClipboard(detail.headBranch)}1586 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 )}
1607 >1587 >
1608 Copied1588 <code
1609 </span>1589 className={cn(
1610 </button>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>
1611 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">1607 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
1612 <span className="inline-flex items-center gap-1.5 tabular-nums">1608 <span className="inline-flex items-center gap-1.5 tabular-nums">
1613 <FileDiffIcon className="size-3.5" />1609 <FileDiffIcon className="size-3.5" />
...@@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({...@@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({
1623 </div>1619 </div>
1624 </div>1620 </div>
1625 ) : null}1621 ) : null}
1622 </div>
1623 </div>
16261624
1627 {detail && conflicting ? (1625 {detail ? (
1628 <div className="col-span-2 flex items-center gap-1 px-4 pb-3">1626 <nav
1629 <Badge1627 className={cn(
1630 variant="error"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",
1631 className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"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)}
1632 >1639 >
1633 <TriangleAlertIcon className="size-3.5" />1640 {item.label}
1634 Merge conflicts1641 </SegmentedTab>
1635 </Badge>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>
1636 <Button1699 <Button
1637 size="xs"1700 size="xs"
1638 variant="ghost"1701 variant="ghost"
1639 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"1702 className="h-7 px-2 text-[10px] text-muted-foreground"
1640 disabled={handoff !== null}1703 aria-label={
1641 onClick={startResolveConflicts}1704 timelineOrder === "newest"
1705 ? "Show oldest activity first"
1706 : "Show newest activity first"
1707 }
1708 onClick={() =>
1709 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1710 }
1642 >1711 >
1643 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}1712 <ArrowDownUpIcon aria-hidden className="size-3" />
1644 <ArrowUpRightIcon className="size-3.5 text-destructive" />1713 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1645 </Button>1714 </Button>
1646 </div>1715 </div>
1647 ) : null}1716 ) : null}
16481717 </nav>
1649 {detail ? (1718 ) : null}
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>
1749 </div>1719 </div>
17501720
1751 <div1721 <div
1752 className="relative min-h-0 flex-1 overflow-hidden"1722 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.
1756 onScrollCapture={(event) => {1723 onScrollCapture={(event) => {
1757 if (chromeVariant !== "collapse") return;
1758 const scroller = event.target as HTMLElement;1724 const scroller = event.target as HTMLElement;
1759 scrollerRef.current = scroller;1725 scrollerRef.current = scroller;
1760 const top = scroller.scrollTop;1726 const top = scroller.scrollTop;
1761 setChromeCondensed((previous) => {1727 setChromeCondensed((previous) => {
1762 let next = previous;1728 let next = previous;
1763 // `scrollHeight` reads the fold's natural height whichever state the track is in.
1764 const foldHeight = foldRef.current?.scrollHeight ?? 0;1729 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.
1767 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);1730 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
1768 if (previous) {1731 if (previous) {
1769 // The hard top reopens the chrome with no refund: the reader asked for the top,1732 // The hard top reopens the chrome with no refund: the reader asked for the top,
...@@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({...@@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({
1781 });1744 });
1782 }}1745 }}
1783 >1746 >
1784 {detailQuery.isPending && !detail ? (1747 {detailQuery.error && !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 ? (
1795 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />1748 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
1796 ) : detail ? (1749 ) : detail ? (
1797 <>1750 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+91-24
...@@ -45,13 +45,11 @@ export function PullRequestListGhost({...@@ -45,13 +45,11 @@ export function PullRequestListGhost({
45 <GhostBar className="size-4 rounded-full" />45 <GhostBar className="size-4 rounded-full" />
46 <div className="min-w-0 space-y-1.5">46 <div className="min-w-0 space-y-1.5">
47 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />47 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
48 <GhostBar48 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
51 </div>49 </div>
52 <div className="flex flex-col items-end gap-1.5">50 <div className="flex flex-col items-end gap-1.5">
53 <GhostBar className="w-12" />51 <GhostBar className="w-12" />
54 <GhostBar className="w-16 bg-muted-foreground/10" />52 <GhostBar className="w-16" />
55 </div>53 </div>
56 </div>54 </div>
57 ))}55 ))}
...@@ -59,32 +57,101 @@ export function PullRequestListGhost({...@@ -59,32 +57,101 @@ export function PullRequestListGhost({
59 );57 );
60}58}
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 */
63export function PullRequestDetailGhost() {65export function PullRequestDetailGhost() {
64 return (66 return (
65 <div67 <div
66 role="status"68 role="status"
67 aria-label="Loading pull request"69 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"
69 >71 >
70 <div className="space-y-2">72 <div className="shrink-0 border-b border-border/60">
71 <GhostBar className="h-5 w-4/5" />73 <div className="flex h-7 items-center justify-between gap-3 px-4">
72 <GhostBar className="w-2/5 bg-muted-foreground/10" />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>
73 </div>109 </div>
74 <div className="space-y-3">110
75 {Array.from({ length: 4 }, (_, index) => (111 <div className="min-h-0 flex-1 overflow-hidden">
76 <div key={index} className="flex items-center gap-3">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" />
77 <GhostBar className="size-3.5 rounded-full" />146 <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]} />
80 </div>147 </div>
81 ))}148 <div className="space-y-2 px-4 pb-4">
82 </div>149 <GhostBar className="w-full" />
83 <div className="space-y-2 pt-1">150 <GhostBar className="w-11/12" />
84 <GhostBar className="w-full bg-muted-foreground/10" />151 <GhostBar className="w-4/5" />
85 <GhostBar className="w-11/12 bg-muted-foreground/10" />152 <GhostBar className="w-2/3" />
86 <GhostBar className="w-4/5 bg-muted-foreground/10" />153 </div>
87 <GhostBar className="w-2/3 bg-muted-foreground/10" />154 </section>
88 </div>155 </div>
89 </div>156 </div>
90 );157 );
...@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {...@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
113 <div key={index} className="relative pb-5">180 <div key={index} className="relative pb-5">
114 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />181 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
115 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />182 <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" />
117 </div>184 </div>
118 ))}185 ))}
119 </div>186 </div>
...@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {...@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
134 <GhostBar className="size-5 shrink-0 rounded-full" />201 <GhostBar className="size-5 shrink-0 rounded-full" />
135 <div className="flex-1 space-y-1.5">202 <div className="flex-1 space-y-1.5">
136 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />203 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
137 <GhostBar className="w-full bg-muted-foreground/10" />204 <GhostBar className="w-full" />
138 <GhostBar className="w-3/4 bg-muted-foreground/10" />205 <GhostBar className="w-3/4" />
139 </div>206 </div>
140 </div>207 </div>
141 ))}208 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+9-6
...@@ -25,6 +25,7 @@ import { cn } from "~/lib/utils";...@@ -25,6 +25,7 @@ import { cn } from "~/lib/utils";
25import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";25import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
26import { ProjectFavicon } from "../ProjectFavicon";26import { ProjectFavicon } from "../ProjectFavicon";
27import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";27import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
28import { Button } from "../ui/button";
2829
29import {30import {
30 Menu,31 Menu,
...@@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({...@@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({
261 return (262 return (
262 <Menu>263 <Menu>
263 <MenuTrigger264 <MenuTrigger
264 className={cn(265 render={
265 // The icon-button size that pairs with a full-height input, so the two read as one strip.266 <Button
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 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}
267 filtered && "text-foreground",268 size="icon"
268 )}269 variant="outline"
269 aria-label="Filter pull requests"270 aria-label="Filter pull requests"
271 />
272 }
270 >273 >
271 <ListFilterIcon className="size-4" />274 <ListFilterIcon className="size-4" />
272 {filtered ? (275 {filtered ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3
...@@ -196,12 +196,12 @@ function MetaRow({...@@ -196,12 +196,12 @@ function MetaRow({
196 children: ReactNode;196 children: ReactNode;
197}) {197}) {
198 return (198 return (
199 <div className="flex items-center gap-2 py-1.5 text-xs">199 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">
200 <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground">200 <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
201 {icon}201 {icon}
202 {label}202 {label}
203 </span>203 </span>
204 <span className="min-w-0 flex-1 text-foreground">{children}</span>204 <span className="min-w-0 text-foreground">{children}</span>
205 </div>205 </div>
206 );206 );
207}207}
apps/web/src/components/settings/DiagnosticsSettings.tsx+1-1
...@@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() {...@@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() {
992 : false;992 : false;
993993
994 return (994 return (
995 <SettingsPageContainer className="max-w-6xl gap-10">995 <SettingsPageContainer width="expanded" className="gap-10">
996 <ResourceTelemetryDiagnostics />996 <ResourceTelemetryDiagnostics />
997997
998 <SettingsSection998 <SettingsSection
apps/web/src/components/settings/KeybindingsSettings.tsx+1-1
...@@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() {...@@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() {
1195 );1195 );
11961196
1197 return (1197 return (
1198 <SettingsPageContainer className="max-w-5xl">1198 <SettingsPageContainer width="wide">
1199 <SettingsSection1199 <SettingsSection
1200 {...searchableSetting("keybindings")}1200 {...searchableSetting("keybindings")}
1201 headerAction={1201 headerAction={
apps/web/src/components/settings/ProjectSettingsPanel.tsx+4-22
...@@ -81,8 +81,6 @@ import {...@@ -81,8 +81,6 @@ import {
81 type NewProjectScriptInput,81 type NewProjectScriptInput,
82 type ProjectScriptEditorRequest,82 type ProjectScriptEditorRequest,
83} from "../projectScriptEditor";83} from "../projectScriptEditor";
84import { cn } from "../../lib/utils";
85import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
86import { Button } from "../ui/button";84import { Button } from "../ui/button";
87import { Input } from "../ui/input";85import { Input } from "../ui/input";
88import {86import {
...@@ -102,6 +100,7 @@ import {...@@ -102,6 +100,7 @@ import {
102 WorkspaceBreadcrumbItem,100 WorkspaceBreadcrumbItem,
103 WorkspaceBreadcrumbSeparator,101 WorkspaceBreadcrumbSeparator,
104} from "../WorkspaceBreadcrumb";102} from "../WorkspaceBreadcrumb";
103import { WorkspacePageHeader } from "../WorkspacePageContainer";
105import {104import {
106 SettingResetButton,105 SettingResetButton,
107 SettingsPageContainer,106 SettingsPageContainer,
...@@ -174,26 +173,9 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {...@@ -174,26 +173,9 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
174 return (173 return (
175 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">174 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
176 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">175 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
177 {!isElectron && (176 <WorkspacePageHeader electron={isElectron}>
178 <header177 <ProjectSettingsBreadcrumb projectKey={projectKey} />
179 className={cn(178 </WorkspacePageHeader>
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 )}
197 <ProjectSettingsPanel projectKey={projectKey} />179 <ProjectSettingsPanel projectKey={projectKey} />
198 </div>180 </div>
199 </SidebarInset>181 </SidebarInset>
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-22
...@@ -9,7 +9,6 @@ import {...@@ -9,7 +9,6 @@ import {
9} from "react";9} from "react";
10import {10import {
11 ArchiveIcon,11 ArchiveIcon,
12 ArrowLeftIcon,
13 BotIcon,12 BotIcon,
14 GitBranchIcon,13 GitBranchIcon,
15 KeyboardIcon,14 KeyboardIcon,
...@@ -19,7 +18,7 @@ import {...@@ -19,7 +18,7 @@ import {
19 Settings2Icon,18 Settings2Icon,
20 XIcon,19 XIcon,
21} from "lucide-react";20} from "lucide-react";
22import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";21import { useLocation, useNavigate } from "@tanstack/react-router";
2322
24import { Button } from "../ui/button";23import { Button } from "../ui/button";
25import { Input } from "../ui/input";24import { Input } from "../ui/input";
...@@ -34,6 +33,7 @@ import {...@@ -34,6 +33,7 @@ import {
34 useSidebar,33 useSidebar,
35} from "../ui/sidebar";34} from "../ui/sidebar";
36import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";35import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
36import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
37import { scrollToSettingsTarget } from "./settingsLayout";37import { scrollToSettingsTarget } from "./settingsLayout";
38import {38import {
39 searchSettings,39 searchSettings,
...@@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {...@@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
72export function SettingsSidebarNav({ pathname }: { pathname: string }) {72export function SettingsSidebarNav({ pathname }: { pathname: string }) {
73 const navigate = useNavigate();73 const navigate = useNavigate();
74 const currentHash = useLocation({ select: (location) => location.hash });74 const currentHash = useLocation({ select: (location) => location.hash });
75 const canGoBack = useCanGoBack();
76 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();75 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
77 const searchInputRef = useRef<HTMLInputElement>(null);76 const searchInputRef = useRef<HTMLInputElement>(null);
78 const [query, setQuery] = useState("");77 const [query, setQuery] = useState("");
...@@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
176 },175 },
177 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],176 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
178 );177 );
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
190 return (178 return (
191 <>179 <>
192 <SidebarContent className="overflow-x-hidden">180 <SidebarContent className="overflow-x-hidden">
...@@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
296 <SidebarFooter className="p-[var(--sidebar-content-inset)]">284 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
297 <T3ConnectSidebarSignIn />285 <T3ConnectSidebarSignIn />
298 <div className="flex items-center gap-1">286 <div className="flex items-center gap-1">
299 <SidebarMenu className="min-w-0 flex-1">287 <SidebarUtilityMenu />
300 <SidebarMenuItem>
301 <SidebarMenuButton onClick={handleBackClick}>
302 <ArrowLeftIcon />
303 <span>Back</span>
304 </SidebarMenuButton>
305 </SidebarMenuItem>
306 </SidebarMenu>
307 <T3ConnectSidebarAvatar />288 <T3ConnectSidebarAvatar />
308 </div>289 </div>
309 </SidebarFooter>290 </SidebarFooter>
apps/web/src/components/settings/ThemeSettings.tsx+1-1
...@@ -768,7 +768,7 @@ export function ThemeLibrary({...@@ -768,7 +768,7 @@ export function ThemeLibrary({
768 <TooltipProvider>768 <TooltipProvider>
769 <div769 <div
770 className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4"770 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))" }}
772 >772 >
773 {STANDARD_THEME_CARDS.map((standardTheme) => (773 {STANDARD_THEME_CARDS.map((standardTheme) => (
774 <ThemeLibraryCard774 <ThemeLibraryCard
apps/web/src/components/settings/settingsLayout.tsx+6-3
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12} from "react";12} from "react";
1313
14import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
15import { Button } from "../ui/button";16import { Button } from "../ui/button";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1718
...@@ -233,9 +234,11 @@ export function SettingResetButton({...@@ -233,9 +234,11 @@ export function SettingResetButton({
233export function SettingsPageContainer({234export function SettingsPageContainer({
234 children,235 children,
235 className,236 className,
237 width = "readable",
236}: {238}: {
237 children: ReactNode;239 children: ReactNode;
238 className?: string;240 className?: string;
241 width?: WorkspacePageWidth;
239}) {242}) {
240 const navigate = useNavigate();243 const navigate = useNavigate();
241 const hash = useLocation({ select: (location) => location.hash });244 const hash = useLocation({ select: (location) => location.hash });
...@@ -247,12 +250,12 @@ export function SettingsPageContainer({...@@ -247,12 +250,12 @@ export function SettingsPageContainer({
247 return (250 return (
248 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>251 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
249 <div252 <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]"
251 data-settings-page-scroll254 data-settings-page-scroll
252 >255 >
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)}>
254 {children}257 {children}
255 </div>258 </WorkspacePageContainer>
256 </div>259 </div>
257 </SettingsSearchTargetProvider>260 </SettingsSearchTargetProvider>
258 );261 );
apps/web/src/components/sidebar/SidebarChrome.tsx+78-68
...@@ -4,8 +4,9 @@ import {...@@ -4,8 +4,9 @@ import {
4 GitPullRequestIcon,4 GitPullRequestIcon,
5 SettingsIcon,5 SettingsIcon,
6} from "lucide-react";6} from "lucide-react";
7import type { ReactNode } from "react";
7import { memo, useCallback } from "react";8import { memo, useCallback } from "react";
8import { Link, useLocation, useNavigate } from "@tanstack/react-router";9import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
910
10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";11import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
11import { cn } from "../../lib/utils";12import { cn } from "../../lib/utils";
...@@ -117,16 +118,44 @@ function T3Wordmark() {...@@ -117,16 +118,44 @@ function T3Wordmark() {
117 );118 );
118}119}
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() {
121 const navigate = useNavigate();147 const navigate = useNavigate();
148 const canGoBack = useCanGoBack();
122 const { isMobile, setOpenMobile } = useSidebar();149 const { isMobile, setOpenMobile } = useSidebar();
123 const currentFooterPage = useLocation({150 const currentFooterPage = useLocation({
124 select: (location) =>151 select: (location) =>
125 location.pathname === "/usage"152 /^\/settings(?:\/|$)/.test(location.pathname)
126 ? "usage"153 ? "settings"
127 : location.pathname === "/pull-requests"154 : location.pathname === "/usage"
128 ? "pull-requests"155 ? "usage"
129 : null,156 : location.pathname === "/pull-requests"
157 ? "pull-requests"
158 : null,
130 });159 });
131 const { environments } = useEnvironments();160 const { environments } = useEnvironments();
132 // The page reads every connected server, so one of them offering pull requests is enough for161 // 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() {...@@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
157186
158 const handleBackClick = useCallback(() => {187 const handleBackClick = useCallback(() => {
159 closeMobileSidebar();188 closeMobileSidebar();
189 if (canGoBack) {
190 window.history.back();
191 return;
192 }
160 void navigate({ to: "/" });193 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() {
163 return (232 return (
164 <SidebarFooter className="p-[var(--sidebar-content-inset)]">233 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
165 <SidebarProviderUpdatePill />234 <SidebarProviderUpdatePill />
166 <SidebarUpdateArchitectureWarning />235 <SidebarUpdateArchitectureWarning />
167 <SidebarMenu className="flex-row items-center">236 <SidebarUtilityMenu />
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>
227 </SidebarFooter>237 </SidebarFooter>
228 );238 );
229});239});
apps/web/src/components/threadActionMenu.logic.test.ts+10-4
...@@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] {...@@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] {
19 return buildThreadActionMenuItems(state).map((item) => item.id);19 return buildThreadActionMenuItems(state).map((item) => item.id);
20}20}
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
22describe("buildThreadActionMenuItems", () => {28describe("buildThreadActionMenuItems", () => {
23 it("hides lifecycle items when the environment lacks the capabilities", () => {29 it("hides lifecycle items when the environment lacks the capabilities", () => {
24 expect(30 expect(
...@@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => {...@@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => {
26 ...baseState,32 ...baseState,
27 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },33 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
28 }),34 }),
29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);35 ).toEqual(["rename", "mark-unread", "copy", "delete"]);
30 });36 });
3137
32 it("includes branch items only for threads with a branch", () => {38 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" });
34 expect(withBranch).toContain("new-thread-on-branch");40 expect(withBranch).toContain("new-thread-on-branch");
35 expect(withBranch).toContain("copy-branch");41 expect(withBranch).toContain("copy-branch");
36 expect(ids(baseState)).not.toContain("new-thread-on-branch");42 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
37 expect(ids(baseState)).not.toContain("copy-branch");43 expect(allIds(baseState)).not.toContain("copy-branch");
38 });44 });
3945
40 it("flips lifecycle labels with thread state", () => {46 it("flips lifecycle labels with thread state", () => {
apps/web/src/components/threadActionMenu.logic.ts+31-11
...@@ -18,6 +18,7 @@ export type ThreadActionMenuId =...@@ -18,6 +18,7 @@ export type ThreadActionMenuId =
18 | "rename"18 | "rename"
19 | "regenerate-title"19 | "regenerate-title"
20 | "mark-unread"20 | "mark-unread"
21 | "copy"
21 | "copy-path"22 | "copy-path"
22 | "copy-branch"23 | "copy-branch"
23 | "copy-thread-id"24 | "copy-thread-id"
...@@ -53,14 +54,15 @@ export function buildThreadActionMenuItems(...@@ -53,14 +54,15 @@ export function buildThreadActionMenuItems(
53 {54 {
54 id: "new-thread-on-branch" as const,55 id: "new-thread-on-branch" as const,
55 label: `New thread on ${state.branch}`,56 label: `New thread on ${state.branch}`,
57 icon: "message-square-plus",
56 },58 },
57 ]59 ]
58 : []),60 : []),
59 ...(state.supports.pinning61 ...(state.supports.pinning
60 ? [62 ? [
61 state.isPinned63 state.isPinned
62 ? { id: "unpin" as const, label: "Unpin thread" }64 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
63 : { id: "pin" as const, label: "Pin thread" },65 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
64 ]66 ]
65 : []),67 : []),
66 // Both lifecycle actions stay available on pinned threads: settling68 // Both lifecycle actions stay available on pinned threads: settling
...@@ -69,17 +71,18 @@ export function buildThreadActionMenuItems(...@@ -69,17 +71,18 @@ export function buildThreadActionMenuItems(
69 ...(state.supports.settlement71 ...(state.supports.settlement
70 ? [72 ? [
71 state.isSettled73 state.isSettled
72 ? { id: "unsettle" as const, label: "Un-settle thread" }74 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
73 : { id: "settle" as const, label: "Settle thread" },75 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
74 ]76 ]
75 : []),77 : []),
76 ...(state.supports.snooze78 ...(state.supports.snooze
77 ? [79 ? [
78 state.isSnoozed80 state.isSnoozed
79 ? { id: "unsnooze" as const, label: "Wake thread" }81 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
80 : {82 : {
81 id: "snooze" as const,83 id: "snooze" as const,
82 label: "Snooze",84 label: "Snooze",
85 icon: "clock",
83 disabled: !state.canSnoozeNow,86 disabled: !state.canSnoozeNow,
84 children: state.snoozePresets.map((preset) => ({87 children: state.snoozePresets.map((preset) => ({
85 id: `snooze:${preset.id}` as const,88 id: `snooze:${preset.id}` as const,
...@@ -88,20 +91,37 @@ export function buildThreadActionMenuItems(...@@ -88,20 +91,37 @@ export function buildThreadActionMenuItems(
88 },91 },
89 ]92 ]
90 : []),93 : []),
91 { id: "rename", label: "Rename thread" },94 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
92 ...(state.supports.titleRegeneration95 ...(state.supports.titleRegeneration
93 ? [96 ? [
94 {97 {
95 id: "regenerate-title" as const,98 id: "regenerate-title" as const,
96 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",99 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
100 icon: "refresh-cw",
97 disabled: state.isRegeneratingTitle,101 disabled: state.isRegeneratingTitle,
98 },102 },
99 ]103 ]
100 : []),104 : []),
101 { id: "mark-unread", label: "Mark unread" },105 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
102 { id: "copy-path", label: "Copy path", icon: "copy" },106 {
103 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),107 id: "copy",
104 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },108 label: "Copy",
105 { id: "delete", label: "Delete", destructive: true, icon: "trash" },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 },
106 ];126 ];
107}127}
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(...@@ -18,6 +18,10 @@ const toggleVariants = cva(
18 "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",18 "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",
19 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",19 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
20 lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",20 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)]",
21 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",25 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
22 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",26 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
23 },27 },
...@@ -27,6 +31,8 @@ const toggleVariants = cva(...@@ -27,6 +31,8 @@ const toggleVariants = cva(
27 "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",31 "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",
28 outline:32 outline:
29 "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",33 "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",
30 },36 },
31 },37 },
32 },38 },
apps/web/src/components/usage/UsagePage.tsx+150-243
...@@ -19,13 +19,22 @@ import {...@@ -19,13 +19,22 @@ import {
19 formatUsd,19 formatUsd,
20 makeWindow,20 makeWindow,
21} from "@t3tools/shared/usageFormat";21} from "@t3tools/shared/usageFormat";
22import { ScrollArea } from "../ui/scroll-area";
23import { Button } from "../ui/button";22import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
24import { SidebarInset } from "../ui/sidebar";24import { SidebarInset } from "../ui/sidebar";
25import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";25import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
26import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";26import {
27import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";27 WorkspaceBreadcrumb,
28import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";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
30const WINDOW_OPTIONS = [39const WINDOW_OPTIONS = [
31 { days: 1, label: "Past 24h" },40 { days: 1, label: "Past 24h" },
...@@ -66,21 +75,6 @@ export function UsagePage() {...@@ -66,21 +75,6 @@ export function UsagePage() {
66 [isPast24Hours, merged.daily, merged.hourly],75 [isPast24Hours, merged.daily, merged.hourly],
67 );76 );
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;
84 const selectWindow = (days: number) => {78 const selectWindow = (days: number) => {
85 setWindowSelection({79 setWindowSelection({
86 days,80 days,
...@@ -100,78 +94,66 @@ export function UsagePage() {...@@ -100,78 +94,66 @@ export function UsagePage() {
100 setWindowSelection({ days: windowDays, window: nextWindow });94 setWindowSelection({ days: windowDays, window: nextWindow });
101 }95 }
102 };96 };
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
104 return (144 return (
105 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">145 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
106 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">146 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
107 {!isElectron && (147 <WorkspacePageHeader electron={isElectron} className="h-auto py-2">
108 <header148 {topbarContent}
109 className={cn(149 </WorkspacePageHeader>
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 )}
132150
133 <ScrollArea className="min-h-0 flex-1">151 <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">152 <WorkspacePageContainer width="wide">
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
171 {settling ? (153 {settling ? (
172 <>154 <>
173 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}155 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
174 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />156 <UsageSkeleton />
175 </>157 </>
176 ) : (158 ) : (
177 <>159 <>
...@@ -181,88 +163,62 @@ export function UsagePage() {...@@ -181,88 +163,62 @@ export function UsagePage() {
181 staleEnvironments={merged.staleEnvironments}163 staleEnvironments={merged.staleEnvironments}
182 />164 />
183165
184 {/* Cost first: the financial answer, then the provider split. */}166 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
185 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">167 <div className="flex min-w-0 flex-col gap-5">
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">
189 <div className="flex flex-col gap-1">168 <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>
193 <span className="text-4xl font-semibold text-foreground tabular-nums">169 <span className="text-4xl font-semibold text-foreground tabular-nums">
194 {metric === "cost"170 {metric === "cost"
195 ? `${formatUsd(merged.costUsd)}*`171 ? formatUsd(merged.costUsd)
196 : formatTokens(merged.totalTokens)}172 : formatTokens(merged.totalTokens)}
197 </span>173 </span>
198 <span className="text-xs text-muted-foreground">174 <span className="text-xs text-muted-foreground">
199 {metric === "cost"175 {metric === "cost"
200 ? "* if billed at full API rate"176 ? `${formatCount(merged.sessions)} sessions · API estimate`
201 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}177 : `${formatCount(merged.sessions)} sessions`}
202 </span>178 </span>
203 </div>179 </div>
204180
205 {orderedProviders.map((provider) => {181 {PROVIDER_ORDER.map((provider) => {
206 const share = metric === "cost" ? provider.costShare : provider.tokenShare;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 }`;
207 return (189 return (
208 <div key={provider.provider} className="flex flex-col gap-1.5">190 <div key={provider} className="flex flex-col gap-1">
209 <div className="flex items-baseline justify-between">191 <div className="flex items-baseline justify-between gap-4">
210 <span className="flex items-center gap-2 text-sm text-foreground">192 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
211 <ProviderMark provider={provider.provider} className="size-4" />193 <ProviderMark provider={provider} className="size-4" />
212 {PROVIDER_LABEL[provider.provider]}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>
213 </span>200 </span>
214 <span className="text-sm text-foreground tabular-nums">201 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
215 {metric === "cost"202 {metric === "cost"
216 ? formatUsd(provider.costUsd)203 ? formatUsd(totals?.costUsd ?? 0)
217 : formatTokens(provider.totalTokens)}204 : formatTokens(totals?.totalTokens ?? 0)}
218 </span>205 </span>
219 </div>206 </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>
229 <span className="text-xs text-muted-foreground">207 <span className="text-xs text-muted-foreground">
230 {metric === "cost"208 {metric === "cost"
231 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`209 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
232 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}210 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
233 </span>211 </span>
234 </div>212 </div>
235 );213 );
236 })}214 })}
237 </div>215 </div>
238216
239 <div className="flex flex-col gap-3">217 <div className="flex min-w-0 flex-col gap-3">
240 <div className="flex flex-wrap items-center justify-between gap-3">218 <h2 className="text-sm font-medium text-foreground">
241 <h2 className="text-sm font-medium text-foreground">219 {isPast24Hours ? "Hourly" : "Daily"}{" "}
242 {isPast24Hours ? "Hourly" : "Daily"}{" "}220 {metric === "tokens" ? "processed tokens" : "cost"}
243 {metric === "tokens" ? "processed tokens" : "cost"}221 </h2>
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>
266 <UsageProviderChart222 <UsageProviderChart
267 days={days}223 days={days}
268 daily={merged.daily}224 daily={merged.daily}
...@@ -276,63 +232,42 @@ export function UsagePage() {...@@ -276,63 +232,42 @@ export function UsagePage() {
276 </div>232 </div>
277 </section>233 </section>
278234
279 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">235 <section className="flex flex-col gap-2">
280 <Metric236 <h2 className="text-sm font-medium text-foreground">Totals</h2>
281 label="Processed tokens"237 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
282 value={formatTokens(merged.totalTokens)}238 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
283 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}239 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
284 />240 <Metric
285 <Metric241 label="Uncached input"
286 label="Cached input"242 value={formatTokens(merged.uncachedInputTokens)}
287 value={formatTokens(merged.cachedInputTokens)}243 />
288 detail={`${formatPercent(cachedShare)} of observed input`}244 <Metric label="Output" value={formatTokens(merged.outputTokens)} />
289 />245 <Metric
290 <Metric246 label="Cache savings"
291 label="Uncached input"247 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
292 value={formatTokens(merged.uncachedInputTokens)}248 />
293 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}249 </div>
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 />
309 </section>250 </section>
310251
311 <section className="flex flex-col gap-3">252 <section className="flex flex-col gap-3">
312 <div className="flex items-center justify-between gap-3">253 <div className="flex items-center justify-between gap-3">
313 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>254 <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">
315 {(256 {(
316 [257 [
317 { value: "model", label: "model" },258 { value: "model", label: "Model" },
318 { value: "time", label: isPast24Hours ? "hour" : "day" },259 { value: "time", label: isPast24Hours ? "Hour" : "Day" },
319 ] as const260 ] as const
320 ).map((option) => (261 ).map((option) => (
321 <button262 <SegmentedTab
322 key={option.value}263 key={option.value}
323 type="button"264 selected={option.value === breakdown}
324 onClick={() => setBreakdown(option.value)}265 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 )}
331 >266 >
332 {option.label}267 {option.label}
333 </button>268 </SegmentedTab>
334 ))}269 ))}
335 </div>270 </SegmentedTabList>
336 </div>271 </div>
337272
338 {breakdown === "model" ? (273 {breakdown === "model" ? (
...@@ -356,7 +291,7 @@ export function UsagePage() {...@@ -356,7 +291,7 @@ export function UsagePage() {
356 merged.models.map((model) => (291 merged.models.map((model) => (
357 <tr292 <tr
358 key={`${model.provider}:${model.model}`}293 key={`${model.provider}:${model.model}`}
359 className="border-b border-border/50"294 className="hover:bg-muted/20"
360 >295 >
361 <td className="py-2 text-foreground">296 <td className="py-2 text-foreground">
362 <span className="flex items-center gap-2">297 <span className="flex items-center gap-2">
...@@ -403,7 +338,7 @@ export function UsagePage() {...@@ -403,7 +338,7 @@ export function UsagePage() {
403 recentPeriods.map((period) => (338 recentPeriods.map((period) => (
404 <tr339 <tr
405 key={"hourStart" in period ? period.hourStart : period.day}340 key={"hourStart" in period ? period.hourStart : period.day}
406 className="border-b border-border/50"341 className="hover:bg-muted/20"
407 >342 >
408 <td className="py-2 text-foreground">343 <td className="py-2 text-foreground">
409 {"hourStart" in period344 {"hourStart" in period
...@@ -433,7 +368,7 @@ export function UsagePage() {...@@ -433,7 +368,7 @@ export function UsagePage() {
433 </section>368 </section>
434 </>369 </>
435 )}370 )}
436 </div>371 </WorkspacePageContainer>
437 </ScrollArea>372 </ScrollArea>
438 </div>373 </div>
439 </SidebarInset>374 </SidebarInset>
...@@ -452,20 +387,11 @@ function ProviderMark({...@@ -452,20 +387,11 @@ function ProviderMark({
452 return <Mark className={cn("shrink-0", className)} aria-hidden />;387 return <Mark className={cn("shrink-0", className)} aria-hidden />;
453}388}
454389
455function Metric({390function Metric({ label, value }: { readonly label: string; readonly value: string }) {
456 label,
457 value,
458 detail,
459}: {
460 readonly label: string;
461 readonly value: string;
462 readonly detail: string;
463}) {
464 return (391 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">
466 <span className="text-xs text-muted-foreground">{label}</span>393 <span className="text-xs text-muted-foreground">{label}</span>
467 <span className="text-lg text-foreground tabular-nums">{value}</span>394 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
468 <span className="text-xs text-muted-foreground">{detail}</span>
469 </div>395 </div>
470 );396 );
471}397}
...@@ -569,70 +495,51 @@ function UsageDeviceStrip({...@@ -569,70 +495,51 @@ function UsageDeviceStrip({
569 );495 );
570}496}
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
575/**498/**
576 * Static stand-in with the loaded page's shape: headline, provider split,499 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
577 * chart and metrics strip. No shimmer; blocks fill in exactly once when the500 * exactly once when the last device answers.
578 * last device answers.
579 */501 */
580function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {502function UsageSkeleton() {
581 return (503 return (
582 <>504 <>
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)]">
584 <div className="flex flex-col gap-5">506 <div className="flex flex-col gap-5">
585 <div className="flex flex-col gap-1">507 <div className="flex flex-col gap-1">
586 <span className="text-xs tracking-wide text-muted-foreground uppercase">508 <div className="h-8 w-36 rounded-sm bg-muted" />
587 Raw token cost509 <div className="h-3 w-32 rounded-sm bg-muted" />
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" />
591 </div>510 </div>
592
593 {PROVIDER_ORDER.map((provider) => (511 {PROVIDER_ORDER.map((provider) => (
594 <div key={provider} className="flex flex-col gap-1.5">512 <div key={provider} className="flex flex-col gap-1">
595 <div className="flex items-center justify-between">513 <div className="flex items-center justify-between gap-4">
596 <span className="flex items-center gap-2 text-sm text-foreground">514 <span className="flex items-center gap-2">
597 <ProviderMark provider={provider} className="size-4" />515 <ProviderMark provider={provider} className="size-4" />
598 {PROVIDER_LABEL[provider]}516 <div className="h-3.5 w-20 rounded-sm bg-muted" />
599 </span>517 </span>
600 <div className="h-3.5 w-14 rounded-sm bg-muted" />518 <div className="h-3.5 w-14 rounded-sm bg-muted" />
601 </div>519 </div>
602 <div className="h-1 w-full rounded-full bg-muted" />
603 <div className="h-3 w-36 rounded-sm bg-muted" />520 <div className="h-3 w-36 rounded-sm bg-muted" />
604 </div>521 </div>
605 ))}522 ))}
606 </div>523 </div>
607524
608 <div className="flex flex-col gap-3">525 <div className="flex flex-col gap-3">
609 <h2 className="py-1 text-sm font-medium text-foreground">526 <div className="h-4 w-24 rounded-sm bg-muted" />
610 {resolution === "hour" ? "Hourly" : "Daily"} cost527 <div className="h-56 rounded-sm bg-muted/35" />
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>
623 </div>528 </div>
624 </section>529 </section>
625530
626 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">531 <section className="flex flex-col gap-2">
627 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(532 <h2 className="text-sm font-medium text-foreground">Totals</h2>
628 (label) => (533 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
629 <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">534 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
630 <span className="text-xs text-muted-foreground">{label}</span>535 (label) => (
631 <div className="my-1 h-5 w-16 rounded-sm bg-muted" />536 <div key={label} className="flex flex-col gap-0.5">
632 <div className="h-3 w-24 rounded-sm bg-muted" />537 <span className="text-xs text-muted-foreground">{label}</span>
633 </div>538 <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" />
634 ),539 </div>
635 )}540 ),
541 )}
542 </div>
636 </section>543 </section>
637 </>544 </>
638 );545 );
apps/web/src/components/usage/UsageProviderChart.tsx+72-65
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
33
4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
5import {5import {
...@@ -68,13 +68,7 @@ function buildPeriodColumns(...@@ -68,13 +68,7 @@ function buildPeriodColumns(
68 });68 });
69}69}
7070
71/**71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
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 */
78function monotoneTangents(points: readonly Point[]): readonly number[] {72function monotoneTangents(points: readonly Point[]): readonly number[] {
79 const count = points.length;73 const count = points.length;
80 if (count < 2) return [0];74 if (count < 2) return [0];
...@@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {...@@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
115 return tangents;109 return tangents;
116}110}
117111
118/** One cubic segment of a smoothed boundary. */
119interface CurveSegment {112interface CurveSegment {
120 readonly from: Point;113 readonly from: Point;
121 readonly c1: Point;114 readonly c1: Point;
...@@ -123,7 +116,6 @@ interface CurveSegment {...@@ -123,7 +116,6 @@ interface CurveSegment {
123 readonly to: Point;116 readonly to: Point;
124}117}
125118
126/** Smoothed polyline through `points`, as explicit cubic control points. */
127function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {119function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
128 if (points.length < 2) return [];120 if (points.length < 2) return [];
129 const tangents = monotoneTangents(points);121 const tangents = monotoneTangents(points);
...@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {...@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144 return segments;136 return segments;
145}137}
146138
147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {139function curvePath(segments: readonly CurveSegment[]): string {
148 const first = segments[0];140 const first = segments[0];
149 if (first === undefined) return "";141 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)}`;
151 for (const segment of segments) {143 for (const segment of segments) {
152 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)}`;144 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)}`;
153 }145 }
...@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re...@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179/**171/**
180 * Turns the merged daily totals into one column per day.172 * Turns the merged daily totals into one column per day.
181 *173 *
182 * Values are absolute, not cumulative: the series are layered from a shared174 * Values are absolute, not cumulative: each provider is drawn from the same
183 * zero baseline rather than stacked. A stacked chart puts whichever provider is175 * zero baseline so the chart never implies that one provider is always larger.
184 * drawn last permanently above the other, which reads as "that one is bigger"
185 * even on days where it is not.
186 *176 *
187 * The chart paths and the hover readout both consume this, so the number under177 * The chart paths and the hover readout both consume this, so the number under
188 * the cursor is by construction the number that was plotted rather than a178 * the cursor is by construction the number that was plotted rather than a
...@@ -216,42 +206,39 @@ export function UsageProviderChart({...@@ -216,42 +206,39 @@ export function UsageProviderChart({
216 );206 );
217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);207 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
218 const plotRef = useRef<HTMLDivElement | null>(null);208 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(() => {
221 if (periods.length === 0) {213 if (periods.length === 0) {
222 return {214 return {
223 paths: [],215 paths: [],
224 ticks: [0] as readonly number[],216 series: [] as readonly DayColumn[],
225 stepX: 0,217 stepX: 0,
218 ticks: [0] as readonly number[],
226 toY: () => VIEW_HEIGHT,219 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
228 };220 };
229 }221 }
230222
231 const columns = buildPeriodColumns(periods, byPeriod, metric);223 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.
236 const peak = columns.reduce(224 const peak = columns.reduce(
237 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),225 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238 0,226 0,
239 );227 );
240 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);228 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);229 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.
244 const toY = (value: number) =>230 const toY = (value: number) =>
245 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);231 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246232
247 const built = PROVIDER_ORDER.map((provider, providerIndex) => {233 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
248 const curve = smoothCurve(234 const line = curvePath(
249 columns.map((column, dayIndex) => ({235 smoothCurve(
250 x: dayIndex * step,236 columns.map((column, periodIndex) => ({
251 y: toY(column.bands[providerIndex]?.value ?? 0),237 x: periodIndex * step,
252 })),238 y: toY(column.bands[providerIndex]?.value ?? 0),
239 })),
240 ),
253 );241 );
254 const line = curvePath(curve, "M");
255 return {242 return {
256 provider,243 provider,
257 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),244 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
...@@ -260,30 +247,65 @@ export function UsageProviderChart({...@@ -260,30 +247,65 @@ export function UsageProviderChart({
260 };247 };
261 });248 });
262249
263 // Paint the heavier series first so the lighter one is never buried under250 return {
264 // it. The fills are faint enough that the order barely shows, but the251 paths: built.toSorted((a, b) => b.total - a.total),
265 // strokes are drawn in a second pass regardless, so neither can be hidden.252 series: columns,
266 const ordered = [...built].sort((a, b) => b.total - a.total);253 stepX: step,
267254 ticks: tickValues,
268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };255 toY,
256 };
269 }, [byPeriod, metric, periods]);257 }, [byPeriod, metric, periods]);
270258
271 const format = metric === "tokens" ? formatTokens : formatUsd;259 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
273 const handleMove = useCallback(290 const handleMove = useCallback(
274 (event: React.MouseEvent<HTMLDivElement>) => {291 (event: React.MouseEvent<HTMLDivElement>) => {
275 const bounds = plotRef.current?.getBoundingClientRect();292 const plot = plotRef.current;
276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;293 if (plot === null || periods.length === 0) return;
277 const fraction = (event.clientX - bounds.left) / bounds.width;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;
278 const index = Math.round(fraction * (periods.length - 1));299 const index = Math.round(fraction * (periods.length - 1));
300 hoverPositionRef.current = { x: localX, y: localY };
301 positionTooltip();
279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));302 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280 },303 },
281 [periods.length],304 [periods.length, positionTooltip],
282 );305 );
283306
284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];307 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];308 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287 const formatPeriod = (period: string) =>309 const formatPeriod = (period: string) =>
288 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);310 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289 const formatTooltipPeriod = (period: string) =>311 const formatTooltipPeriod = (period: string) =>
...@@ -311,7 +333,10 @@ export function UsageProviderChart({...@@ -311,7 +333,10 @@ export function UsageProviderChart({
311 ref={plotRef}333 ref={plotRef}
312 className="relative h-56 flex-1"334 className="relative h-56 flex-1"
313 onMouseMove={handleMove}335 onMouseMove={handleMove}
314 onMouseLeave={() => setHoverIndex(null)}336 onMouseLeave={() => {
337 hoverPositionRef.current = null;
338 setHoverIndex(null);
339 }}
315 >340 >
316 <svg341 <svg
317 className="h-full w-full"342 className="h-full w-full"
...@@ -337,7 +362,6 @@ export function UsageProviderChart({...@@ -337,7 +362,6 @@ export function UsageProviderChart({
337 );362 );
338 })}363 })}
339364
340 {/* Fills first, then every stroke, so no series covers another's line. */}
341 {paths.map(({ provider, area }) => (365 {paths.map(({ provider, area }) => (
342 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />366 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
343 ))}367 ))}
...@@ -368,10 +392,11 @@ export function UsageProviderChart({...@@ -368,10 +392,11 @@ export function UsageProviderChart({
368392
369 {hoveredPeriod === undefined ? null : (393 {hoveredPeriod === undefined ? null : (
370 <div394 <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"
372 style={{397 style={{
373 left: `${hoverLeft}%`,398 left: "var(--usage-tooltip-left, 0px)",
374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",399 top: "var(--usage-tooltip-top, 0px)",
375 }}400 }}
376 >401 >
377 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>402 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
...@@ -418,21 +443,3 @@ export function UsageProviderChart({...@@ -418,21 +443,3 @@ export function UsageProviderChart({
418 </div>443 </div>
419 );444 );
420}445}
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";...@@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts";
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";3import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
5/**5/**
6 * Series and table order. The chart layers both providers from a shared zero6 * Stable provider reading order across summaries, tables, and hover rows.
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.
9 */7 */
10export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];8export 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";...@@ -4,6 +4,15 @@ const SVG_NS = "http://www.w3.org/2000/svg";
44
5// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).5// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).
6const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {6const 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 ],
7 pencil: [16 pencil: [
8 {17 {
9 tag: "path",18 tag: "path",
...@@ -17,6 +26,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -17,6 +26,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
17 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },26 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
18 { 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" } },27 { 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" } },
19 ],28 ],
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 ],
20 "folder-tree": [94 "folder-tree": [
21 {95 {
22 tag: "path",96 tag: "path",
...@@ -44,7 +118,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -44,7 +118,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
44118
45function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {119function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
46 const paths = ICON_PATHS[name];120 const paths = ICON_PATHS[name];
47 if (!paths) {121 if (!paths || typeof document.createElementNS !== "function") {
48 return null;122 return null;
49 }123 }
50 const svg = document.createElementNS(SVG_NS, "svg");124 const svg = document.createElementNS(SVG_NS, "svg");
...@@ -57,7 +131,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV...@@ -57,7 +131,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
57 svg.setAttribute("stroke-linejoin", "round");131 svg.setAttribute("stroke-linejoin", "round");
58 svg.setAttribute(132 svg.setAttribute(
59 "class",133 "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",
61 );135 );
62 for (const node of paths) {136 for (const node of paths) {
63 const child = document.createElementNS(SVG_NS, node.tag);137 const child = document.createElementNS(SVG_NS, node.tag);
...@@ -200,6 +274,16 @@ export function showContextMenuFallback<T extends string>(...@@ -200,6 +274,16 @@ export function showContextMenuFallback<T extends string>(
200 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";274 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
201275
202 for (const item of entries) {276 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
203 if (item.header === true) {287 if (item.header === true) {
204 const header = document.createElement("div");288 const header = document.createElement("div");
205 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";289 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
...@@ -247,10 +331,12 @@ export function showContextMenuFallback<T extends string>(...@@ -247,10 +331,12 @@ export function showContextMenuFallback<T extends string>(
247 button.appendChild(label);331 button.appendChild(label);
248332
249 if (hasChildren) {333 if (hasChildren) {
250 const chevron = document.createElement("span");334 const chevron = createIconElement("chevron-right", "neutral");
251 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";335 if (chevron) {
252 chevron.textContent = ">";336 chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80");
253 button.appendChild(chevron);337 chevron.dataset.contextMenuChevron = "true";
338 button.appendChild(chevron);
339 }
254 }340 }
255341
256 if (!isDisabled) {342 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...@@ -241,6 +241,22 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
241 opacity: 1;241 opacity: 1;
242 }242 }
243 }243 }
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 }
244 @keyframes status-ping {260 @keyframes status-ping {
245 /* Burst first (immediate feedback for click ripples), then hold261 /* Burst first (immediate feedback for click ripples), then hold
246 invisible for the rest of the cycle. Mirrors animate-ping's262 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...@@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
431 }447 }
432}448}
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
434@layer base {506@layer base {
435 :root {507 :root {
436 /* Keep the original T3 Code artwork palettes as the defaults. Built-in508 /* 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] {...@@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] {
13491421
1350/* The panel layout toggles stay ghost: they render both inside the header and1422/* The panel layout toggles stay ghost: they render both inside the header and
1351 in the titlebar strip, so filling them would make them change appearance as1423 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 pressed1424 the panel opens. Their icons use the same themed foreground as the toolbar
1353 keep the base ghost accent. The tooltip trigger's data-slot wins over the1425 action text; hover and pressed keep the base ghost accent. The tooltip
1354 toggle's when the trigger renders the toggle, so match both. */1426 trigger's data-slot wins over the toggle's when it renders the toggle, so
1427 match both. */
1355html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],1428html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
1356html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],1429html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
1357html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],1430html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
1358html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {1431html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1359 --control-icon-color: var(--toolbar-foreground);1432 --control-icon-color: var(--toolbar-control-foreground);
1360 color: var(--toolbar-foreground);1433 color: var(--toolbar-control-foreground);
1361}1434}
13621435
1363html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,1436html[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...@@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118 return null;118 return null;
119}119}
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
121function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {132function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
122 const repository = match?.[1];133 const repository = match?.[1];
123 const number = Number(match?.[2]);134 const number = Number(match?.[2]);
apps/web/src/routes/-chatIndexTitlebar.test.ts+1-3
...@@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => {...@@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => {
1515
16 const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd);16 const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd);
1717
18 expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]");18 expect(onboardingHeader).toContain('<WorkspacePageHeader className="border-b border-border">');
19 expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]");
20 expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS");
21 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);19 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);
22 });20 });
23});21});
apps/web/src/routes/_chat.index.tsx+3-9
...@@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";...@@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
8import { Button } from "../components/ui/button";8import { Button } from "../components/ui/button";
9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
10import { SidebarInset } from "../components/ui/sidebar";10import { SidebarInset } from "../components/ui/sidebar";
11import { WorkspacePageHeader } from "../components/WorkspacePageContainer";
11import { useNewThreadHandler } from "../hooks/useHandleNewThread";12import { useNewThreadHandler } from "../hooks/useHandleNewThread";
12import {13import {
13 useAllEnvironmentShellsBootstrapped,14 useAllEnvironmentShellsBootstrapped,
...@@ -17,8 +18,6 @@ import {...@@ -17,8 +18,6 @@ import {
17import { useEnvironments } from "../state/environments";18import { useEnvironments } from "../state/environments";
18import { APP_DISPLAY_NAME } from "~/branding";19import { APP_DISPLAY_NAME } from "~/branding";
19import { hasCloudPublicConfig } from "~/cloud/publicConfig";20import { hasCloudPublicConfig } from "~/cloud/publicConfig";
20import { cn } from "~/lib/utils";
21import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
2221
23function ChatIndexRouteView() {22function ChatIndexRouteView() {
24 const { authGateState } = Route.useRouteContext();23 const { authGateState } = Route.useRouteContext();
...@@ -143,18 +142,13 @@ function HostedStaticOnboardingState() {...@@ -143,18 +142,13 @@ function HostedStaticOnboardingState() {
143 return (142 return (
144 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">143 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
145 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">144 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
146 <header145 <WorkspacePageHeader className="border-b border-border">
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 >
152 <div className="flex items-center gap-2">146 <div className="flex items-center gap-2">
153 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">147 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
154 {APP_DISPLAY_NAME}148 {APP_DISPLAY_NAME}
155 </span>149 </span>
156 </div>150 </div>
157 </header>151 </WorkspacePageHeader>
158152
159 <Empty className="flex-1">153 <Empty className="flex-1">
160 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">154 <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 {...@@ -74,6 +74,12 @@ import {
74 WorkspaceBreadcrumbItem,74 WorkspaceBreadcrumbItem,
75 WorkspaceBreadcrumbSeparator,75 WorkspaceBreadcrumbSeparator,
76} from "../components/WorkspaceBreadcrumb";76} from "../components/WorkspaceBreadcrumb";
77import {
78 WorkspacePageContainer,
79 WorkspacePageHeader,
80 WorkspacePageHeaderEdgeControl,
81} from "../components/WorkspacePageContainer";
82import { isElectron } from "../env";
77import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";83import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
78import { Button } from "../components/ui/button";84import { Button } from "../components/ui/button";
79import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";85import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
...@@ -99,7 +105,6 @@ import {...@@ -99,7 +105,6 @@ import {
99import { useAtomCommand } from "../state/use-atom-command";105import { useAtomCommand } from "../state/use-atom-command";
100import { cn } from "~/lib/utils";106import { cn } from "~/lib/utils";
101import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";107import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
102import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
103108
104export interface PullRequestsSearch {109export interface PullRequestsSearch {
105 readonly involvement: PullRequestInvolvement;110 readonly involvement: PullRequestInvolvement;
...@@ -1181,6 +1186,17 @@ function PullRequestsRouteView() {...@@ -1181,6 +1186,17 @@ function PullRequestsRouteView() {
1181 : null,1186 : null,
1182 [search.number, search.repository, selectedProject],1187 [search.number, search.repository, selectedProject],
1183 );1188 );
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;
1184 useEffect(() => {1200 useEffect(() => {
1185 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;1201 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
1186 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);1202 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
...@@ -1294,9 +1310,10 @@ function PullRequestsRouteView() {...@@ -1294,9 +1310,10 @@ function PullRequestsRouteView() {
1294 terminalAvailable={false}1310 terminalAvailable={false}
1295 terminalOpen={false}1311 terminalOpen={false}
1296 terminalShortcutLabel={null}1312 terminalShortcutLabel={null}
1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}1313 rightPanelAvailable={rightPanelAvailable}
1298 rightPanelOpen={rightPanelState.isOpen}1314 rightPanelOpen={rightPanelState.isOpen}
1299 rightPanelShortcutLabel={null}1315 rightPanelShortcutLabel={null}
1316 rightPanelUnavailableLabel="Select a pull request first"
1300 liveAgentCount={0}1317 liveAgentCount={0}
1301 onToggleTerminal={() => undefined}1318 onToggleTerminal={() => undefined}
1302 onToggleRightPanel={toggleRightPanel}1319 onToggleRightPanel={toggleRightPanel}
...@@ -1603,7 +1620,6 @@ function PullRequestsRouteView() {...@@ -1603,7 +1620,6 @@ function PullRequestsRouteView() {
1603 reviewingQuery.refresh();1620 reviewingQuery.refresh();
1604 }}1621 }}
1605 onStateChange={handlePullRequestTabStatusChange}1622 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
1607 />1623 />
1608 </RightPanelTabs>1624 </RightPanelTabs>
1609 ) : null}1625 ) : null}
...@@ -1827,18 +1843,10 @@ function PullRequestsColumn({...@@ -1827,18 +1843,10 @@ function PullRequestsColumn({
1827 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a1843 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
1828 // content surface that lets it show reads as a different background than every thread.1844 // content surface that lets it show reads as a different background than every thread.
1829 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">1845 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
1830 <header1846 {/* A closed right panel leaves this column full-width, so the shared header
1831 className={cn(1847 reserves native window controls. While the panel is open, the column ends
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",1848 at the panel and the absolute controls strip owns the top-right corner. */}
1833 // A closed right panel leaves this column full-width, so its header runs1849 <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}>
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 >
1842 {condensed ? (1850 {condensed ? (
1843 <WorkspaceBreadcrumb ariaLabel="Pull request scope">1851 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
1844 {/* The page name remains the foreground anchor in both states; the live filters are1852 {/* The page name remains the foreground anchor in both states; the live filters are
...@@ -1880,27 +1888,24 @@ function PullRequestsColumn({...@@ -1880,27 +1888,24 @@ function PullRequestsColumn({
1880 )}1888 )}
1881 <div className="min-w-0 flex-1" />1889 <div className="min-w-0 flex-1" />
1882 {condensed ? (1890 {condensed ? (
1883 <ExpandableSearch1891 <div className="flex shrink-0 items-center gap-1.5">
1884 searchInput={searchInput}1892 <ExpandableSearch
1885 searchValue={searchValue}1893 searchInput={searchInput}
1886 open={searchOpen}1894 searchValue={searchValue}
1887 onOpenChange={setSearchOpen}1895 open={searchOpen}
1888 focusToken={searchFocusToken}1896 onOpenChange={setSearchOpen}
1889 onFocusWithin={(focused) => {1897 focusToken={searchFocusToken}
1890 topbarSearchFocusedRef.current = focused;1898 onFocusWithin={(focused) => {
1891 }}1899 topbarSearchFocusedRef.current = focused;
1892 />1900 }}
1901 />
1902 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
1903 </div>
1904 ) : null}
1905 {rightPanelControl ? (
1906 <WorkspacePageHeaderEdgeControl>{rightPanelControl}</WorkspacePageHeaderEdgeControl>
1893 ) : null}1907 ) : null}
1894 <Button1908 </WorkspacePageHeader>
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>
19041909
1905 <div1910 <div
1906 ref={scrollRef}1911 ref={scrollRef}
...@@ -1909,19 +1914,43 @@ function PullRequestsColumn({...@@ -1909,19 +1914,43 @@ function PullRequestsColumn({
1909 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the1914 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
1910 settings page makes: at rest the controls sit fully below the mask, and only1915 settings page makes: at rest the controls sit fully below the mask, and only
1911 content actually passing under the chrome fades. */}1916 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">
1913 <div className="flex flex-col gap-3">1918 <div className="flex flex-col gap-3">
1914 <div ref={inFlowSearchRef} className="flex items-center gap-2">1919 <div ref={inFlowSearchRef} className="flex items-center gap-2">
1915 {searchInput}1920 {searchInput}
1916 {filtersMenu}1921 {filtersMenu}
1922 {!condensed ? (
1923 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1924 ) : null}
1917 </div>1925 </div>
1918 {/* Scrolled past this marker, the controls are gone and the title takes over. */}1926 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
1919 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />1927 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
1920 </div>1928 </div>
19211929
1922 {listBody}1930 {listBody}
1923 </div>1931 </WorkspacePageContainer>
1924 </div>1932 </div>
1925 </div>1933 </div>
1926 );1934 );
1927}1935}
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";...@@ -13,9 +13,8 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels";
13import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";13import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
14import { Button } from "../components/ui/button";14import { Button } from "../components/ui/button";
15import { SidebarInset } from "../components/ui/sidebar";15import { SidebarInset } from "../components/ui/sidebar";
16import { WorkspacePageHeader } from "../components/WorkspacePageContainer";
16import { isElectron } from "../env";17import { isElectron } from "../env";
17import { cn } from "~/lib/utils";
18import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1918
20function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {19function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
21 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);20 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
...@@ -72,41 +71,16 @@ function SettingsContentLayout() {...@@ -72,41 +71,16 @@ function SettingsContentLayout() {
72 return (71 return (
73 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">72 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
74 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">73 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
75 {!isElectron && (74 <WorkspacePageHeader electron={isElectron}>
76 <header75 <div className="flex w-full items-center gap-3">
77 className={cn(76 <SettingsBreadcrumb pathname={location.pathname} />
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",77 {showRestoreDefaults ? (
79 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,78 <div className="ms-auto flex items-center gap-2">
80 )}79 <RestoreDefaultsButton onRestored={handleRestored} />
81 >80 </div>
82 <div className="flex w-full items-center gap-2">81 ) : null}
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>
108 </div>82 </div>
109 )}83 </WorkspacePageHeader>
11084
111 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">85 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
112 <Outlet />86 <Outlet />
apps/web/src/session-logic.test.ts+127-6
...@@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => {...@@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => {
722});722});
723723
724describe("deriveWorkLogEntries", () => {724describe("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", () => {
726 const activities: OrchestrationThreadActivity[] = [726 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 }),
727 makeActivity({784 makeActivity({
728 id: "tool-complete",785 id: "tool-complete",
729 createdAt: "2026-02-23T00:00:03.000Z",786 createdAt: "2026-02-23T00:00:03.000Z",
730 summary: "Tool call complete",787 summary: "Command run",
731 kind: "tool.completed",788 kind: "tool.completed",
789 payload: {
790 itemType: "command_execution",
791 toolCallId: "call-1",
792 status: "completed",
793 title: "Command run",
794 },
732 }),795 }),
733 makeActivity({796 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",
735 createdAt: "2026-02-23T00:00:02.000Z",839 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",
737 kind: "tool.started",853 kind: "tool.started",
854 payload: { itemType: "search", title: "Search", status: "inProgress" },
738 }),855 }),
739 ];856 ];
740857
741 const entries = deriveWorkLogEntries(activities);858 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([
742 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);859 "unkeyed-start-1",
860 "keyed-start",
861 "unkeyed-start-2",
862 ]);
743 });863 });
744864
745 it("omits task.started but shows task.progress and task.completed", () => {865 it("omits task.started but shows task.progress and task.completed", () => {
...@@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => {
1239 expect(entries).toHaveLength(1);1359 expect(entries).toHaveLength(1);
1240 expect(entries[0]).toMatchObject({1360 expect(entries[0]).toMatchObject({
1241 id: "grep-complete",1361 id: "grep-complete",
1362 toolCallId: "tool-grep-1",
1242 toolTitle: "grep",1363 toolTitle: "grep",
1243 detail: "19 files",1364 detail: "19 files",
1244 itemType: "web_search",1365 itemType: "web_search",
apps/web/src/session-logic.ts+98-7
...@@ -65,6 +65,8 @@ export interface WorkLogEntry {...@@ -65,6 +65,8 @@ export interface WorkLogEntry {
65 id: string;65 id: string;
66 createdAt: string;66 createdAt: string;
67 turnId?: TurnId | null;67 turnId?: TurnId | null;
68 /** Stable provider identity across in-progress and completed lifecycle updates. */
69 toolCallId?: string;
68 label: string;70 label: string;
69 detail?: string;71 detail?: string;
70 command?: string;72 command?: string;
...@@ -748,7 +750,6 @@ export function deriveWorkLogEntries(...@@ -748,7 +750,6 @@ export function deriveWorkLogEntries(
748 const ordered = [...activities].toSorted(compareActivitiesByOrder);750 const ordered = [...activities].toSorted(compareActivitiesByOrder);
749 const entries: DerivedWorkLogEntry[] = [];751 const entries: DerivedWorkLogEntry[] = [];
750 for (const activity of ordered) {752 for (const activity of ordered) {
751 if (activity.kind === "tool.started") continue;
752 // Agent task.started rows are CTA seeds: they carry the true spawn turn,753 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
753 // which is the batch key (completions of background subagents arrive754 // which is the batch key (completions of background subagents arrive
754 // under later synthetic turns and must not start new batches). They755 // under later synthetic turns and must not start new batches). They
...@@ -757,8 +758,13 @@ export function deriveWorkLogEntries(...@@ -757,8 +758,13 @@ export function deriveWorkLogEntries(
757 if (activity.kind === "task.updated") continue;758 if (activity.kind === "task.updated") continue;
758 if (activity.kind === "tool.progress") continue;759 if (activity.kind === "tool.progress") continue;
759 if (activity.kind === "context-window.updated") continue;760 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;
760 if (activity.summary === "Checkpoint captured") continue;765 if (activity.summary === "Checkpoint captured") continue;
761 if (isPlanBoundaryToolActivity(activity)) continue;766 if (isPlanBoundaryToolActivity(activity)) continue;
767 if (isCodexTerminalInteractionActivity(activity)) continue;
762 if (isAgentInternalActivity(activity)) continue;768 if (isAgentInternalActivity(activity)) continue;
763 entries.push(toDerivedWorkLogEntry(activity));769 entries.push(toDerivedWorkLogEntry(activity));
764 }770 }
...@@ -769,7 +775,11 @@ export function deriveWorkLogEntries(...@@ -769,7 +775,11 @@ export function deriveWorkLogEntries(
769}775}
770776
771function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {777function 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 ) {
773 return false;783 return false;
774 }784 }
775785
...@@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
780 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");790 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
781}791}
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
783function extractWorkLogToolLifecycleStatus(815function extractWorkLogToolLifecycleStatus(
784 payload: Record<string, unknown> | null,816 payload: Record<string, unknown> | null,
785): WorkLogToolLifecycleStatus | undefined {817): WorkLogToolLifecycleStatus | undefined {
...@@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
878 entry.toolCallId = toolCallId;910 entry.toolCallId = toolCallId;
879 }911 }
880 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);912 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
913 if (!toolLifecycleStatus && activity.kind === "tool.started") {
914 toolLifecycleStatus = "inProgress";
915 }
881 if (!toolLifecycleStatus && activity.kind === "tool.completed") {916 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
882 toolLifecycleStatus = "completed";917 toolLifecycleStatus = "completed";
883 }918 }
...@@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {...@@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
933 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;968 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
934}969}
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
936function collapseDerivedWorkLogEntries(982function collapseDerivedWorkLogEntries(
937 entries: ReadonlyArray<DerivedWorkLogEntry>,983 entries: ReadonlyArray<DerivedWorkLogEntry>,
938): DerivedWorkLogEntry[] {984): DerivedWorkLogEntry[] {
...@@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries(...@@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries(
949 // own turn splintered one batch into a stream of "Kicked off N subagents"995 // own turn splintered one batch into a stream of "Kicked off N subagents"
950 // rows (live-test finding, thread 7ac7ef05).996 // rows (live-test finding, thread 7ac7ef05).
951 const groupKeyByTaskId = new Map<string, string>();997 const groupKeyByTaskId = new Map<string, string>();
998 const toolLifecycleRowIndex = new Map<string, number>();
952 for (const entry of entries) {999 for (const entry of entries) {
953 const isTaskRow =1000 const isTaskRow =
954 entry.taskId !== undefined &&1001 entry.taskId !== undefined &&
...@@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries(...@@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries(
993 });1040 });
994 continue;1041 continue;
995 }1042 }
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 }
996 const previous = collapsed.at(-1);1060 const previous = collapsed.at(-1);
997 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {1061 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 }
999 continue;1071 continue;
1000 }1072 }
1001 collapsed.push(entry);1073 collapsed.push(entry);
1074 if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") {
1075 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1076 }
1002 }1077 }
1003 return collapsed;1078 return collapsed;
1004}1079}
...@@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries(...@@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries(
1007 previous: DerivedWorkLogEntry,1082 previous: DerivedWorkLogEntry,
1008 next: DerivedWorkLogEntry,1083 next: DerivedWorkLogEntry,
1009): boolean {1084): 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 ) {
1011 return false;1090 return false;
1012 }1091 }
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 ) {
1014 return false;1097 return false;
1015 }1098 }
1016 if (previous.activityKind === "tool.completed") {1099 if (previous.activityKind === "tool.completed") {
...@@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un...@@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
1080 ) {1163 ) {
1081 return `task${entry.taskId}`;1164 return `task${entry.taskId}`;
1082 }1165 }
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 ) {
1084 return undefined;1171 return undefined;
1085 }1172 }
1086 if (entry.toolCallId) {1173 if (entry.toolCallId) {
...@@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {...@@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
1283 const item = asRecord(data?.item);1370 const item = asRecord(data?.item);
1284 const itemResult = asRecord(item?.result);1371 const itemResult = asRecord(item?.result);
1285 const itemInput = asRecord(item?.input);1372 const itemInput = asRecord(item?.input);
1373 const dataInput = asRecord(data?.input);
1374 const stateInput = asRecord(asRecord(data?.state)?.input);
1286 const itemType = asTrimmedString(payload?.itemType);1375 const itemType = asTrimmedString(payload?.itemType);
1287 const detail = asTrimmedString(payload?.detail);1376 const detail = asTrimmedString(payload?.detail);
1288 const candidates: unknown[] = [1377 const candidates: unknown[] = [
...@@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {...@@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
1290 itemInput?.command,1379 itemInput?.command,
1291 itemResult?.command,1380 itemResult?.command,
1292 data?.command,1381 data?.command,
1382 dataInput?.command,
1383 stateInput?.command,
1293 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,1384 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
1294 ];1385 ];
12951386
...@@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul...@@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
13161407
1317function extractToolCallId(payload: Record<string, unknown> | null): string | null {1408function extractToolCallId(payload: Record<string, unknown> | null): string | null {
1318 const data = asRecord(payload?.data);1409 const data = asRecord(payload?.data);
1319 return asTrimmedString(data?.toolCallId);1410 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
1320}1411}
13211412
1322function normalizeInlinePreview(value: string): string {1413function normalizeInlinePreview(value: string): string {
apps/web/src/terminalUiStateStore.test.ts+8
...@@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => {...@@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => {
18 useTerminalUiStateStore.persist.clearStorage();18 useTerminalUiStateStore.persist.clearStorage();
19 useTerminalUiStateStore.setState({19 useTerminalUiStateStore.setState({
20 terminalUiStateByThreadKey: {},20 terminalUiStateByThreadKey: {},
21 terminalCustomLabelsByThreadKey: {},
21 suppressedTerminalIdsByThreadKey: {},22 suppressedTerminalIdsByThreadKey: {},
22 });23 });
23 });24 });
...@@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => {...@@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => {
248 it("reconciles terminal ids from an external ordered list", () => {249 it("reconciles terminal ids from an external ordered list", () => {
249 const store = useTerminalUiStateStore.getState();250 const store = useTerminalUiStateStore.getState();
250 store.setTerminalOpen(THREAD_REF, true);251 store.setTerminalOpen(THREAD_REF, true);
252 store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server");
253 store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task");
251 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);254 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
252255
253 const terminalUiState = selectThreadTerminalUiState(256 const terminalUiState = selectThreadTerminalUiState(
...@@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => {...@@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => {
260 { id: "group-term-a", terminalIds: ["term-a"] },263 { id: "group-term-a", terminalIds: ["term-a"] },
261 { id: "group-term-b", terminalIds: ["term-b"] },264 { id: "group-term-b", terminalIds: ["term-b"] },
262 ]);265 ]);
266 expect(
267 useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[
268 scopedThreadKey(THREAD_REF)
269 ],
270 ).toEqual({ "term-a": "API server" });
263 });271 });
264272
265 it("does not import a closed panel terminal from stale metadata", () => {273 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";...@@ -32,8 +32,11 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
32interface PersistedTerminalUiStateStoreState {32interface PersistedTerminalUiStateStoreState {
33 terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>;33 terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>;
34 terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;34 terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;
35 terminalCustomLabelsByThreadKey?: Record<string, Record<string, string>>;
35}36}
3637
38const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({});
39
37export function migratePersistedTerminalUiStateStoreState(40export function migratePersistedTerminalUiStateStoreState(
38 persistedState: unknown,41 persistedState: unknown,
39 _version: number,42 _version: number,
...@@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState(...@@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState(
50 parseScopedThreadKey(threadKey),53 parseScopedThreadKey(threadKey),
51 ),54 ),
52 );55 );
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 };
55}82}
5683
57function createTerminalUiStateStorage() {84function createTerminalUiStateStorage() {
...@@ -489,6 +516,18 @@ export function selectThreadTerminalUiState(...@@ -489,6 +516,18 @@ export function selectThreadTerminalUiState(
489 );516 );
490}517}
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
492function updateTerminalUiStateByThreadKey(531function updateTerminalUiStateByThreadKey(
493 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,532 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
494 threadRef: ScopedThreadRef,533 threadRef: ScopedThreadRef,
...@@ -562,6 +601,7 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st...@@ -562,6 +601,7 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st
562601
563interface TerminalUiStateStoreState {602interface TerminalUiStateStoreState {
564 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;603 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
604 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>;
565 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */605 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */
566 suppressedTerminalIdsByThreadKey: Record<string, string[]>;606 suppressedTerminalIdsByThreadKey: Record<string, string[]>;
567 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;607 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
...@@ -575,6 +615,11 @@ interface TerminalUiStateStoreState {...@@ -575,6 +615,11 @@ interface TerminalUiStateStoreState {
575 options?: { open?: boolean; active?: boolean },615 options?: { open?: boolean; active?: boolean },
576 ) => void;616 ) => void;
577 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;617 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
618 setTerminalCustomLabel: (
619 threadRef: ScopedThreadRef,
620 terminalId: string,
621 label: string | null,
622 ) => void;
578 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;623 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
579 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;624 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
580 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;625 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
...@@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
591 state: ThreadTerminalUiState,636 state: ThreadTerminalUiState,
592 suppressedTerminalIds: readonly string[],637 suppressedTerminalIds: readonly string[],
593 ) => ThreadTerminalUiState,638 ) => ThreadTerminalUiState,
594 suppression?: { terminalId: string; suppressed: boolean },639 suppression?: {
640 terminalId: string;
641 suppressed: boolean;
642 clearCustomLabel?: boolean;
643 },
644 pruneCustomLabels = false,
595 ) => {645 ) => {
596 set((state) => {646 set((state) => {
597 const threadKey = terminalThreadKey(threadRef);647 const threadKey = terminalThreadKey(threadRef);
...@@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
609 suppression.suppressed,659 suppression.suppressed,
610 )660 )
611 : state.suppressedTerminalIdsByThreadKey;661 : 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 }
612 if (695 if (
613 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&696 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
614 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey697 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey &&
698 nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey
615 ) {699 ) {
616 return state;700 return state;
617 }701 }
618 return {702 return {
619 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,703 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
620 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,704 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
705 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
621 };706 };
622 });707 });
623 };708 };
624709
625 return {710 return {
626 terminalUiStateByThreadKey: {},711 terminalUiStateByThreadKey: {},
712 terminalCustomLabelsByThreadKey: {},
627 suppressedTerminalIdsByThreadKey: {},713 suppressedTerminalIdsByThreadKey: {},
628 setTerminalOpen: (threadRef, open) => {714 setTerminalOpen: (threadRef, open) => {
629 const terminalState = selectThreadTerminalUiState(715 const terminalState = selectThreadTerminalUiState(
...@@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
682 ),768 ),
683 setActiveTerminal: (threadRef, terminalId) =>769 setActiveTerminal: (threadRef, terminalId) =>
684 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),770 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 }),
685 closeTerminal: (threadRef, terminalId) =>799 closeTerminal: (threadRef, terminalId) =>
686 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {800 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {
687 terminalId,801 terminalId,
688 suppressed: true,802 suppressed: true,
803 clearCustomLabel: true,
689 }),804 }),
690 reconcileTerminalIds: (threadRef, nextIds) =>805 reconcileTerminalIds: (threadRef, nextIds) =>
691 updateTerminal(threadRef, (state, suppressedTerminalIds) => {806 updateTerminal(
692 if (suppressedTerminalIds.length === 0) {807 threadRef,
693 return reconcileThreadTerminalSessionIds(state, nextIds);808 (state, suppressedTerminalIds) => {
694 }809 if (suppressedTerminalIds.length === 0) {
695 const suppressedIds = new Set(suppressedTerminalIds);810 return reconcileThreadTerminalSessionIds(state, nextIds);
696 return reconcileThreadTerminalSessionIds(811 }
697 state,812 const suppressedIds = new Set(suppressedTerminalIds);
698 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),813 return reconcileThreadTerminalSessionIds(
699 );814 state,
700 }),815 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),
816 );
817 },
818 undefined,
819 true,
820 ),
701 clearTerminalUiState: (threadRef) =>821 clearTerminalUiState: (threadRef) =>
702 set((state) => {822 set((state) => {
703 const threadKey = terminalThreadKey(threadRef);823 const threadKey = terminalThreadKey(threadRef);
...@@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
708 );828 );
709 const hadSuppressedTerminalIds =829 const hadSuppressedTerminalIds =
710 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;830 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
831 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
711 if (832 if (
712 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&833 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
713 !hadSuppressedTerminalIds834 !hadSuppressedTerminalIds &&
835 !hadCustomLabels
714 ) {836 ) {
715 return state;837 return state;
716 }838 }
717 return {839 return {
718 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,840 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
841 terminalCustomLabelsByThreadKey: removeRecordEntry(
842 state.terminalCustomLabelsByThreadKey,
843 threadKey,
844 ),
719 suppressedTerminalIdsByThreadKey: removeRecordEntry(845 suppressedTerminalIdsByThreadKey: removeRecordEntry(
720 state.suppressedTerminalIdsByThreadKey,846 state.suppressedTerminalIdsByThreadKey,
721 threadKey,847 threadKey,
...@@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
728 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;854 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
729 const hadSuppressedTerminalIds =855 const hadSuppressedTerminalIds =
730 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;856 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
731 if (!hadTerminalUiState && !hadSuppressedTerminalIds) {857 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
858 if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) {
732 return state;859 return state;
733 }860 }
734 return {861 return {
...@@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
736 state.terminalUiStateByThreadKey,863 state.terminalUiStateByThreadKey,
737 threadKey,864 threadKey,
738 ),865 ),
866 terminalCustomLabelsByThreadKey: removeRecordEntry(
867 state.terminalCustomLabelsByThreadKey,
868 threadKey,
869 ),
739 suppressedTerminalIdsByThreadKey: removeRecordEntry(870 suppressedTerminalIdsByThreadKey: removeRecordEntry(
740 state.suppressedTerminalIdsByThreadKey,871 state.suppressedTerminalIdsByThreadKey,
741 threadKey,872 threadKey,
...@@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
747 const orphanedIds = new Set(878 const orphanedIds = new Set(
748 [879 [
749 ...Object.keys(state.terminalUiStateByThreadKey),880 ...Object.keys(state.terminalUiStateByThreadKey),
881 ...Object.keys(state.terminalCustomLabelsByThreadKey),
750 ...Object.keys(state.suppressedTerminalIdsByThreadKey),882 ...Object.keys(state.suppressedTerminalIdsByThreadKey),
751 ].filter((key) => !activeThreadKeys.has(key)),883 ].filter((key) => !activeThreadKeys.has(key)),
752 );884 );
...@@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
757 const nextSuppressedTerminalIdsByThreadKey = {889 const nextSuppressedTerminalIdsByThreadKey = {
758 ...state.suppressedTerminalIdsByThreadKey,890 ...state.suppressedTerminalIdsByThreadKey,
759 };891 };
892 const nextTerminalCustomLabelsByThreadKey = {
893 ...state.terminalCustomLabelsByThreadKey,
894 };
760 for (const id of orphanedIds) {895 for (const id of orphanedIds) {
761 delete nextTerminalUiStateByThreadKey[id];896 delete nextTerminalUiStateByThreadKey[id];
897 delete nextTerminalCustomLabelsByThreadKey[id];
762 delete nextSuppressedTerminalIdsByThreadKey[id];898 delete nextSuppressedTerminalIdsByThreadKey[id];
763 }899 }
764 return {900 return {
765 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,901 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
902 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
766 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,903 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
767 };904 };
768 }),905 }),
...@@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
770 },907 },
771 {908 {
772 name: TERMINAL_UI_STATE_STORAGE_KEY,909 name: TERMINAL_UI_STATE_STORAGE_KEY,
773 version: 4,910 version: 5,
774 storage: createJSONStorage(createTerminalUiStateStorage),911 storage: createJSONStorage(createTerminalUiStateStorage),
775 migrate: migratePersistedTerminalUiStateStoreState,912 migrate: migratePersistedTerminalUiStateStoreState,
776 partialize: (state) => ({913 partialize: (state) => ({
777 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,914 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
915 terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey,
778 }),916 }),
779 },917 },
780 ),918 ),
packages/contracts/src/ipc.ts+4
...@@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> {...@@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> {
111 header?: boolean;111 header?: boolean;
112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */
113 icon?: string;113 icon?: string;
114 /** Inserts a visual section divider immediately before this item. */
115 separatorBefore?: boolean;
114 children?: readonly ContextMenuItem<T>[];116 children?: readonly ContextMenuItem<T>[];
115}117}
116118
...@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {...@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {
121 readonly disabled?: boolean;123 readonly disabled?: boolean;
122 readonly header?: boolean;124 readonly header?: boolean;
123 readonly icon?: string;125 readonly icon?: string;
126 readonly separatorBefore?: boolean;
124 readonly children?: readonly ContextMenuItemSchemaType[];127 readonly children?: readonly ContextMenuItemSchemaType[];
125}128}
126129
...@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc...@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
131 disabled: Schema.optionalKey(Schema.Boolean),134 disabled: Schema.optionalKey(Schema.Boolean),
132 header: Schema.optionalKey(Schema.Boolean),135 header: Schema.optionalKey(Schema.Boolean),
133 icon: Schema.optionalKey(Schema.String),136 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
134 children: Schema.optionalKey(138 children: Schema.optionalKey(
135 Schema.Array(139 Schema.Array(
136 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),140 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/shared/src/usageMerge.test.ts+1
...@@ -248,6 +248,7 @@ describe("mergeUsage", () => {...@@ -248,6 +248,7 @@ describe("mergeUsage", () => {
248 );248 );
249249
250 expect(merged.sessions).toBe(1);250 expect(merged.sessions).toBe(1);
251 expect(merged.providers[0]?.sessions).toBe(1);
251 });252 });
252253
253 it("returns empty totals with no environments", () => {254 it("returns empty totals with no environments", () => {
packages/shared/src/usageMerge.ts+29-11
...@@ -25,6 +25,7 @@ export interface ProviderTotals {...@@ -25,6 +25,7 @@ export interface ProviderTotals {
25 readonly costUsd: number;25 readonly costUsd: number;
26 readonly totalTokens: number;26 readonly totalTokens: number;
27 readonly records: number;27 readonly records: number;
28 readonly sessions: number;
28 readonly costShare: number;29 readonly costShare: number;
29 readonly tokenShare: number;30 readonly tokenShare: number;
30}31}
...@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): {...@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
135function ownedContribution(136function ownedContribution(
136 environment: EnvironmentUsage,137 environment: EnvironmentUsage,
137 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,138 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} {
139 const ownedProviders = new Set<UsageProviderKind>();143 const ownedProviders = new Set<UsageProviderKind>();
140 let sessions = 0;144 const sessionsByProvider = new Map<UsageProviderKind, number>();
141 for (const source of environment.summary.sources) {145 for (const source of environment.summary.sources) {
142 if (source.status === "missing") continue;146 if (source.status === "missing") continue;
143 const key = fingerprintKey(source.fingerprint);147 const key = fingerprintKey(source.fingerprint);
144 if (ownerByFingerprint.get(key) === environment.environmentId) {148 if (ownerByFingerprint.get(key) === environment.environmentId) {
145 ownedProviders.add(source.fingerprint.provider);149 const provider = source.fingerprint.provider;
150 ownedProviders.add(provider);
146 // Distinct within a directory. Summing per-bucket session counts instead151 // Distinct within a directory. Summing per-bucket session counts instead
147 // would count a session once per day and model it spans.152 // 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 );
149 }157 }
150 }158 }
151 return {159 return {
152 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),160 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
153 sessions,161 sessionsByProvider,
154 };162 };
155}163}
156164
...@@ -228,7 +236,7 @@ export function mergeUsage(...@@ -228,7 +236,7 @@ export function mergeUsage(
228236
229 const providerAccumulator = new Map<237 const providerAccumulator = new Map<
230 UsageProviderKind,238 UsageProviderKind,
231 { costUsd: number; totalTokens: number; records: number }239 { costUsd: number; totalTokens: number; records: number; sessions: number }
232 >();240 >();
233 const modelAccumulator = new Map<241 const modelAccumulator = new Map<
234 string,242 string,
...@@ -255,12 +263,20 @@ export function mergeUsage(...@@ -255,12 +263,20 @@ export function mergeUsage(
255 const contributingEnvironments: EnvironmentId[] = [];263 const contributingEnvironments: EnvironmentId[] = [];
256264
257 for (const environment of current) {265 for (const environment of current) {
258 const { buckets, sessions: environmentSessions } = ownedContribution(266 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
259 environment,
260 ownerByFingerprint,
261 );
262 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);267 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
265 for (const bucket of buckets) {281 for (const bucket of buckets) {
266 const tokens = bucketTokens(bucket);282 const tokens = bucketTokens(bucket);
...@@ -280,6 +296,7 @@ export function mergeUsage(...@@ -280,6 +296,7 @@ export function mergeUsage(
280 costUsd: 0,296 costUsd: 0,
281 totalTokens: 0,297 totalTokens: 0,
282 records: 0,298 records: 0,
299 sessions: 0,
283 };300 };
284 provider.costUsd += bucket.costUsd;301 provider.costUsd += bucket.costUsd;
285 provider.totalTokens += tokens;302 provider.totalTokens += tokens;
...@@ -341,6 +358,7 @@ export function mergeUsage(...@@ -341,6 +358,7 @@ export function mergeUsage(
341 costUsd: totals.costUsd,358 costUsd: totals.costUsd,
342 totalTokens: totals.totalTokens,359 totalTokens: totals.totalTokens,
343 records: totals.records,360 records: totals.records,
361 sessions: totals.sessions,
344 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,362 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
345 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,363 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
346 }))364 }))