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

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


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

apps/desktop/src/electron/ElectronMenu.test.ts+1-10
...@@ -98,10 +98,7 @@ describe("ElectronMenu", () => {...@@ -98,10 +98,7 @@ 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: [101 items: [{ id: "copy", label: "Copy" }],
102 { id: "copy", label: "Copy" },
103 { id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104 ],
105 position: Option.some({ x: 10.8, y: 20.2 }),102 position: Option.some({ x: 10.8, y: 20.2 }),
106 });103 });
107104
...@@ -113,12 +110,6 @@ describe("ElectronMenu", () => {...@@ -113,12 +110,6 @@ describe("ElectronMenu", () => {
113 enabled: true,110 enabled: true,
114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,111 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
115 });112 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
122 }).pipe(Effect.provide(TestLayer)),113 }).pipe(Effect.provide(TestLayer)),
123 );114 );
124115
apps/desktop/src/electron/ElectronMenu.ts+1-9
...@@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM...@@ -78,7 +78,6 @@ 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 } : {}),
82 };81 };
8382
84 if (sourceItem.children) {83 if (sourceItem.children) {
...@@ -142,17 +141,10 @@ export const make = Effect.gen(function* () {...@@ -142,17 +141,10 @@ export const make = Effect.gen(function* () {
142 ): Electron.MenuItemConstructorOptions[] => {141 ): Electron.MenuItemConstructorOptions[] => {
143 const template: Electron.MenuItemConstructorOptions[] = [];142 const template: Electron.MenuItemConstructorOptions[] = [];
144 let hasInsertedDestructiveSeparator = false;143 let hasInsertedDestructiveSeparator = false;
145 const appendSeparator = () => {
146 if (template.length === 0 || template.at(-1)?.type === "separator") return;
147 template.push({ type: "separator" });
148 };
149144
150 for (const item of entries) {145 for (const item of entries) {
151 if (item.separatorBefore) {
152 appendSeparator();
153 }
154 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {146 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
155 appendSeparator();147 template.push({ type: "separator" });
156 hasInsertedDestructiveSeparator = true;148 hasInsertedDestructiveSeparator = true;
157 }149 }
158150
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+1-40
...@@ -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", () => {23describe("projectActivityPayload agent-field survival", () => {
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,45 +44,6 @@ describe("projectActivityPayload", () => {...@@ -44,45 +44,6 @@ describe("projectActivityPayload", () => {
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
86 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {47 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
87 const projected = projectActivityPayload(48 const projected = projectActivityPayload(
88 activity({49 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+7-27
...@@ -104,24 +104,6 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno...@@ -104,24 +104,6 @@ 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
125function summarizeToolTextOutput(value: string): string | null {107function summarizeToolTextOutput(value: string): string | null {
126 const lines: string[] = [];108 const lines: string[] = [];
127 for (const rawLine of value.split(/\r?\n/u)) {109 for (const rawLine of value.split(/\r?\n/u)) {
...@@ -305,9 +287,8 @@ export function projectActivityPayload(...@@ -305,9 +287,8 @@ export function projectActivityPayload(
305 if (item) {287 if (item) {
306 projectedData.item = item;288 projectedData.item = item;
307 }289 }
308 const command = projectCommandValue(data);290 if ("command" in data) {
309 if (command !== undefined) {291 projectedData.command = data.command;
310 projectedData.command = command;
311 }292 }
312293
313 const changedFiles: string[] = [];294 const changedFiles: string[] = [];
...@@ -387,10 +368,10 @@ function dropStaleContextWindowActivities(...@@ -387,10 +368,10 @@ function dropStaleContextWindowActivities(
387/**368/**
388 * Identity both clients use to fold a tool lifecycle row into the call it369 * Identity both clients use to fold a tool lifecycle row into the call it
389 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and370 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
390 * mobile's `threadActivity`): the runtime item id ingestion stamps as371 * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter
391 * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple.372 * emits one, otherwise the itemType/title/detail triple. Returns null for rows
392 * Returns null for rows with no identity at all — those never collapse on the373 * with no identity at all — those never collapse on the client either, so they
393 * client either, so they must not be dropped here.374 * must not be dropped here.
394 */375 */
395function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {376function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
396 const payload = asRecord(activity.payload);377 const payload = asRecord(activity.payload);
...@@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |...@@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |
398 return null;379 return null;
399 }380 }
400381
401 const toolCallId =382 const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);
402 asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
403 if (toolCallId) {383 if (toolCallId) {
404 return `id:${toolCallId}`;384 return `id:${toolCallId}`;
405 }385 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-22
...@@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2811,16 +2811,11 @@ 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"),
2815 payload: {2814 payload: {
2816 itemType: "command_execution",2815 itemType: "command_execution",
2817 status: "inProgress",2816 status: "in_progress",
2818 title: "Command run",2817 title: "Read file",
2819 detail: "Bash: vp test run",2818 detail: "/tmp/file.ts",
2820 data: {
2821 toolName: "Bash",
2822 input: { command: "vp test run" },
2823 },
2824 },2819 },
2825 });2820 });
28262821
...@@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => {
2835 );2830 );
28362831
2837 expect(thread.session?.status).toBe("ready");2832 expect(thread.session?.status).toBe("ready");
2838 const activity = thread.activities.find(2833 expect(
2839 (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started",2834 thread.activities.some(
2840 );2835 (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2841 const payload = activity?.payload as Record<string, unknown> | undefined;2836 ),
2842 expect(payload).toMatchObject({2837 ).toBe(true);
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 });
2852 });2838 });
28532839
2854 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {2840 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts-6
...@@ -794,7 +794,6 @@ export function runtimeEventToActivities(...@@ -794,7 +794,6 @@ 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 } : {}),
798 ...(event.payload.status ? { status: event.payload.status } : {}),797 ...(event.payload.status ? { status: event.payload.status } : {}),
799 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),798 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
800 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),799 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
...@@ -822,8 +821,6 @@ export function runtimeEventToActivities(...@@ -822,8 +821,6 @@ export function runtimeEventToActivities(
822 summary: event.payload.title ?? "Tool",821 summary: event.payload.title ?? "Tool",
823 payload: {822 payload: {
824 itemType: event.payload.itemType,823 itemType: event.payload.itemType,
825 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
826 ...(event.payload.status ? { status: event.payload.status } : {}),
827 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),824 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
828 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),825 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
829 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),826 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
...@@ -850,10 +847,7 @@ export function runtimeEventToActivities(...@@ -850,10 +847,7 @@ export function runtimeEventToActivities(
850 summary: `${event.payload.title ?? "Tool"} started`,847 summary: `${event.payload.title ?? "Tool"} started`,
851 payload: {848 payload: {
852 itemType: event.payload.itemType,849 itemType: event.payload.itemType,
853 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
854 ...(event.payload.status ? { status: event.payload.status } : {}),
855 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),850 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
856 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
857 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),851 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
858 ...(event.payload.parentToolUseId852 ...(event.payload.parentToolUseId
859 ? { parentToolUseId: event.payload.parentToolUseId }853 ? { parentToolUseId: event.payload.parentToolUseId }
apps/web/src/components/ChatView.tsx+26-32
...@@ -167,6 +167,7 @@ import {...@@ -167,6 +167,7 @@ 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";
170import { stackedThreadToast, toastManager } from "./ui/toast";171import { stackedThreadToast, toastManager } from "./ui/toast";
171import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";172import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
172import { type NewProjectScriptInput } from "./ProjectScriptsControl";173import { type NewProjectScriptInput } from "./ProjectScriptsControl";
...@@ -219,11 +220,7 @@ import {...@@ -219,11 +220,7 @@ import {
219import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";220import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
220import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";221import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
221import { environmentCatalog } from "../connection/catalog";222import { environmentCatalog } from "../connection/catalog";
222import {223import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
223 selectThreadTerminalCustomLabels,
224 selectThreadTerminalUiState,
225 useTerminalUiStateStore,
226} from "../terminalUiStateStore";
227import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";224import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
228import { projectEnvironment } from "../state/projects";225import { projectEnvironment } from "../state/projects";
229import { useEnvironmentQuery } from "../state/query";226import { useEnvironmentQuery } from "../state/query";
...@@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader";...@@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader";
259import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";256import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
260import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";257import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
261import { NoActiveThreadState } from "./NoActiveThreadState";258import { NoActiveThreadState } from "./NoActiveThreadState";
262import { WorkspacePageHeader } from "./WorkspacePageContainer";
263import {259import {
264 resolveEffectiveEnvMode,260 resolveEffectiveEnvMode,
265 resolveLocalCheckoutBranchMismatch,261 resolveLocalCheckoutBranchMismatch,
...@@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps {...@@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps {
657 newShortcutLabel: string | undefined;653 newShortcutLabel: string | undefined;
658 closeShortcutLabel: string | undefined;654 closeShortcutLabel: string | undefined;
659 keybindings: ResolvedKeybindingsConfig;655 keybindings: ResolvedKeybindingsConfig;
660 onHide: () => void;
661 onAddTerminalContext: (selection: TerminalContextSelection) => void;656 onAddTerminalContext: (selection: TerminalContextSelection) => void;
662}657}
663658
...@@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
672 newShortcutLabel,667 newShortcutLabel,
673 closeShortcutLabel,668 closeShortcutLabel,
674 keybindings,669 keybindings,
675 onHide,
676 onAddTerminalContext,670 onAddTerminalContext,
677}: PersistentThreadTerminalDrawerProps) {671}: PersistentThreadTerminalDrawerProps) {
678 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");672 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
...@@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
996 onSplitTerminal={splitTerminal}990 onSplitTerminal={splitTerminal}
997 onSplitTerminalVertical={splitTerminalVertical}991 onSplitTerminalVertical={splitTerminalVertical}
998 onNewTerminal={createNewTerminal}992 onNewTerminal={createNewTerminal}
999 onHide={onHide}
1000 splitShortcutLabel={visible ? splitShortcutLabel : undefined}993 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1001 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}994 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1002 newShortcutLabel={visible ? newShortcutLabel : undefined}995 newShortcutLabel={visible ? newShortcutLabel : undefined}
...@@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) {
1547 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1540 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1548 const activeThreadId = activeThread?.id ?? null;1541 const activeThreadId = activeThread?.id ?? null;
1549 const activeThreadEnvironmentId = activeThread?.environmentId ?? null;1542 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 );
1560 const runningTerminalIds = useThreadRunningTerminalIds({1543 const runningTerminalIds = useThreadRunningTerminalIds({
1561 environmentId: activeThread?.environmentId ?? null,1544 environmentId: activeThread?.environmentId ?? null,
1562 threadId: activeThreadId,1545 threadId: activeThreadId,
...@@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) {
1586 for (const session of activeThreadKnownSessions) {1569 for (const session of activeThreadKnownSessions) {
1587 labels.set(1570 labels.set(
1588 session.target.terminalId,1571 session.target.terminalId,
1589 activeTerminalCustomLabels[session.target.terminalId] ??1572 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1590 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1591 );1573 );
1592 }1574 }
1593 for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) {
1594 if (!labels.has(terminalId)) labels.set(terminalId, label);
1595 }
1596 return labels;1575 return labels;
1597 }, [activeTerminalCustomLabels, activeThreadKnownSessions]);1576 }, [activeThreadKnownSessions]);
1577 const activeThreadRef = useMemo(
1578 () =>
1579 activeThreadEnvironmentId && activeThreadId
1580 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1581 : null,
1582 [activeThreadEnvironmentId, activeThreadId],
1583 );
1598 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1584 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1599 const [timelineAnchor, setTimelineAnchor] = useState<{1585 const [timelineAnchor, setTimelineAnchor] = useState<{
1600 readonly threadKey: string | null;1586 readonly threadKey: string | null;
...@@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) {
2822 },2808 },
2823 [activeThreadRef, storeSetTerminalOpen],2809 [activeThreadRef, storeSetTerminalOpen],
2824 );2810 );
2825 const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]);
2826 const toggleTerminalVisibility = useCallback(() => {2811 const toggleTerminalVisibility = useCallback(() => {
2827 if (!activeThreadRef) return;2812 if (!activeThreadRef) return;
2828 const nextOpen = !terminalUiState.terminalOpen;2813 const nextOpen = !terminalUiState.terminalOpen;
...@@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) {
6129 ? "thread"6114 ? "thread"
6130 : "page"6115 : "page"
6131 }6116 }
6117 chromeVariant="collapse"
6132 composerDraftTarget={composerDraftTarget}6118 composerDraftTarget={composerDraftTarget}
6133 onStateChange={handlePullRequestTabStatusChange}6119 onStateChange={handlePullRequestTabStatusChange}
6134 />6120 />
...@@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) {
6174 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}6160 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
6175 >6161 >
6176 {/* Top bar */}6162 {/* Top bar */}
6177 <WorkspacePageHeader6163 <header
6178 data-chat-header6164 data-chat-header
6179 electron={isElectron}6165 className={cn(
6180 reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}6166 "bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",
6181 className="relative bg-background"6167 isElectron
6168 ? cn(
6169 "drag-region relative flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5",
6170 reserveTitleBarControlInset &&
6171 !inlineRightPanelOwnsTitleBar &&
6172 "wco:pr-[var(--workspace-native-controls-inset)]",
6173 )
6174 : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
6175 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
6176 )}
6182 >6177 >
6183 {!rightPanelOpen ? panelLayoutControls : null}6178 {!rightPanelOpen ? panelLayoutControls : null}
6184 <ChatHeader6179 <ChatHeader
...@@ -6209,7 +6204,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6209,7 +6204,7 @@ function ChatViewContent(props: ChatViewProps) {
6209 onUpdateProjectScript={updateProjectScript}6204 onUpdateProjectScript={updateProjectScript}
6210 onDeleteProjectScript={deleteProjectScript}6205 onDeleteProjectScript={deleteProjectScript}
6211 />6206 />
6212 </WorkspacePageHeader>6207 </header>
62136208
6214 <ThreadErrorBanner6209 <ThreadErrorBanner
6215 error={visibleThreadError}6210 error={visibleThreadError}
...@@ -6547,7 +6542,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6547,7 +6542,6 @@ function ChatViewContent(props: ChatViewProps) {
6547 newShortcutLabel={newTerminalShortcutLabel ?? undefined}6542 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
6548 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}6543 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
6549 keybindings={keybindings}6544 keybindings={keybindings}
6550 onHide={hideTerminal}
6551 onAddTerminalContext={addTerminalContextToDraft}6545 onAddTerminalContext={addTerminalContextToDraft}
6552 />6546 />
6553 ))}6547 ))}
apps/web/src/components/NoActiveThreadState.tsx+15-4
...@@ -1,15 +1,26 @@...@@ -1,15 +1,26 @@
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 { WorkspacePageHeader } from "./WorkspacePageContainer";4import { cn } from "~/lib/utils";
5import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
56
6export function NoActiveThreadState() {7export function NoActiveThreadState() {
7 return (8 return (
8 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">9 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
9 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">10 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
10 <WorkspacePageHeader electron={isElectron} className="border-b border-border">11 <header
12 className={cn(
13 "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
14 isElectron
15 ? "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center"
16 : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center",
17 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
18 )}
19 >
11 {isElectron ? (20 {isElectron ? (
12 <span className="text-xs text-muted-foreground/50">No active thread</span>21 <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]">
22 No active thread
23 </span>
13 ) : (24 ) : (
14 <div className="flex items-center gap-2">25 <div className="flex items-center gap-2">
15 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">26 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
...@@ -17,7 +28,7 @@ export function NoActiveThreadState() {...@@ -17,7 +28,7 @@ export function NoActiveThreadState() {
17 </span>28 </span>
18 </div>29 </div>
19 )}30 )}
20 </WorkspacePageHeader>31 </header>
2132
22 <Empty className="flex-1">33 <Empty className="flex-1">
23 <div className="w-full max-w-lg px-8 py-12">34 <div className="w-full max-w-lg px-8 py-12">
apps/web/src/components/Sidebar.logic.ts+1-2
...@@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {...@@ -299,9 +299,8 @@ 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,
303): boolean {302): boolean {
304 return hasProjectScope || shiftKey || projectGroupCount <= 1;303 return shiftKey || projectGroupCount <= 1;
305}304}
306305
307export function orderItemsByPreferredIds<TItem, TId>(input: {306export function orderItemsByPreferredIds<TItem, TId>(input: {
apps/web/src/components/Sidebar.tsx+157-159
...@@ -184,7 +184,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25;...@@ -184,7 +184,6 @@ 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";
188187
189function compactSidebarTimeLabel(label: string): string {188function compactSidebarTimeLabel(label: string): string {
190 if (label === "just now") return "now";189 if (label === "just now") return "now";
...@@ -367,20 +366,26 @@ function SnoozePopoverButton(props: {...@@ -367,20 +366,26 @@ function SnoozePopoverButton(props: {
367 );366 );
368 return (367 return (
369 <Popover open={open} onOpenChange={onOpenChange}>368 <Popover open={open} onOpenChange={onOpenChange}>
370 <PopoverTrigger369 <Tooltip>
371 render={370 <TooltipTrigger
372 <Button371 render={
373 size="icon-micro"372 <PopoverTrigger
374 variant="ghost-muted"373 render={
375 aria-label="Snooze thread"374 <button
376 title="Snooze thread"375 type="button"
377 onClick={(event) => event.stopPropagation()}376 aria-label="Snooze thread"
378 onDoubleClick={(event) => event.stopPropagation()}377 onClick={(event) => event.stopPropagation()}
379 />378 onDoubleClick={(event) => event.stopPropagation()}
380 }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"
381 >380 />
382 <ClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />381 }
383 </PopoverTrigger>382 />
383 }
384 >
385 <ClockIcon className="size-3" />
386 </TooltipTrigger>
387 <TooltipPopup>Snooze thread</TooltipPopup>
388 </Tooltip>
384 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">389 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
385 {presets.map((preset) => (390 {presets.map((preset) => (
386 <button391 <button
...@@ -1194,9 +1199,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1194,9 +1199,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1194 aria-label="Dismiss Woke notification"1199 aria-label="Dismiss Woke notification"
1195 title="Dismiss Woke notification"1200 title="Dismiss Woke notification"
1196 onClick={handleAcknowledgeWokeClick}1201 onClick={handleAcknowledgeWokeClick}
1197 className="inline-flex h-6 cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"1202 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
1198 >1203 >
1199 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1204 <AlarmClockIcon aria-hidden className="size-3" />
1200 <span role="status">Woke</span>1205 <span role="status">Woke</span>
1201 </button>1206 </button>
1202 ) : (1207 ) : (
...@@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1218 isWoke && "group-hover/sidebar-row:static",1223 isWoke && "group-hover/sidebar-row:static",
1219 )}1224 )}
1220 >1225 >
1221 <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1226 <AlarmClockOffIcon className="mb-px size-3" />
1222 </button>1227 </button>
1223 )1228 )
1224 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (1229 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
...@@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1231 isWoke && "group-hover/sidebar-row:static",1236 isWoke && "group-hover/sidebar-row:static",
1232 )}1237 )}
1233 >1238 >
1234 <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1239 <Undo2Icon className="mb-px size-3.5" />
1235 </button>1240 </button>
1236 ) : (1241 ) : (
1237 <button1242 <button
...@@ -1243,7 +1248,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1243,7 +1248,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1243 isWoke && "group-hover/sidebar-row:static",1248 isWoke && "group-hover/sidebar-row:static",
1244 )}1249 )}
1245 >1250 >
1246 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1251 <CheckIcon className="size-3" />
1247 </button>1252 </button>
1248 )}1253 )}
1249 </span>1254 </span>
...@@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1312 ) : (1317 ) : (
1313 <span className="flex-1" />1318 <span className="flex-1" />
1314 )}1319 )}
1315 <span className="ml-auto flex h-5 shrink-0 items-center">1320 {props.isPinned ? (
1316 {props.isPinned ? (1321 props.pinningSupported ? (
1317 props.pinningSupported ? (1322 <Tooltip>
1318 <Button1323 <TooltipTrigger
1319 size="icon-micro"1324 render={
1320 variant="ghost-muted"1325 <button
1321 aria-label="Unpin thread"1326 type="button"
1322 title="Unpin thread"1327 aria-label="Unpin thread"
1323 onClick={handleUnpinClick}1328 onClick={handleUnpinClick}
1329 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1330 />
1331 }
1324 >1332 >
1325 <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1333 <PinIcon aria-hidden className="size-3 shrink-0" />
1326 </Button>1334 </TooltipTrigger>
1327 ) : (1335 <TooltipPopup>Unpin thread</TooltipPopup>
1328 <span className="inline-flex size-5 shrink-0 items-center justify-center">1336 </Tooltip>
1329 <PinIcon1337 ) : (
1330 aria-label="Pinned"1338 <PinIcon
1331 role="img"1339 aria-label="Pinned"
1332 className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")}1340 role="img"
1333 />1341 className="size-3 shrink-0 text-muted-foreground/65"
1334 </span>1342 />
1335 )1343 )
1336 ) : null}1344 ) : null}
1337 {/* Only the visible state owns this slot's width: the pin stays1345 {/* The visible state owns this slot's width: status at rest,
1338 directly beside the idle status and beside the first action1346 actions on hover/keyboard focus or while the popover is open. Keeping
1339 when the hover controls replace it. */}1347 the hidden state out of flow lets the project label reclaim
1348 space without either state overlapping it. */}
1349 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1350 {/* Read-only status labels yield to the hover actions. Woke is
1351 itself an action, so it stays pointer-enabled and visible
1352 while the other controls appear beside it. */}
1340 <span1353 <span
1341 className={cn(1354 className={cn(
1342 "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs",1355 isWokeStatus
1343 props.isPinned ? "min-w-0" : "min-w-8",1356 ? "pointer-events-auto"
1357 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
1358 "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1359 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1344 )}1360 )}
1345 >1361 >
1346 {/* Read-only status labels yield to the hover actions. Woke is1362 {topStatus ? (
1347 itself an action, so it stays pointer-enabled and visible1363 isWokeStatus ? (
1348 while the other controls appear beside it. */}1364 <button
1365 type="button"
1366 aria-label="Dismiss Woke notification"
1367 title="Dismiss Woke notification"
1368 onClick={handleAcknowledgeWokeClick}
1369 className={cn(
1370 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1371 topStatus.className,
1372 )}
1373 >
1374 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1375 <span role="status">{topStatus.label}</span>
1376 </button>
1377 ) : (
1378 <span
1379 className={cn(
1380 "inline-flex items-center gap-1 font-medium",
1381 topStatus.className,
1382 )}
1383 >
1384 {topStatus.icon === "working" ? (
1385 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1386 ) : topStatus.icon === "done" ? (
1387 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1388 ) : null}
1389 {/* The label alone is the live region: a role="status"
1390 wrapper around the ticking duration would make
1391 screen readers announce every second. */}
1392 <span role="status">{topStatus.label}</span>
1393 {status === "working" ? (
1394 <span aria-hidden>
1395 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1396 </span>
1397 ) : null}
1398 </span>
1399 )
1400 ) : (
1401 threadTimeLabel(thread)
1402 )}
1403 </span>
1404 {props.settlementSupported || showSnoozeButton ? (
1349 <span1405 <span
1350 className={cn(1406 className={cn(
1351 isWokeStatus1407 // focus-visible, not focus-within: a mouse click leaves
1352 ? "pointer-events-auto"1408 // the Settle button focused, and a plain focus-within
1353 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",1409 // would keep the controls pinned over the status label
1354 "self-center tabular-nums text-secondary-label transition-opacity",1410 // once the pointer moves away (e.g. after a failed
1355 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",1411 // settle) instead of cross-fading back.
1412 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
1413 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1356 )}1414 )}
1357 >1415 >
1358 {topStatus ? (1416 {showSnoozeButton ? (
1359 isWokeStatus ? (1417 <SnoozePopoverButton
1360 <button1418 open={snoozeMenuOpen}
1361 type="button"1419 onOpenChange={setSnoozeMenuOpen}
1362 aria-label="Dismiss Woke notification"1420 onSnooze={handleSnoozePreset}
1363 title="Dismiss Woke notification"1421 timestampFormat={props.timestampFormat}
1364 onClick={handleAcknowledgeWokeClick}1422 />
1365 className={cn(1423 ) : null}
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",1424 {props.settlementSupported ? (
1367 topStatus.className,1425 <Tooltip>
1368 )}1426 <TooltipTrigger
1369 >1427 render={
1370 <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1428 <button
1371 <span role="status">{topStatus.label}</span>1429 type="button"
1372 </button>1430 aria-label="Settle thread"
1373 ) : (1431 onClick={handleSettleClick}
1374 <span1432 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"
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}
1384 />1433 />
1385 ) : topStatus.icon === "done" ? (1434 }
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"
1429 >1435 >
1430 <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} />1436 <CheckIcon className="size-3.5" />
1431 Settle1437 Settle
1432 </button>1438 </TooltipTrigger>
1433 ) : null}1439 <TooltipPopup>Settle thread</TooltipPopup>
1434 </span>1440 </Tooltip>
1435 ) : null}1441 ) : null}
1436 </span>1442 </span>
1443 ) : null}
1437 </span>1444 </span>
1438 </div>1445 </div>
1439 <div className="mt-1 flex min-w-0">1446 <div className="mt-1 flex min-w-0">
...@@ -3211,25 +3218,17 @@ export default function Sidebar() {...@@ -3211,25 +3218,17 @@ export default function Sidebar() {
3211 autoAnimate(node, { duration: 150, easing: "ease-out" });3218 autoAnimate(node, { duration: 150, easing: "ease-out" });
3212 }, []);3219 }, []);
32133220
3214 // A selected project scope owns creation: users should not have to choose3221 // New thread defaults to the project you're in (active thread's project,
3215 // the same project twice. "All projects" keeps the picker in multi-project3222 // falling back to the top project) — same resolution the command palette
3216 // setups, while Shift+click retains the direct-create shortcut.3223 // uses. The command palette already offers a "New thread in..." submenu
3224 // for multi-project setups.
3217 const handleNewThreadClick = useCallback(3225 const handleNewThreadClick = useCallback(
3218 (event?: ReactMouseEvent) => {3226 (event?: ReactMouseEvent) => {
3219 if (3227 // One project: nothing to pick, create immediately. Shift+click creates
3220 shouldCreateNewThreadInCurrentProject(3228 // directly in the current project even with several projects, skipping
3221 event?.shiftKey ?? false,3229 // the palette picker.
3222 projectGroups.length,3230 if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {
3223 scopedProjectGroup !== null,
3224 )
3225 ) {
3226 if (isMobile) setOpenMobile(false);3231 if (isMobile) setOpenMobile(false);
3227 if (scopedProjectGroup) {
3228 void newThreadContext.handleNewThread(
3229 scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id),
3230 );
3231 return;
3232 }
3233 void startNewThreadFromContext({3232 void startNewThreadFromContext({
3234 activeDraftThread: newThreadContext.activeDraftThread,3233 activeDraftThread: newThreadContext.activeDraftThread,
3235 activeThread: newThreadContext.activeThread ?? undefined,3234 activeThread: newThreadContext.activeThread ?? undefined,
...@@ -3241,19 +3240,20 @@ export default function Sidebar() {...@@ -3241,19 +3240,20 @@ export default function Sidebar() {
3241 if (isMobile) setOpenMobile(false);3240 if (isMobile) setOpenMobile(false);
3242 openCommandPalette({ open: "new-thread-in" });3241 openCommandPalette({ open: "new-thread-in" });
3243 },3242 },
3244 [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile],3243 [isMobile, newThreadContext, projectGroups.length, setOpenMobile],
3245 );3244 );
32463245
3247 // With no explicit scope the button mirrors chat.new. A scoped button has3246 // The button mirrors chat.new: in multi-project setups both route through
3248 // intentionally more specific behavior, so it does not advertise the3247 // the command palette's "New thread in..." picker, and in single-project
3249 // broader command's shortcut.3248 // setups both create immediately. In multi-project setups the label is only
3249 // the picker's shortcut: falling back to chat.newLocal would advertise the
3250 // same shortcut for both the picker and direct create. In single-project
3251 // setups both commands create directly, so chat.newLocal is a valid
3252 // fallback. The second tooltip line (multi-project only) advertises
3253 // shift+click and its keyboard twin chat.newLocal for direct create.
3250 const newThreadShortcutLabel =3254 const newThreadShortcutLabel =
3251 scopedProjectGroup === null3255 shortcutLabelForCommand(keybindings, "chat.new") ??
3252 ? (shortcutLabelForCommand(keybindings, "chat.new") ??3256 (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
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,9 +3332,7 @@ export default function Sidebar() {...@@ -3332,9 +3332,7 @@ export default function Sidebar() {
3332 />3332 />
3333 </TooltipTrigger>3333 </TooltipTrigger>
3334 <TooltipPopup side="right">3334 <TooltipPopup side="right">
3335 {scopedProjectGroup ? (3335 {projectGroups.length > 1 ? (
3336 `New thread in ${scopedProjectGroup.displayName}`
3337 ) : projectGroups.length > 1 ? (
3338 <span className="flex flex-col gap-0.5">3336 <span className="flex flex-col gap-0.5">
3339 <span>3337 <span>
3340 {newThreadShortcutLabel3338 {newThreadShortcutLabel
apps/web/src/components/ThreadTerminalDrawer.tsx+308-356
...@@ -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,
9 Plus,8 Plus,
10 SquareSplitHorizontal,9 SquareSplitHorizontal,
11 SquareSplitVertical,10 SquareSplitVertical,
12 TerminalSquare,11 TerminalSquare,
12 Trash2,
13 XIcon,13 XIcon,
14} from "lucide-react";14} from "lucide-react";
15import {15import {
...@@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";...@@ -21,6 +21,7 @@ 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,
24 type SetStateAction,25 type SetStateAction,
25 useCallback,26 useCallback,
26 useEffect,27 useEffect,
...@@ -29,9 +30,9 @@ import {...@@ -29,9 +30,9 @@ import {
29 useRef,30 useRef,
30 useState,31 useState,
31} from "react";32} from "react";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
32import { Button } from "~/components/ui/button";34import { Button } from "~/components/ui/button";
33import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";35import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
34import { useResizableWidth } from "~/hooks/useResizableWidth";
35import { cn } from "~/lib/utils";36import { cn } from "~/lib/utils";
36import { type TerminalContextSelection } from "~/lib/terminalContext";37import { type TerminalContextSelection } from "~/lib/terminalContext";
37import {38import {
...@@ -59,7 +60,6 @@ import {...@@ -59,7 +60,6 @@ import {
59import { readLocalApi } from "~/localApi";60import { readLocalApi } from "~/localApi";
60import { useClientSettings } from "../hooks/useSettings";61import { useClientSettings } from "../hooks/useSettings";
61import { useLocalStorage } from "../hooks/useLocalStorage";62import { 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,15 +72,10 @@ import {...@@ -72,15 +72,10 @@ 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";
7675
77const MIN_DRAWER_HEIGHT = 180;76const MIN_DRAWER_HEIGHT = 180;
78const MAX_DRAWER_HEIGHT_RATIO = 0.75;77const MAX_DRAWER_HEIGHT_RATIO = 0.75;
79const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;78const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
80const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144;
81const TERMINAL_SIDEBAR_MIN_WIDTH = 144;
82const TERMINAL_SIDEBAR_MAX_WIDTH = 320;
83const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width";
8479
85function maxDrawerHeight(): number {80function maxDrawerHeight(): number {
86 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;81 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
...@@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp(...@@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp(
249 return selectionGestureActive && button === 0;244 return selectionGestureActive && button === 0;
250}245}
251246
252export function shouldShowTerminalSidebar(terminalCount: number): boolean {
253 return terminalCount > 1;
254}
255
256export function terminalSelectionLineRange(position: {247export function terminalSelectionLineRange(position: {
257 start: { y: number };248 start: { y: number };
258 end: { y: number };249 end: { y: number };
...@@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps {...@@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps {
885 onSplitTerminal: () => void;876 onSplitTerminal: () => void;
886 onSplitTerminalVertical: () => void;877 onSplitTerminalVertical: () => void;
887 onNewTerminal: () => void;878 onNewTerminal: () => void;
888 onHide?: () => void;
889 splitShortcutLabel?: string | undefined;879 splitShortcutLabel?: string | undefined;
890 splitVerticalShortcutLabel?: string | undefined;880 splitVerticalShortcutLabel?: string | undefined;
891 newShortcutLabel?: string | undefined;881 newShortcutLabel?: string | undefined;
...@@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps {...@@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps {
901 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;891 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
902}892}
903893
894interface TerminalActionButtonProps {
895 label: string;
896 className: string;
897 onClick: () => void;
898 children: ReactNode;
899}
900
901function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
902 return (
903 <Popover>
904 <PopoverTrigger
905 openOnHover
906 render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
907 >
908 {children}
909 </PopoverTrigger>
910 <PopoverPopup
911 tooltipStyle
912 side="bottom"
913 sideOffset={6}
914 align="center"
915 className="pointer-events-none select-none"
916 >
917 {label}
918 </PopoverPopup>
919 </Popover>
920 );
921}
922
904export default function ThreadTerminalDrawer({923export default function ThreadTerminalDrawer({
905 mode = "drawer",924 mode = "drawer",
906 threadRef,925 threadRef,
...@@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({...@@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({
918 onSplitTerminal,937 onSplitTerminal,
919 onSplitTerminalVertical,938 onSplitTerminalVertical,
920 onNewTerminal,939 onNewTerminal,
921 onHide,
922 splitShortcutLabel,940 splitShortcutLabel,
923 splitVerticalShortcutLabel,941 splitVerticalShortcutLabel,
924 newShortcutLabel,942 newShortcutLabel,
...@@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({...@@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({
932 terminalLaunchLocationsById,950 terminalLaunchLocationsById,
933}: ThreadTerminalDrawerProps) {951}: ThreadTerminalDrawerProps) {
934 const isPanel = mode === "panel";952 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);
950 const [advancedTypography] = useLocalStorage(953 const [advancedTypography] = useLocalStorage(
951 TYPOGRAPHY_ADVANCED_STORAGE_KEY,954 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
952 false,955 false,
...@@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({...@@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({
1095 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);1098 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
1096 const splitDirection =1099 const splitDirection =
1097 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";1100 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
1098 const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length);1101 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
1099 const isSplitView = visibleTerminalIds.length > 1;1102 const isSplitView = visibleTerminalIds.length > 1;
1103 const showGroupHeaders =
1104 resolvedTerminalGroups.length > 1 ||
1105 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
1100 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;1106 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
1101 const automaticTerminalLabelById = useMemo(() => {1107 const terminalLabelById = useMemo(() => {
1102 const next = new Map<string, string>();1108 const next = new Map<string, string>();
1103 for (const terminalId of normalizedTerminalIds) {1109 for (const terminalId of normalizedTerminalIds) {
1104 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));1110 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
1105 }1111 }
1106 return next;1112 return next;
1107 }, [normalizedTerminalIds, terminalLabelsById]);1113 }, [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]);
1120 const resolveTerminalLaunchLocation = useCallback(1114 const resolveTerminalLaunchLocation = useCallback(
1121 (terminalId: string): TerminalLaunchLocation => {1115 (terminalId: string): TerminalLaunchLocation => {
1122 return (1116 return (
...@@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({...@@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({
1129 },1123 },
1130 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],1124 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
1131 );1125 );
1132 const newTerminalActionLabel = newShortcutLabel
1133 ? `New Terminal (${newShortcutLabel})`
1134 : "New Terminal";
1135 const splitTerminalActionLabel = hasReachedSplitLimit1126 const splitTerminalActionLabel = hasReachedSplitLimit
1136 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`1127 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
1137 : splitShortcutLabel1128 : splitShortcutLabel
...@@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({...@@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({
1142 : splitVerticalShortcutLabel1133 : splitVerticalShortcutLabel
1143 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`1134 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
1144 : "Split Terminal Vertically";1135 : "Split Terminal Vertically";
1136 const newTerminalActionLabel = newShortcutLabel
1137 ? `New Terminal (${newShortcutLabel})`
1138 : "New Terminal";
1145 const closeTerminalActionLabel = closeShortcutLabel1139 const closeTerminalActionLabel = closeShortcutLabel
1146 ? `Close Terminal (${closeShortcutLabel})`1140 ? `Close Terminal (${closeShortcutLabel})`
1147 : "Close Terminal";1141 : "Close Terminal";
...@@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({...@@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({
1153 if (hasReachedSplitLimit) return;1147 if (hasReachedSplitLimit) return;
1154 onSplitTerminalVertical();1148 onSplitTerminalVertical();
1155 }, [hasReachedSplitLimit, onSplitTerminalVertical]);1149 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1156 const startTerminalRename = useCallback(1150 const onNewTerminalAction = useCallback(() => {
1157 (terminalId: string) => {1151 onNewTerminal();
1158 cancelTerminalRenameRef.current = false;1152 }, [onNewTerminal]);
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]);
11931153
1194 useEffect(() => {1154 useEffect(() => {
1195 onHeightChangeRef.current = onHeightChange;1155 onHeightChangeRef.current = onHeightChange;
...@@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({...@@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({
1314 ) : null}1274 ) : null}
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">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">
1316 <p>No terminal sessions for this thread yet.</p>1276 <p>No terminal sessions for this thread yet.</p>
1317 <Button size="xs" variant="outline" onClick={onNewTerminal}>1277 <Button size="xs" variant="outline" onClick={onNewTerminalAction}>
1318 {newTerminalActionLabel}1278 {newTerminalActionLabel}
1319 </Button>1279 </Button>
1320 </div>1280 </div>
...@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({...@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({
1323 }1283 }
13241284
1325 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);1285 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1326 const compactTerminalToolbar = (1286
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 );
1392 return (1287 return (
1393 <aside1288 <aside
1394 data-terminal-owner={isPanel ? "right-panel" : "drawer"}1289 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
...@@ -1408,220 +1303,277 @@ export default function ThreadTerminalDrawer({...@@ -1408,220 +1303,277 @@ export default function ThreadTerminalDrawer({
1408 />1303 />
1409 ) : null}1304 ) : null}
14101305
1411 {!hasTerminalSidebar ? (1306 {!hasTerminalSidebar && (
1412 <div className="pointer-events-none absolute top-2 right-2 z-20">1307 <div className="pointer-events-none absolute right-2 top-2 z-20">
1413 <div className="pointer-events-auto flex h-8 items-center rounded-lg border border-border/70 bg-background p-0.5 shadow-xs">1308 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
1414 {compactTerminalToolbar}1309 <TerminalActionButton
1310 className={`p-1 text-foreground/90 transition-colors ${
1311 hasReachedSplitLimit
1312 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1313 : "hover:bg-accent"
1314 }`}
1315 onClick={onSplitTerminalAction}
1316 label={splitTerminalActionLabel}
1317 >
1318 <SquareSplitHorizontal className="size-3.25" />
1319 </TerminalActionButton>
1320 <div className="h-4 w-px bg-border/80" />
1321 <TerminalActionButton
1322 className={`p-1 text-foreground/90 transition-colors ${
1323 hasReachedSplitLimit
1324 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1325 : "hover:bg-accent"
1326 }`}
1327 onClick={onSplitTerminalVerticalAction}
1328 label={splitTerminalVerticalActionLabel}
1329 >
1330 <SquareSplitVertical className="size-3.25" />
1331 </TerminalActionButton>
1332 <div className="h-4 w-px bg-border/80" />
1333 <TerminalActionButton
1334 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1335 onClick={onNewTerminalAction}
1336 label={newTerminalActionLabel}
1337 >
1338 <Plus className="size-3.25" />
1339 </TerminalActionButton>
1340 <div className="h-4 w-px bg-border/80" />
1341 <TerminalActionButton
1342 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1343 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1344 label={closeTerminalActionLabel}
1345 >
1346 <Trash2 className="size-3.25" />
1347 </TerminalActionButton>
1415 </div>1348 </div>
1416 </div>1349 </div>
1417 ) : null}1350 )}
14181351
1419 <div className="flex min-h-0 w-full flex-1">1352 <div className="min-h-0 w-full flex-1">
1420 <div className="min-w-0 flex-1">1353 <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}>
1421 {isSplitView ? (1354 <div className="min-w-0 flex-1">
1422 <div1355 {isSplitView ? (
1423 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"1356 <div
1424 style={1357 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1425 splitDirection === "vertical"1358 style={
1426 ? {1359 splitDirection === "vertical"
1427 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1360 ? {
1428 }1361 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1429 : {1362 }
1430 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1363 : {
1431 }1364 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1432 }1365 }
1433 >1366 }
1434 {visibleTerminalIds.map((terminalId) => {1367 >
1435 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);1368 {visibleTerminalIds.map((terminalId) => {
1436 const isActive = terminalId === resolvedActiveTerminalId;1369 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1437 return (1370 return (
1438 <div1371 <div
1439 key={terminalId}1372 key={terminalId}
1440 className={cn(1373 className={`min-h-0 min-w-0 ${
1441 "min-h-0 min-w-0",1374 splitDirection === "vertical"
1442 splitDirection === "vertical"1375 ? "border-t first:border-t-0"
1443 ? "border-t first:border-t-0"1376 : "border-l first:border-l-0"
1444 : "border-l first:border-l-0",1377 } ${
1445 isActive ? "border-border" : "border-border/70",1378 terminalId === resolvedActiveTerminalId
1446 )}1379 ? "border-border"
1447 onMouseDown={() => {1380 : "border-border/70"
1448 if (!isActive) onActiveTerminalChange(terminalId);1381 }`}
1449 }}1382 onMouseDown={() => {
1450 >1383 if (terminalId !== resolvedActiveTerminalId) {
1451 <div className="h-full p-1">1384 onActiveTerminalChange(terminalId);
1452 <TerminalViewport1385 }
1453 advancedTypography={advancedTypography}1386 }}
1454 threadRef={threadRef}1387 >
1455 threadId={threadId}1388 <div className="h-full p-1">
1456 terminalId={terminalId}1389 <TerminalViewport
1457 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1390 advancedTypography={advancedTypography}
1458 cwd={terminalLaunchLocation.cwd}1391 threadRef={threadRef}
1459 {...(terminalLaunchLocation.worktreePath !== undefined1392 threadId={threadId}
1460 ? { worktreePath: terminalLaunchLocation.worktreePath }1393 terminalId={terminalId}
1461 : {})}1394 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1462 {...(terminalLaunchLocation.runtimeEnv1395 cwd={terminalLaunchLocation.cwd}
1463 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }1396 {...(terminalLaunchLocation.worktreePath !== undefined
1464 : {})}1397 ? { worktreePath: terminalLaunchLocation.worktreePath }
1465 onSessionExited={() => onCloseTerminal(terminalId)}1398 : {})}
1466 onAddTerminalContext={onAddTerminalContext}1399 {...(terminalLaunchLocation.runtimeEnv
1467 focusRequestId={focusRequestId}1400 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1468 autoFocus={isActive}1401 : {})}
1469 resizeEpoch={resizeEpoch}1402 onSessionExited={() => onCloseTerminal(terminalId)}
1470 drawerHeight={drawerHeight}1403 onAddTerminalContext={onAddTerminalContext}
1471 keybindings={keybindings}1404 focusRequestId={focusRequestId}
1472 />1405 autoFocus={terminalId === resolvedActiveTerminalId}
1406 resizeEpoch={resizeEpoch}
1407 drawerHeight={drawerHeight}
1408 keybindings={keybindings}
1409 />
1410 </div>
1473 </div>1411 </div>
1474 </div>1412 );
1475 );1413 })}
1476 })}1414 </div>
1477 </div>1415 ) : (
1478 ) : (1416 <div className="h-full p-1">
1479 <div className="h-full p-1">1417 <TerminalViewport
1480 <TerminalViewport1418 advancedTypography={advancedTypography}
1481 advancedTypography={advancedTypography}1419 key={resolvedActiveTerminalId}
1482 key={resolvedActiveTerminalId}1420 threadRef={threadRef}
1483 threadRef={threadRef}1421 threadId={threadId}
1484 threadId={threadId}1422 terminalId={resolvedActiveTerminalId}
1485 terminalId={resolvedActiveTerminalId}1423 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1486 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1424 cwd={activeTerminalLaunchLocation.cwd}
1487 cwd={activeTerminalLaunchLocation.cwd}1425 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1488 {...(activeTerminalLaunchLocation.worktreePath !== undefined1426 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1489 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }1427 : {})}
1490 : {})}1428 {...(activeTerminalLaunchLocation.runtimeEnv
1491 {...(activeTerminalLaunchLocation.runtimeEnv1429 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1492 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }1430 : {})}
1493 : {})}1431 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1494 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1432 onAddTerminalContext={onAddTerminalContext}
1495 onAddTerminalContext={onAddTerminalContext}1433 focusRequestId={focusRequestId}
1496 focusRequestId={focusRequestId}1434 autoFocus
1497 autoFocus1435 resizeEpoch={resizeEpoch}
1498 resizeEpoch={resizeEpoch}1436 drawerHeight={drawerHeight}
1499 drawerHeight={drawerHeight}1437 keybindings={keybindings}
1500 keybindings={keybindings}1438 />
1501 />1439 </div>
1502 </div>1440 )}
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}
1516 </div>1441 </div>
15171442
1518 <div className="min-h-0 flex-1 overflow-y-auto p-1">1443 {hasTerminalSidebar && (
1519 {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {1444 <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">
1520 const isSplitGroup = terminalGroup.terminalIds.length > 1;1445 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
1521 const splitVertically = terminalGroup.splitDirection === "vertical";1446 <div className="inline-flex h-full items-stretch">
1522 const SplitIcon = splitVertically ? SquareSplitVertical : SquareSplitHorizontal;1447 <TerminalActionButton
1523 return (1448 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
1524 <div1449 hasReachedSplitLimit
1525 key={terminalGroup.id}1450 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1526 className={cn(1451 : "hover:bg-accent/70"
1527 groupIndex > 0 && "mt-1",1452 }`}
1528 isSplitGroup && "rounded-lg border border-border/80 bg-muted/20 p-1",1453 onClick={onSplitTerminalAction}
1529 )}1454 label={splitTerminalActionLabel}
1530 >1455 >
1531 {isSplitGroup ? (1456 <SquareSplitHorizontal className="size-3.25" />
1532 <div className="flex h-5 items-center gap-1 px-1 text-[10px] font-medium text-muted-foreground">1457 </TerminalActionButton>
1533 <SplitIcon className="size-3" />1458 <TerminalActionButton
1534 <span>{splitVertically ? "Split down" : "Split right"}</span>1459 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
1535 </div>1460 hasReachedSplitLimit
1536 ) : null}1461 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1537 <div className={cn(isSplitGroup && "space-y-0.5")}>1462 : "hover:bg-accent/70"
1538 {terminalGroup.terminalIds.map((terminalId) => {1463 }`}
1539 const isActive = terminalId === resolvedActiveTerminalId;1464 onClick={onSplitTerminalVerticalAction}
1540 const automaticLabel =1465 label={splitTerminalVerticalActionLabel}
1541 automaticTerminalLabelById.get(terminalId) ?? "Terminal";1466 >
1542 const customLabel = terminalCustomLabels[terminalId]?.trim() ?? "";1467 <SquareSplitVertical className="size-3.25" />
1543 const displayLabel = terminalLabelById.get(terminalId) ?? automaticLabel;1468 </TerminalActionButton>
1544 const isRenaming = renamingTerminalId === terminalId;1469 <TerminalActionButton
1545 return (1470 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1546 <div1471 onClick={onNewTerminalAction}
1547 key={terminalId}1472 label={newTerminalActionLabel}
1548 className={cn(1473 >
1549 "group/tab flex h-6 items-center gap-0.5 rounded-md pr-2 text-xs",1474 <Plus className="size-3.25" />
1550 isSplitGroup ? "pl-0.5" : "pl-1.5",1475 </TerminalActionButton>
1551 isActive1476 <TerminalActionButton
1552 ? "bg-accent text-foreground"1477 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1553 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",1478 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1554 )}1479 label={closeTerminalActionLabel}
1480 >
1481 <Trash2 className="size-3.25" />
1482 </TerminalActionButton>
1483 </div>
1484 </div>
1485
1486 <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
1487 {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {
1488 const isGroupActive =
1489 terminalGroup.terminalIds.includes(resolvedActiveTerminalId);
1490 const groupActiveTerminalId = isGroupActive
1491 ? resolvedActiveTerminalId
1492 : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId);
1493
1494 return (
1495 <div key={terminalGroup.id} className="pb-0.5">
1496 {showGroupHeaders && (
1497 <button
1498 type="button"
1499 className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${
1500 isGroupActive
1501 ? "bg-accent/70 text-foreground"
1502 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1503 }`}
1504 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
1555 >1505 >
1556 <button1506 Group {groupIndex + 1}
1557 type="button"1507 </button>
1558 className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"1508 )}
1559 aria-label={`Close ${displayLabel}`}1509
1560 onClick={() => onCloseTerminal(terminalId)}1510 <div
1561 >1511 className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""}
1562 <TerminalSquare className="size-3 shrink-0 group-hover/tab:hidden group-focus-visible/close:hidden pointer-coarse:hidden" />1512 >
1563 <XIcon className="hidden size-3 group-hover/tab:block group-focus-visible/close:block pointer-coarse:block" />1513 {terminalGroup.terminalIds.map((terminalId) => {
1564 </button>1514 const isActive = terminalId === resolvedActiveTerminalId;
1565 {isRenaming ? (1515 const closeTerminalLabel = `Close ${
1566 <input1516 terminalLabelById.get(terminalId) ?? "terminal"
1567 autoFocus1517 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
1568 value={terminalRenameDraft}1518 return (
1569 aria-label="Rename terminal"1519 <div
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"1520 key={terminalId}
1571 onFocus={(event) => event.currentTarget.select()}1521 className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${
1572 onChange={(event) =>1522 isActive
1573 setTerminalRenameDraft(event.currentTarget.value)1523 ? "bg-accent text-foreground"
1574 }1524 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1575 onBlur={() => {1525 }`}
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 }}
1605 >1526 >
1606 <span className="min-w-0 flex-1 truncate">1527 {showGroupHeaders && (
1607 {displayLabel}1528 <span className="text-[10px] text-muted-foreground/80">└</span>
1608 {customLabel && customLabel !== automaticLabel ? (1529 )}
1609 <span className="font-normal text-muted-foreground/80">1530 <button
1610 {` · ${automaticLabel}`}1531 type="button"
1611 </span>1532 className="flex min-w-0 flex-1 items-center gap-1 text-left"
1612 ) : null}1533 onClick={() => onActiveTerminalChange(terminalId)}
1613 </span>1534 >
1614 </button>1535 <TerminalSquare className="size-3 shrink-0" />
1615 )}1536 <span className="truncate">
1616 </div>1537 {terminalLabelById.get(terminalId) ?? "Terminal"}
1617 );1538 </span>
1618 })}1539 </button>
1619 </div>1540 {normalizedTerminalIds.length > 1 && (
1620 </div>1541 <Popover>
1621 );1542 <PopoverTrigger
1622 })}1543 openOnHover
1623 </div>1544 render={
1624 </aside>1545 <button
1546 type="button"
1547 className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
1548 onClick={() => onCloseTerminal(terminalId)}
1549 aria-label={closeTerminalLabel}
1550 />
1551 }
1552 >
1553 <XIcon className="size-2.5" />
1554 </PopoverTrigger>
1555 <PopoverPopup
1556 tooltipStyle
1557 side="bottom"
1558 sideOffset={6}
1559 align="center"
1560 className="pointer-events-none select-none"
1561 >
1562 {closeTerminalLabel}
1563 </PopoverPopup>
1564 </Popover>
1565 )}
1566 </div>
1567 );
1568 })}
1569 </div>
1570 </div>
1571 );
1572 })}
1573 </div>
1574 </aside>
1575 )}
1576 </div>
1625 </div>1577 </div>
1626 </aside>1578 </aside>
1627 );1579 );
apps/web/src/components/WorkspacePageContainer.tsx deleted-62
...@@ -1,62 +0,0 @@
1import type { ComponentPropsWithoutRef } from "react";
2
3import { cn } from "../lib/utils";
4import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
5
6export type WorkspacePageWidth = "readable" | "wide" | "expanded";
7
8const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
9 readable: "max-w-4xl",
10 wide: "max-w-5xl",
11 expanded: "max-w-6xl",
12};
13
14/** Shared full-page frame for workspace routes beneath their top bar. */
15export function WorkspacePageContainer({
16 width = "readable",
17 className,
18 ...props
19}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
20 return (
21 <div
22 className={cn(
23 "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",
24 WIDTH_CLASS[width],
25 className,
26 )}
27 {...props}
28 />
29 );
30}
31
32/** Shared top-bar geometry for every full-width workspace surface. */
33export function WorkspacePageHeader({
34 electron = false,
35 reserveNativeControls = electron,
36 className,
37 ...props
38}: ComponentPropsWithoutRef<"header"> & {
39 readonly electron?: boolean;
40 readonly reserveNativeControls?: boolean;
41}) {
42 return (
43 <header
44 className={cn(
45 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-3 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
46 electron && "drag-region",
47 reserveNativeControls && "wco:pr-[var(--workspace-native-controls-inset)]",
48 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
49 className,
50 )}
51 {...props}
52 />
53 );
54}
55
56/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
57export function WorkspacePageHeaderEdgeControl({
58 className,
59 ...props
60}: ComponentPropsWithoutRef<"div">) {
61 return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;
62}
apps/web/src/components/chat/ChangedFilesTree.test.tsx+12-11
...@@ -23,9 +23,13 @@ describe("ChangedFilesCard", () => {...@@ -23,9 +23,13 @@ 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('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1');26 expect(markup).toContain(
27 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden',
28 );
27 expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap');29 expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap');
28 expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>');30 expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate');
31 expect(markup).toContain("@[24rem]/changed-files:inline");
32 expect(markup).not.toContain("sm:inline");
29 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');33 expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"');
30 expect(markup).toContain("!size-[22px]");34 expect(markup).toContain("!size-[22px]");
31 expect(markup).toContain("size-3");35 expect(markup).toContain("size-3");
...@@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => {...@@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => {
34 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');38 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
35 expect(markup).toContain("1 changed file");39 expect(markup).toContain("1 changed file");
36 expect(markup).not.toContain("1 changed files");40 expect(markup).not.toContain("1 changed files");
37 expect(markup).not.toContain("Hide files");
38 expect(markup).not.toContain("ml-auto");
39 });41 });
4042
41 it("renders a clean representative-file preview for a large latest change", () => {43 it("renders a scope and representative-file preview for a large latest change", () => {
42 const markup = renderToStaticMarkup(44 const markup = renderToStaticMarkup(
43 <ChangedFilesCard45 <ChangedFilesCard
44 turnId={TurnId.make("turn-1")}46 turnId={TurnId.make("turn-1")}
...@@ -65,15 +67,14 @@ describe("ChangedFilesCard", () => {...@@ -65,15 +67,14 @@ describe("ChangedFilesCard", () => {
6567
66 expect(markup).toContain('data-changed-files-state="preview"');68 expect(markup).toContain('data-changed-files-state="preview"');
67 expect(markup).toContain('aria-expanded="false"');69 expect(markup).toContain('aria-expanded="false"');
68 expect(markup).toContain("apps/web/src/");70 expect(markup).toContain("apps");
69 expect(markup).toContain("packages/shared/src/");71 expect(markup).toContain("2 files");
72 expect(markup).toContain("packages");
73 expect(markup).toContain("root");
70 expect(markup).toContain("App.tsx");74 expect(markup).toContain("App.tsx");
71 expect(markup).toContain("git.ts");75 expect(markup).toContain("git.ts");
72 expect(markup).toContain("README.md");76 expect(markup).toContain("README.md");
73 expect(markup).not.toContain("basis-0");77 expect(markup).toContain("Show all 4 files");
74 expect(markup).not.toContain("+1 more");
75 expect(markup).not.toContain("Show files");
76 expect(markup).toContain('aria-label="120 additions, 20 deletions"');
77 expect(markup).not.toContain("App.test.tsx");78 expect(markup).not.toContain("App.test.tsx");
78 });79 });
7980
apps/web/src/components/chat/ChangedFilesTree.tsx+39-23
...@@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -19,7 +19,11 @@ 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 { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation";22import {
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
2327
24const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2529
...@@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
46 onOpenTurnDiff,50 onOpenTurnDiff,
47 } = props;51 } = props;
48 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);52 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
49 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);54 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
50 const compactPreviewVisible = showCompactPreview && !expanded;55 const compactPreviewVisible = showCompactPreview && !expanded;
5156
52 return (57 return (
53 <div58 <div
54 className="@container/changed-files mt-4 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32"59 className="@container/changed-files mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
55 data-changed-files-state={60 data-changed-files-state={
56 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"61 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
57 }62 }
...@@ -59,7 +64,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -59,7 +64,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
59 <div64 <div
60 data-changed-files-header=""65 data-changed-files-header=""
61 className={cn(66 className={cn(
62 "flex items-center justify-between gap-2 rounded-lg px-1",67 "flex items-center justify-between gap-2 rounded-xl px-1",
63 expanded &&68 expanded &&
64 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",69 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
65 )}70 )}
...@@ -68,7 +73,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -68,7 +73,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
68 type="button"73 type="button"
69 aria-expanded={expanded}74 aria-expanded={expanded}
70 data-scroll-anchor-ignore75 data-scroll-anchor-ignore
71 className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"76 className="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
72 onClick={() => onExpandedChange(!expanded)}77 onClick={() => onExpandedChange(!expanded)}
73 >78 >
74 <ChevronRightIcon79 <ChevronRightIcon
...@@ -91,6 +96,9 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -91,6 +96,9 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
91 />96 />
92 )}97 )}
93 </span>98 </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>
94 </button>102 </button>
95 <div className="flex shrink-0 items-center gap-1.5">103 <div className="flex shrink-0 items-center gap-1.5">
96 {expanded ? (104 {expanded ? (
...@@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
150 onOpenTurnDiff={onOpenTurnDiff}158 onOpenTurnDiff={onOpenTurnDiff}
151 />159 />
152 ) : compactPreviewVisible ? (160 ) : compactPreviewVisible ? (
153 <div className="px-1.5 pb-1 pt-0.5">161 <div className="px-2 pb-1.5 pt-1">
154 <div className="flex min-w-0 items-center gap-x-3 overflow-hidden">162 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
163 {scopeSummary.map((scope, index) => (
164 <span key={scope.label} className="inline-flex items-center gap-1">
165 {index > 0 ? <span aria-hidden="true">·</span> : null}
166 <span className="font-mono text-foreground/75">{scope.label}</span>
167 <span>
168 {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"}
169 </span>
170 </span>
171 ))}
172 </p>
173 <div className="mt-2 flex flex-wrap items-center gap-1.5">
155 {previewFiles.map((file) => (174 {previewFiles.map((file) => (
156 <button175 <button
157 key={file.path}176 key={file.path}
158 type="button"177 type="button"
159 title={file.path}178 title={file.path}
160 className="group flex min-h-6 min-w-0 max-w-72 shrink items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"179 className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
161 onClick={() => onOpenTurnDiff(turnId, file.path)}180 onClick={() => onOpenTurnDiff(turnId, file.path)}
162 >181 >
163 <PierreEntryIcon182 <PierreEntryIcon
164 pathValue={file.path}183 pathValue={file.path}
165 kind="file"184 kind="file"
166 theme={resolvedTheme}185 theme={resolvedTheme}
167 className="size-3.5 shrink-0 text-muted-foreground/70"186 className="size-3 shrink-0 text-muted-foreground/70"
168 />187 />
169 <span className="min-w-0 truncate font-mono text-xs text-foreground/75 group-hover:text-foreground">188 <span className="truncate">{changedFileName(file.path)}</span>
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}
180 </button>189 </button>
181 ))}190 ))}
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>
182 </div>198 </div>
183 </div>199 </div>
184 ) : null}200 ) : null}
...@@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
254 ) : (270 ) : (
255 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />271 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
256 )}272 )}
257 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">273 <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
258 {node.name}274 {node.name}
259 </span>275 </span>
260 {hasNonZeroStat(node.stat) && (276 {hasNonZeroStat(node.stat) && (
261 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">277 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
262 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />278 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
263 </span>279 </span>
264 )}280 )}
...@@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
289 theme={resolvedTheme}305 theme={resolvedTheme}
290 className="size-3.5 text-muted-foreground/70"306 className="size-3.5 text-muted-foreground/70"
291 />307 />
292 <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">308 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
293 {node.name}309 {node.name}
294 </span>310 </span>
295 {node.stat && (311 {node.stat && (
296 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">312 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
297 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />313 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
298 </span>314 </span>
299 )}315 )}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+7-209
...@@ -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 "assistant-thought-entry",
535 "work-toggle:work-entry-1",
536 "turn-fold:turn-1",534 "turn-fold:turn-1",
535 "assistant-thought-entry",
536 "work-entry-1",
537 "assistant-final-entry",537 "assistant-final-entry",
538 ]);538 ]);
539 expect(539 expect(
...@@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -638,84 +638,6 @@ 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
719 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {641 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
720 const rows = deriveMessagesTimelineRows({642 const rows = deriveMessagesTimelineRows({
721 timelineEntries: [643 timelineEntries: [
...@@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => {
849 turnId: "turn-1" as never,771 turnId: "turn-1" as never,
850 label: "Ran command",772 label: "Ran command",
851 tone: "tool" as const,773 tone: "tool" as const,
852 toolLifecycleStatus: "inProgress" as const,
853 },774 },
854 },775 },
855 ],776 ],
...@@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => {...@@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => {
867788
868 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);789 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
869 expect(rows.map((row) => row.id)).toEqual([790 expect(rows.map((row) => row.id)).toEqual([
870 "working-indicator-row",
871 "assistant-thought-entry",791 "assistant-thought-entry",
872 "work-live:work-entry-1",792 "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([
927 "working-indicator-row",793 "working-indicator-row",
928 "work-live:tool:call-1",
929 ]);794 ]);
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);
997 });795 });
998796
999 it("does not fold the session's running turn when latestTurn regresses", () => {797 it("does not fold the session's running turn when latestTurn regresses", () => {
...@@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => {
1054 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([852 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
1055 "turn-1",853 "turn-1",
1056 ]);854 ]);
1057 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");855 expect(rows.map((row) => row.id)).toContain("running-work-entry");
1058 });856 });
1059857
1060 it("only shows assistant metadata on the terminal assistant message", () => {858 it("only shows assistant metadata on the terminal assistant message", () => {
...@@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => {
1196 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),994 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
1197 });995 });
1198996
1199 expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]);997 expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
1200 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({998 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1201 groupId: "work-group:work-entry-1",999 groupId: "work-group:work-entry-1",
1202 hiddenCount: 3,1000 hiddenCount: 2,
1203 expanded: false,1001 expanded: false,
1204 onlyToolEntries: true,1002 onlyToolEntries: true,
1205 });1003 });
1206 expect(expandedRows.map((row) => row.id)).toEqual([1004 expect(expandedRows.map((row) => row.id)).toEqual([
1207 "work-toggle:work-entry-1",
1208 "work-1",1005 "work-1",
1209 "work-2",1006 "work-2",
1210 "work-3",1007 "work-3",
1008 "work-toggle:work-entry-1",
1211 ]);1009 ]);
1212 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1010 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1213 expanded: true,1011 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+30-382
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import {2import {
3 formatDuration,3 formatDuration,
4 workEntryIndicatesToolFailure,
5 workEntryIndicatesToolNeutralStatus,4 workEntryIndicatesToolNeutralStatus,
6 workLogEntryIsToolLike,5 workLogEntryIsToolLike,
7 type TimelineEntry,6 type TimelineEntry,
...@@ -167,17 +166,6 @@ export type MessagesTimelineRow =...@@ -167,17 +166,6 @@ export type MessagesTimelineRow =
167 id: string;166 id: string;
168 createdAt: string;167 createdAt: string;
169 groupedEntries: WorkLogEntry[];168 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;
181 }169 }
182 | {170 | {
183 kind: "work-toggle";171 kind: "work-toggle";
...@@ -187,9 +175,6 @@ export type MessagesTimelineRow =...@@ -187,9 +175,6 @@ export type MessagesTimelineRow =
187 hiddenCount: number;175 hiddenCount: number;
188 expanded: boolean;176 expanded: boolean;
189 onlyToolEntries: boolean;177 onlyToolEntries: boolean;
190 summary: string | null;
191 summaryKind: ToolGroupAction | "mixed" | null;
192 hasFailure: boolean;
193 }178 }
194 | {179 | {
195 kind: "turn-fold";180 kind: "turn-fold";
...@@ -223,12 +208,7 @@ export type MessagesTimelineRow =...@@ -223,12 +208,7 @@ export type MessagesTimelineRow =
223 createdAt: string;208 createdAt: string;
224 turnPlan: TurnPlanEntry;209 turnPlan: TurnPlanEntry;
225 }210 }
226 | {211 | { kind: "working"; id: string; createdAt: string | null };
227 kind: "working";
228 id: string;
229 createdAt: string | null;
230 showThinking: boolean;
231 };
232212
233export interface StableMessagesTimelineRowsState {213export interface StableMessagesTimelineRowsState {
234 byId: Map<string, MessagesTimelineRow>;214 byId: Map<string, MessagesTimelineRow>;
...@@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string {
258 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();238 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
259}239}
260240
261type ToolGroupAction = "read" | "edit" | "command" | "search" | "other";
262
263function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
264 if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read";
265 if (
266 entry.requestKind === "file-change" ||
267 entry.itemType === "file_change" ||
268 (entry.changedFiles?.length ?? 0) > 0
269 ) {
270 return "edit";
271 }
272 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
273 return "command";
274 }
275 if (entry.itemType === "web_search") return "search";
276 return "other";
277}
278
279function toolGroupActionCount(
280 action: ToolGroupAction,
281 entries: ReadonlyArray<WorkLogEntry>,
282): number {
283 if (action !== "edit") return entries.length;
284
285 const changedFiles = new Set<string>();
286 let editsWithoutFileDetails = 0;
287 for (const entry of entries) {
288 if (!entry.changedFiles || entry.changedFiles.length === 0) {
289 editsWithoutFileDetails += 1;
290 continue;
291 }
292 for (const file of entry.changedFiles) changedFiles.add(file);
293 }
294 return changedFiles.size + editsWithoutFileDetails;
295}
296
297function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
298 switch (action) {
299 case "read":
300 return `Read ${count} ${count === 1 ? "file" : "files"}`;
301 case "edit":
302 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
303 case "command":
304 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
305 case "search":
306 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
307 case "other":
308 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
309 }
310}
311
312/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
313export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
314 const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
315 for (const entry of entries) {
316 const action = toolGroupAction(entry);
317 const group = groupedEntries.get(action);
318 if (group) group.push(entry);
319 else groupedEntries.set(action, [entry]);
320 }
321 const labels = [...groupedEntries].map(([action, actionEntries]) =>
322 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
323 );
324 const sentenceLabels = labels.map((label, index) =>
325 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
326 );
327 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
328 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
329 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
330}
331
332function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupAction | "mixed" {
333 const actions = new Set(entries.map(toolGroupAction));
334 return actions.size === 1 ? actions.values().next().value! : "mixed";
335}
336
337function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
338 return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId;
339}
340
341function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {
342 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;
343}
344
345export function resolveAssistantMessageCopyState({241export function resolveAssistantMessageCopyState({
346 text,242 text,
347 showCopyButton,243 showCopyButton,
...@@ -414,34 +310,17 @@ function deriveUnsettledTurnId(...@@ -414,34 +310,17 @@ function deriveUnsettledTurnId(
414 return isSettled ? null : latestTurn.turnId;310 return isSettled ? null : latestTurn.turnId;
415}311}
416312
417function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number {
418 return timelineEntries.findLastIndex(
419 (entry) => entry.kind === "message" && entry.message.role === "user",
420 );
421}
422
423function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
424 if (entry.kind === "message") {
425 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;
426 }
427 if (entry.kind === "turn-plan") {
428 return entry.turnPlan.turnId;
429 }
430 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
431}
432
433/**313/**
434 * Settled turns fold their commentary and tool activity behind a314 * Settled turns fold their commentary and tool activity behind a
435 * "Worked for ..." row placed immediately before the next terminal assistant315 * "Worked for ..." row anchored at the turn's first foldable entry; the
436 * response. A steer can split one visible response across turn ids, so tying316 * terminal assistant message stays visible below the fold.
437 * the disclosure to the first hidden entry would strand it above the steer.
438 */317 */
439function deriveTurnFolds(input: {318function deriveTurnFolds(input: {
440 timelineEntries: ReadonlyArray<TimelineEntry>;319 timelineEntries: ReadonlyArray<TimelineEntry>;
441 terminalAssistantMessageIds: ReadonlySet<string>;320 terminalAssistantMessageIds: ReadonlySet<string>;
442 latestTurn: TimelineLatestTurn | null;321 latestTurn: TimelineLatestTurn | null;
443 unsettledTurnId: TurnId | null;322 unsettledTurnId: TurnId | null;
444}): ReadonlyMap<string, ReadonlyArray<TurnFold>> {323}): ReadonlyMap<string, TurnFold> {
445 interface TurnGroup {324 interface TurnGroup {
446 entries: Array<TimelineEntry>;325 entries: Array<TimelineEntry>;
447 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;326 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
...@@ -496,7 +375,7 @@ function deriveTurnFolds(input: {...@@ -496,7 +375,7 @@ function deriveTurnFolds(input: {
496 }375 }
497 }376 }
498377
499 const foldsByAnchorEntryId = new Map<string, TurnFold[]>();378 const foldsByAnchorEntryId = new Map<string, TurnFold>();
500 for (const [turnId, group] of groupsByTurnId) {379 for (const [turnId, group] of groupsByTurnId) {
501 if (turnId === input.unsettledTurnId) {380 if (turnId === input.unsettledTurnId) {
502 continue;381 continue;
...@@ -526,24 +405,6 @@ function deriveTurnFolds(input: {...@@ -526,24 +405,6 @@ function deriveTurnFolds(input: {
526 if (!firstEntry || !lastEntry) {405 if (!firstEntry || !lastEntry) {
527 continue;406 continue;
528 }407 }
529 const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) =>
530 hiddenEntryIds.has(entry.id),
531 );
532 if (lastHiddenEntryIndex < 0) {
533 continue;
534 }
535 const nextTerminalAssistantEntry = input.timelineEntries
536 .slice(lastHiddenEntryIndex + 1)
537 .find(
538 (entry) =>
539 entry.kind === "message" &&
540 entry.message.role === "assistant" &&
541 input.terminalAssistantMessageIds.has(entry.message.id),
542 );
543 const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex];
544 if (!anchorEntry) {
545 continue;
546 }
547408
548 const isLatestInterruptedTurn =409 const isLatestInterruptedTurn =
549 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";410 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
...@@ -570,16 +431,13 @@ function deriveTurnFolds(input: {...@@ -570,16 +431,13 @@ function deriveTurnFolds(input: {
570 ? `Worked for ${duration}`431 ? `Worked for ${duration}`
571 : "Worked";432 : "Worked";
572433
573 const fold = {434 foldsByAnchorEntryId.set(firstEntry.id, {
574 turnId,435 turnId,
575 anchorEntryId: anchorEntry.id,436 anchorEntryId: firstEntry.id,
576 createdAt: anchorEntry.createdAt,437 createdAt: firstEntry.createdAt,
577 hiddenEntryIds,438 hiddenEntryIds,
578 label,439 label,
579 };440 });
580 const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id);
581 if (anchoredFolds) anchoredFolds.push(fold);
582 else foldsByAnchorEntryId.set(anchorEntry.id, [fold]);
583 }441 }
584 return foldsByAnchorEntryId;442 return foldsByAnchorEntryId;
585}443}
...@@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: {...@@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: {
611 unsettledTurnId,469 unsettledTurnId,
612 });470 });
613 const collapsedEntryIds = new Set<string>();471 const collapsedEntryIds = new Set<string>();
614 for (const folds of foldsByAnchorEntryId.values()) {472 for (const fold of foldsByAnchorEntryId.values()) {
615 for (const fold of folds) {473 if (!input.expandedTurnIds?.has(fold.turnId)) {
616 if (!input.expandedTurnIds?.has(fold.turnId)) {474 for (const entryId of fold.hiddenEntryIds) {
617 for (const entryId of fold.hiddenEntryIds) {475 collapsedEntryIds.add(entryId);
618 collapsedEntryIds.add(entryId);
619 }
620 }476 }
621 }477 }
622 }478 }
623479
624 let activeTurnHeaderIndex = input.timelineEntries.length;
625 if (input.isWorking) {
626 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
627 const firstOwnedAfterUser =
628 unsettledTurnId === null
629 ? -1
630 : input.timelineEntries.findIndex(
631 (entry, index) =>
632 index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
633 );
634 activeTurnHeaderIndex =
635 firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
636 }
637 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
638 input.isWorking &&
639 index >= activeTurnHeaderIndex &&
640 (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
641 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
642 workLogEntryIsToolLike(entry) &&
643 (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry));
644 const activeEntries = input.isWorking
645 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
646 : [];
647 const activeTurnHasVisibleContent =
648 activeEntries.some((entry) => {
649 if (entry.kind === "message") {
650 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
651 }
652 if (entry.kind === "work") {
653 return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry);
654 }
655 if (entry.kind === "turn-plan") return true;
656 return false;
657 }) ||
658 input.timelineEntries
659 .slice(activeTurnHeaderIndex)
660 .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan");
661
662 const activeWorkEntryIds = new Set<string>();
663 const activeWorkRowsByAnchorId = new Map<
664 string,
665 Extract<MessagesTimelineRow, { kind: "work-live" }>
666 >();
667 const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false);
668 for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) {
669 const entry = input.timelineEntries[index];
670 if (!entry) continue;
671 const isVisibleTurnContent =
672 (entry.kind === "message" && entry.message.role === "user") ||
673 entry.kind === "proposed-plan" ||
674 (entryBelongsToActiveTurn(entry, index) &&
675 ((entry.kind === "message" && entry.message.role === "assistant") ||
676 entry.kind === "turn-plan" ||
677 (entry.kind === "work" &&
678 entry.entry.agentSpawn === undefined &&
679 isVisibleActiveToolEntry(entry.entry))));
680 hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true;
681 }
682
683 for (let index = 0; index < input.timelineEntries.length; index += 1) {
684 const entry = input.timelineEntries[index];
685 if (
686 !entry ||
687 entry.kind !== "work" ||
688 entry.entry.agentSpawn !== undefined ||
689 !entryBelongsToActiveTurn(entry, index)
690 ) {
691 continue;
692 }
693 if (!isVisibleActiveToolEntry(entry.entry)) {
694 continue;
695 }
696
697 const anchorEntry = entry;
698 let latestToolEntry = entry;
699 const batchEntryIds = [entry.id];
700 const visibleBatchEntries = [entry.entry];
701 let cursor = index + 1;
702 while (cursor < input.timelineEntries.length) {
703 const nextEntry = input.timelineEntries[cursor];
704 if (
705 !nextEntry ||
706 nextEntry.kind !== "work" ||
707 nextEntry.entry.agentSpawn !== undefined ||
708 !entryBelongsToActiveTurn(nextEntry, cursor)
709 ) {
710 break;
711 }
712 batchEntryIds.push(nextEntry.id);
713 if (isVisibleActiveToolEntry(nextEntry.entry)) {
714 latestToolEntry = nextEntry;
715 visibleBatchEntries.push(nextEntry.entry);
716 }
717 cursor += 1;
718 }
719
720 // Once newer commentary, a plan, or another tool batch exists, this batch
721 // is history. Let the regular work-group path turn it into an expandable
722 // summary so none of its calls disappear behind the live one-line view.
723 if (hasLaterTurnContent[cursor] !== true) {
724 for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId);
725 const groupId = workGroupId(anchorEntry.id, anchorEntry.entry);
726 activeWorkRowsByAnchorId.set(anchorEntry.id, {
727 kind: "work-live",
728 id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`,
729 createdAt: anchorEntry.createdAt,
730 entry: latestToolEntry.entry,
731 groupedEntries: visibleBatchEntries,
732 groupId,
733 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
734 });
735 }
736 index = cursor - 1;
737 }
738
739 for (let index = 0; index < input.timelineEntries.length; index += 1) {480 for (let index = 0; index < input.timelineEntries.length; index += 1) {
740 const timelineEntry = input.timelineEntries[index];481 const timelineEntry = input.timelineEntries[index];
741 if (!timelineEntry) {482 if (!timelineEntry) {
742 continue;483 continue;
743 }484 }
744485
745 if (input.isWorking && index === activeTurnHeaderIndex) {486 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
487 if (turnFold) {
746 nextRows.push({488 nextRows.push({
747 kind: "working",489 kind: "turn-fold",
748 id: "working-indicator-row",490 id: `turn-fold:${turnFold.turnId}`,
749 createdAt: input.activeTurnStartedAt,491 createdAt: turnFold.createdAt,
750 showThinking: !activeTurnHasVisibleContent,492 turnId: turnFold.turnId,
493 label: turnFold.label,
494 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
751 });495 });
752 }496 }
753497
754 const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id);
755 if (anchoredTurnFolds) {
756 for (const turnFold of anchoredTurnFolds) {
757 nextRows.push({
758 kind: "turn-fold",
759 id: `turn-fold:${turnFold.turnId}`,
760 createdAt: turnFold.createdAt,
761 turnId: turnFold.turnId,
762 label: turnFold.label,
763 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
764 });
765 }
766 }
767
768 if (collapsedEntryIds.has(timelineEntry.id)) {498 if (collapsedEntryIds.has(timelineEntry.id)) {
769 continue;499 continue;
770 }500 }
771501
772 if (activeWorkEntryIds.has(timelineEntry.id)) {
773 const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id);
774 if (activeWorkRow) {
775 nextRows.push(activeWorkRow);
776 if (activeWorkRow.expanded) {
777 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
778 nextRows.push({
779 kind: "work",
780 id: workEntry.id,
781 createdAt: workEntry.createdAt,
782 groupedEntries: [workEntry],
783 isExpandedToolGroupEntry: true,
784 isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
785 });
786 }
787 }
788 }
789 continue;
790 }
791
792 if (timelineEntry.kind === "work") {502 if (timelineEntry.kind === "work") {
793 const groupedEntries = [timelineEntry.entry];503 const groupedEntries = [timelineEntry.entry];
794 let cursor = index + 1;504 let cursor = index + 1;
...@@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: {
797 if (507 if (
798 !nextEntry ||508 !nextEntry ||
799 nextEntry.kind !== "work" ||509 nextEntry.kind !== "work" ||
800 activeWorkEntryIds.has(nextEntry.id) ||
801 collapsedEntryIds.has(nextEntry.id) ||510 collapsedEntryIds.has(nextEntry.id) ||
802 foldsByAnchorEntryId.has(nextEntry.id)511 foldsByAnchorEntryId.has(nextEntry.id)
803 ) {512 ) {
...@@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: {...@@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: {
810 (entry) => !workEntryIndicatesToolNeutralStatus(entry),519 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
811 );520 );
812 if (visibleGroupedEntries.length > 0) {521 if (visibleGroupedEntries.length > 0) {
813 const onlyToolEntries = visibleGroupedEntries.every(522 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
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) {
845 nextRows.push({523 nextRows.push({
846 kind: "work",524 kind: "work",
847 id: timelineEntry.id,525 id: timelineEntry.id,
848 createdAt: timelineEntry.createdAt,526 createdAt: timelineEntry.createdAt,
849 groupedEntries: visibleGroupedEntries,527 groupedEntries: visibleGroupedEntries,
850 isExpandedToolGroupEntry: false,
851 isLastExpandedToolGroupEntry: false,
852 });528 });
853 } else {529 } else {
854 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);530 const groupId = `work-group:${timelineEntry.id}`;
855 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;531 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
856 // Agent-spawn CTA rows are always visible: a running fleet must532 // Agent-spawn CTA rows are always visible: a running fleet must
857 // never hide behind a "+N tool calls" toggle. Selection is by533 // never hide behind a "+N tool calls" toggle. Selection is by
...@@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: {
875 id: workEntry.id,551 id: workEntry.id,
876 createdAt: workEntry.createdAt,552 createdAt: workEntry.createdAt,
877 groupedEntries: [workEntry],553 groupedEntries: [workEntry],
878 isExpandedToolGroupEntry: false,
879 isLastExpandedToolGroupEntry: false,
880 });554 });
881 }555 }
882556
...@@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: {
888 groupId,562 groupId,
889 hiddenCount: hiddenEntries.length,563 hiddenCount: hiddenEntries.length,
890 expanded,564 expanded,
891 onlyToolEntries,565 onlyToolEntries: visibleGroupedEntries.every((entry) =>
892 summary: null,566 workLogEntryIsToolLike(entry),
893 summaryKind: null,
894 hasFailure: visibleGroupedEntries.some((entry) =>
895 workEntryIndicatesToolFailure(entry),
896 ),567 ),
897 });568 });
898 }569 }
...@@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: {
958 });629 });
959 }630 }
960631
961 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {632 if (input.isWorking) {
962 nextRows.push({633 nextRows.push({
963 kind: "working",634 kind: "working",
964 id: "working-indicator-row",635 id: "working-indicator-row",
965 createdAt: input.activeTurnStartedAt,636 createdAt: input.activeTurnStartedAt,
966 showThinking: !activeTurnHasVisibleContent,
967 });637 });
968 }638 }
969639
...@@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
996666
997 switch (a.kind) {667 switch (a.kind) {
998 case "working":668 case "working":
999 return (669 return a.createdAt === (b as typeof a).createdAt;
1000 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1001 );
1002670
1003 case "turn-fold": {671 case "turn-fold": {
1004 const bf = b as typeof a;672 const bf = b as typeof a;
...@@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1015 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;683 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
1016 }684 }
1017685
1018 case "work": {686 case "work":
1019 const bw = b as typeof a;687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
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 }
1037688
1038 case "work-toggle": {689 case "work-toggle": {
1039 const bw = b as typeof a;690 const bw = b as typeof a;
...@@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1042 a.groupId === bw.groupId &&693 a.groupId === bw.groupId &&
1043 a.hiddenCount === bw.hiddenCount &&694 a.hiddenCount === bw.hiddenCount &&
1044 a.expanded === bw.expanded &&695 a.expanded === bw.expanded &&
1045 a.onlyToolEntries === bw.onlyToolEntries &&696 a.onlyToolEntries === bw.onlyToolEntries
1046 a.summary === bw.summary &&
1047 a.summaryKind === bw.summaryKind &&
1048 a.hasFailure === bw.hasFailure
1049 );697 );
1050 }698 }
1051699
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-44
...@@ -554,49 +554,7 @@ describe("MessagesTimeline", () => {...@@ -554,49 +554,7 @@ describe("MessagesTimeline", () => {
554 expect(markup).toContain("Work Log");554 expect(markup).toContain("Work Log");
555 });555 });
556556
557 it("makes the whole live tool row expandable without adding a chevron", () => {557 it("formats changed file paths from the workspace root", () => {
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", () => {
600 const markup = renderToStaticMarkup(558 const markup = renderToStaticMarkup(
601 <MessagesTimeline559 <MessagesTimeline
602 {...buildProps()}560 {...buildProps()}
...@@ -618,7 +576,7 @@ describe("MessagesTimeline", () => {...@@ -618,7 +576,7 @@ describe("MessagesTimeline", () => {
618 />,576 />,
619 );577 );
620578
621 expect(markup).toContain("Changed 1 file");579 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
622 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");580 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
623 });581 });
624582
apps/web/src/components/chat/MessagesTimeline.tsx+155-336
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 deriveTimelineEntries,35 deriveTimelineEntries,
36 workEntryIndicatesToolFailure,36 workEntryIndicatesToolFailure,
37 workEntryIndicatesToolNeutralStatus,37 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
38 workLogEntryIsToolLike,39 workLogEntryIsToolLike,
39} from "../../session-logic";40} from "../../session-logic";
40import { type TurnDiffSummary } from "../../types";41import { type TurnDiffSummary } from "../../types";
...@@ -56,6 +57,7 @@ import {...@@ -56,6 +57,7 @@ import {
56 MessageCircleIcon,57 MessageCircleIcon,
57 MousePointerClickIcon,58 MousePointerClickIcon,
58 PaintbrushIcon,59 PaintbrushIcon,
60 MinusIcon,
59 SquarePenIcon,61 SquarePenIcon,
60 TerminalIcon,62 TerminalIcon,
61 Undo2Icon,63 Undo2Icon,
...@@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped...@@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
918type TimelineRow = MessagesTimelineRow;920type TimelineRow = MessagesTimelineRow;
919921
920const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {922const 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
927 return (923 return (
928 <div924 <div
929 className={cn(925 className={cn(
930 // Commentary (non-terminal assistant) rows carry no metadata row, so926 // Commentary (non-terminal assistant) rows carry no metadata row, so
931 // they sit closer to the work that follows them.927 // they sit closer to the work that follows them.
932 isExpandedToolGroupEntry928 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
933 ? isLastExpandedToolGroupEntry929 row.kind === "work" ||
934 ? "pb-1"930 row.kind === "work-toggle" ||
935 : "pb-0"931 row.kind === "turn-plan"
936 : isExpandedToolGroupHeader932 ? "pb-2"
937 ? "pb-0"933 : "pb-4",
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",
949 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,934 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
950 )}935 )}
951 data-timeline-row-id={row.id}936 data-timeline-row-id={row.id}
...@@ -953,13 +938,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -953,13 +938,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
953 data-message-id={row.kind === "message" ? row.message.id : undefined}938 data-message-id={row.kind === "message" ? row.message.id : undefined}
954 data-message-role={row.kind === "message" ? row.message.role : undefined}939 data-message-role={row.kind === "message" ? row.message.role : undefined}
955 >940 >
956 {row.kind === "work" ? (941 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
957 <WorkGroupSection
958 groupedEntries={row.groupedEntries}
959 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}
960 />
961 ) : null}
962 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
963 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}942 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
964 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}943 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
965 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}944 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
...@@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {...@@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
11041083
1105function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {1084function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
1106 const ctx = use(TimelineRowCtx);1085 const ctx = use(TimelineRowCtx);
1086 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
11071087
1108 return (1088 return (
1109 <div className="border-b border-border/60 pb-2 pt-1">1089 <div className="border-b border-border/60 pb-2 pt-1">
...@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-...@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
1112 aria-expanded={row.expanded}1092 aria-expanded={row.expanded}
1113 data-scroll-anchor-ignore1093 data-scroll-anchor-ignore
1114 onClick={() => ctx.onToggleTurnFold(row.turnId)}1094 onClick={() => ctx.onToggleTurnFold(row.turnId)}
1115 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1095 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1116 >1096 >
1117 <span>{row.label}</span>1097 <span>{row.label}</span>
1118 <ChevronRightIcon1098 <Icon className="size-3.5" />
1119 className={cn("size-3.5 transition-transform", row.expanded && "-rotate-90")}
1120 />
1121 </button>1099 </button>
1122 </div>1100 </div>
1123 );1101 );
...@@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1300});1278});
13011279
1302function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1280function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1281 const { workingStepLabel } = use(TimelineRowActivityCtx);
1303 return (1282 return (
1304 <div>1283 <div className="py-0.5 pl-1.5">
1305 <div className="border-b border-border/60 pb-2 pt-1">1284 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">
1306 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">1285 <span className="inline-flex items-center gap-[3px]">
1286 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1287 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
1288 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
1289 </span>
1290 <span className="shrink-0">
1307 {row.createdAt ? (1291 {row.createdAt ? (
1308 <>1292 <>
1309 Working for <WorkingTimer createdAt={row.createdAt} />1293 Working for <WorkingTimer createdAt={row.createdAt} />
...@@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1311 ) : (1295 ) : (
1312 "Working..."1296 "Working..."
1313 )}1297 )}
1314 </div>1298 </span>
1299 {workingStepLabel ? (
1300 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1301 ) : null}
1315 </div>1302 </div>
1316 {row.showThinking ? (
1317 <div className="mt-2">
1318 <ThinkingActivityRow />
1319 </div>
1320 ) : null}
1321 </div>1303 </div>
1322 );1304 );
1323}1305}
...@@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
1358/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */1340/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
1359const WorkGroupSection = memo(function WorkGroupSection({1341const WorkGroupSection = memo(function WorkGroupSection({
1360 groupedEntries,1342 groupedEntries,
1361 isExpandedToolGroupEntry,
1362}: {1343}: {
1363 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];1344 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1364 isExpandedToolGroupEntry: boolean;
1365}) {1345}) {
1366 const { workspaceRoot } = use(TimelineRowCtx);1346 const { workspaceRoot } = use(TimelineRowCtx);
1367 const nonEmptyEntries = useMemo(1347 const nonEmptyEntries = useMemo(
...@@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
1378 if (nonEmptyEntries.length === 0) return null;1358 if (nonEmptyEntries.length === 0) return null;
13791359
1380 return (1360 return (
1381 <section1361 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
1382 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1383 aria-label={groupLabel}
1384 >
1385 {!onlyToolEntries && (1362 {!onlyToolEntries && (
1386 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>1363 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1387 )}1364 )}
...@@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({
1391 key={workEntry.id}1368 key={workEntry.id}
1392 workEntry={workEntry}1369 workEntry={workEntry}
1393 workspaceRoot={workspaceRoot}1370 workspaceRoot={workspaceRoot}
1394 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
1395 />1371 />
1396 ))}1372 ))}
1397 </div>1373 </div>
...@@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
1399 );1375 );
1400});1376});
14011377
1402function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
1403 return (
1404 <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
1405 <LiveActivityContent label={label} iconName={iconName} />
1406 <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-0">
1407 <div className="live-activity-focus-counter">
1408 <div className="live-activity-focus-aligned">
1409 <LiveActivityContent label={label} iconName={iconName} highlighted />
1410 </div>
1411 </div>
1412 </div>
1413 </div>
1414 );
1415}
1416
1417function ThinkingActivityRow() {
1418 return <LiveActivityRow label="Thinking" />;
1419}
1420
1421function LiveActivityContent({
1422 label,
1423 iconName,
1424 highlighted = false,
1425}: {
1426 label: string;
1427 iconName: WorkEntryIconName | undefined;
1428 highlighted?: boolean;
1429}) {
1430 return (
1431 <div
1432 className={cn(
1433 "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
1434 highlighted ? "text-foreground" : "text-secondary-label",
1435 )}
1436 >
1437 {iconName ? (
1438 <span
1439 className={cn(
1440 "flex size-6 shrink-0 items-center justify-center",
1441 highlighted ? "text-foreground" : "text-icon-muted",
1442 )}
1443 >
1444 <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />
1445 </span>
1446 ) : null}
1447 <span className="min-w-0 flex-1 truncate">{label}</span>
1448 </div>
1449 );
1450}
1451
1452function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
1453 const ctx = use(TimelineRowCtx);
1454
1455 return (
1456 <button
1457 type="button"
1458 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1459 aria-expanded={row.expanded}
1460 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1461 >
1462 <LiveActivityRow
1463 label={liveWorkEntryLabel(row.entry, ctx.workspaceRoot)}
1464 iconName={workEntryIconName(row.entry)}
1465 />
1466 </button>
1467 );
1468}
1469
1470function toolGroupSummaryIconName(
1471 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
1472): WorkEntryIconName {
1473 switch (kind) {
1474 case "read":
1475 return "eye";
1476 case "edit":
1477 return "square-pen";
1478 case "command":
1479 return "terminal";
1480 case "search":
1481 return "globe";
1482 case "other":
1483 return "wrench";
1484 case "mixed":
1485 case null:
1486 return "hammer";
1487 }
1488}
1489
1490function WorkGroupToggleTimelineRow({1378function WorkGroupToggleTimelineRow({
1491 row,1379 row,
1492}: {1380}: {
1493 row: Extract<TimelineRow, { kind: "work-toggle" }>;1381 row: Extract<TimelineRow, { kind: "work-toggle" }>;
1494}) {1382}) {
1495 const ctx = use(TimelineRowCtx);1383 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 }
1524 const labelNoun = row.onlyToolEntries1384 const labelNoun = row.onlyToolEntries
1525 ? row.hiddenCount === 11385 ? row.hiddenCount === 1
1526 ? "tool call"1386 ? "tool call"
...@@ -2159,101 +2019,32 @@ function workEntryPreview(...@@ -2159,101 +2019,32 @@ function workEntryPreview(
2159 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;2019 : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
2160}2020}
21612021
2162type CommandWrapper = "env" | "sudo";2022function workEntryRawCommand(
21632023 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
2164const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {2024): string | null {
2165 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),2025 const rawCommand = workEntry.rawCommand?.trim();
2166 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),2026 if (!rawCommand || !workEntry.command) {
2167};2027 return null;
2168
2169const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
2170 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]),
2171 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
2172};
2173
2174function commandProgramName(command: string): string | null {
2175 const tokens = command.trim().split(/\s+/);
2176 let index = 0;
2177 let wrapper: CommandWrapper | null = null;
2178
2179 while (index < tokens.length) {
2180 const token = tokens[index]?.replace(/^["']|["']$/g, "");
2181 if (!token) return null;
2182 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
2183 index += 1;
2184 continue;
2185 }
2186 if (token === "env" || token === "sudo") {
2187 wrapper = token;
2188 index += 1;
2189 continue;
2190 }
2191 if (wrapper !== null && token === "--") {
2192 wrapper = null;
2193 index += 1;
2194 continue;
2195 }
2196 if (wrapper !== null && token.startsWith("-")) {
2197 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
2198 if (tokens[index + 1] === undefined) return null;
2199 index += 2;
2200 continue;
2201 }
2202 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) {
2203 index += 1;
2204 continue;
2205 }
2206 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
2207 let consumesNextToken = false;
2208 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
2209 const shortOption = `-${option}`;
2210 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
2211 consumesNextToken = optionIndex === token.length - 2;
2212 break;
2213 }
2214 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
2215 }
2216 if (consumesNextToken && tokens[index + 1] === undefined) return null;
2217 index += consumesNextToken ? 2 : 1;
2218 continue;
2219 }
2220 return null;
2221 }
2222 return token.split(/[\\/]/).at(-1) || null;
2223 }
2224
2225 return null;
2226}
2227
2228function liveWorkEntryLabel(
2229 workEntry: TimelineWorkEntry,
2230 workspaceRoot: string | undefined,
2231): string {
2232 const command = workEntry.command?.trim();
2233 if (command) {
2234 const program = commandProgramName(command);
2235 if (program) return `Running ${program}`;
2236 return "Running command";
2237 }2028 }
22382029 return rawCommand === workEntry.command.trim() ? null : rawCommand;
2239 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2240}2030}
22412031
2242function buildToolCallExpandedBody(2032function buildToolCallExpandedBody(
2243 workEntry: TimelineWorkEntry,2033 workEntry: TimelineWorkEntry,
2244 workspaceRoot: string | undefined,2034 workspaceRoot: string | undefined,
2245): string | null {2035): string | null {
2246 const command = workEntry.rawCommand?.trim() || workEntry.command?.trim();
2247 const blocks: string[] = [];2036 const blocks: string[] = [];
2248 if (command) {
2249 blocks.push(command);
2250 }
2251 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {2037 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
2252 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);2038 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
2253 }2039 }
2254 const detail = workEntry.detail?.trim();2040 const raw = workEntryRawCommand(workEntry);
2255 if (detail && detail !== command) {2041 if (raw?.trim()) {
2256 blocks.push(detail);2042 blocks.push(raw.trim());
2043 } else if (workEntry.command?.trim()) {
2044 blocks.push(workEntry.command.trim());
2045 }
2046 if (workEntry.detail?.trim()) {
2047 blocks.push(workEntry.detail.trim());
2257 }2048 }
2258 const changedFiles = workEntry.changedFiles ?? [];2049 const changedFiles = workEntry.changedFiles ?? [];
2259 if (changedFiles.length > 0) {2050 if (changedFiles.length > 0) {
...@@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
2389 : "working"2180 : "working"
2390 : failed > 02181 : failed > 0
2391 ? `${failed} failed`2182 ? `${failed} failed`
2392 : "Completed";2183 : "✓ completed";
23932184
2394 return (2185 return (
2395 <div className="-mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32">2186 <button
2396 <div className="flex items-center justify-between gap-2 rounded-xl px-1">2187 type="button"
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">2188 onClick={onOpenAgents}
2398 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />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"
2399 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />2190 >
2400 <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>2191 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2401 {workflowName ? (2192 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2402 <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground md:inline">2193 <span className="min-w-0 truncate">
2403 {workflowName}2194 <span className="font-medium">{lead}</span>
2404 </span>2195 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
2405 ) : null}2196 </span>
2406 <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground sm:flex">2197 <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
2407 <span>{status}</span>2198 <span>{status}</span>
2408 {totalTokens > 0 ? (2199 {totalTokens > 0 ? (
2409 <span className="font-mono tabular-nums">2200 <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
2410 Σ {formatSubagentTokenCount(totalTokens)}2201 ) : null}
2411 </span>2202 <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
2412 ) : null}2203 </span>
2413 </span>2204 </button>
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>
2427 );2205 );
2428});2206});
24292207
2430const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {2208const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
2431 workEntry: TimelineWorkEntry;2209 workEntry: TimelineWorkEntry;
2432 workspaceRoot: string | undefined;2210 workspaceRoot: string | undefined;
2433 isExpandedToolGroupEntry: boolean;
2434}) {2211}) {
2435 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;2212 const { workEntry, workspaceRoot } = props;
2436 // Before any hooks: spawn CTA rows render their own component.2213 // Before any hooks: spawn CTA rows render their own component.
2437 if (workEntry.agentSpawn) {2214 if (workEntry.agentSpawn) {
2438 return <AgentSpawnCtaRow workEntry={workEntry} />;2215 return <AgentSpawnCtaRow workEntry={workEntry} />;
2439 }2216 }
2440 return (2217 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;
2441 <PlainWorkEntryRow
2442 workEntry={workEntry}
2443 workspaceRoot={workspaceRoot}
2444 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2445 />
2446 );
2447});2218});
24482219
2449const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {2220const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2450 workEntry: TimelineWorkEntry;2221 workEntry: TimelineWorkEntry;
2451 workspaceRoot: string | undefined;2222 workspaceRoot: string | undefined;
2452 isExpandedToolGroupEntry: boolean;
2453}) {2223}) {
2454 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;2224 const { workEntry, workspaceRoot } = props;
2225 const activity = use(TimelineRowActivityCtx);
2455 const [expanded, setExpanded] = useState(false);2226 const [expanded, setExpanded] = useState(false);
2456 const iconConfig = workToneIcon(workEntry.tone);2227 const iconConfig = workToneIcon(workEntry.tone);
2457 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2228 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2458 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);2229 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
2459 const entryIconName =2230 const heading = toolWorkEntryHeading(workEntry);
2460 showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);2231 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
2461 const isCommandEntry =2232 const preview =
2462 workEntry.requestKind === "command" ||2233 rawPreview &&
2463 workEntry.itemType === "command_execution" ||2234 normalizeCompactToolLabel(rawPreview).toLowerCase() ===
2464 Boolean(workEntry.command);2235 normalizeCompactToolLabel(heading).toLowerCase()
2465 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);2236 ? null
2237 : rawPreview;
2238 const displayText = preview ? `${heading} - ${preview}` : heading;
2466 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2239 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
2467 const canExpand = expandedBody !== null;2240 const canExpand = expandedBody !== null;
2241 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2468 const showDestructiveRowStyle =2242 const showDestructiveRowStyle =
2469 showFailedIndicator &&2243 showFailedIndicator &&
2470 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));2244 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
2471 const iconWrapperClass = cn(2245 const iconWrapperClass = cn(
2472 "flex size-6 shrink-0 items-center justify-center",2246 "flex size-5 shrink-0 items-center justify-center",
2473 showWarningIndicator || showFailedIndicator2247 showWarningIndicator
2474 ? "text-destructive"2248 ? "text-destructive"
2475 : showDestructiveRowStyle2249 : showDestructiveRowStyle
2476 ? "text-destructive"2250 ? "text-destructive"
...@@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2482 ? "font-medium text-warning"2256 ? "font-medium text-warning"
2483 : showDestructiveRowStyle2257 : showDestructiveRowStyle
2484 ? "font-medium text-destructive"2258 ? "font-medium text-destructive"
2485 : workLogEntryIsToolLike(workEntry)2259 : "font-medium text-foreground";
2486 ? "text-secondary-label"2260 const turnSettled = !activity.activeTurnInProgress;
2487 : "text-foreground/80";2261 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);
2488 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;2262 const showSuccessIndicator =
2263 workEntryIndicatesToolSuccess(workEntry) ||
2264 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
2489 const rowToggleProps = canExpand2265 const rowToggleProps = canExpand
2490 ? {2266 ? {
2491 role: "button" as const,2267 role: "button" as const,
2492 tabIndex: 0 as const,2268 tabIndex: 0 as const,
2493 "aria-label": displayText,2269 "aria-label": displayText,
2494 "aria-expanded": expanded,
2495 onClick: () => setExpanded((v) => !v),2270 onClick: () => setExpanded((v) => !v),
2496 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {2271 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
2497 if (e.key === "Enter" || e.key === " ") {2272 if (e.key === "Enter" || e.key === " ") {
...@@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2505 return (2280 return (
2506 <div2281 <div
2507 className={cn(2282 className={cn(
2508 "flex flex-col rounded-md px-0.5 transition-colors",2283 "flex flex-col rounded-md px-0.5 py-0.5 transition-colors",
2509 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
2510 canExpand &&2284 canExpand &&
2511 "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",2285 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
2512 )}2286 )}
2513 {...rowToggleProps}2287 {...rowToggleProps}
2514 >2288 >
2515 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">2289 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
2516 {showEntryIcon ? (2290 <span className={iconWrapperClass}>
2517 <span2291 <WorkEntryIconSvg
2518 className={iconWrapperClass}2292 name={entryIconName}
2519 aria-label={showFailedIndicator ? "Tool call failed" : undefined}2293 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
2520 >2294 />
2521 <WorkEntryIconSvg2295 </span>
2522 name={entryIconName}
2523 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
2524 />
2525 </span>
2526 ) : null}
2527 <div className="flex min-w-0 flex-1 items-center gap-1.5">2296 <div className="flex min-w-0 flex-1 items-center gap-1.5">
2528 <div className="min-w-0 flex-1 overflow-hidden">2297 <div className="min-w-0 flex-1 overflow-hidden">
2529 <p2298 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
2530 className={cn(2299 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
2531 "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed",2300 {preview && (
2532 isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm",2301 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
2533 )}2302 )}
2534 >
2535 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
2536 </p>2303 </p>
2537 </div>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 >
2310 {canExpand ? (
2311 <ChevronDownIcon
2312 className={cn(
2313 "size-3 shrink-0 opacity-70 transition-transform duration-200",
2314 expanded && "rotate-180",
2315 )}
2316 aria-hidden
2317 />
2318 ) : null}
2319 </span>
2320 <span className="flex size-4 shrink-0 items-center justify-center">
2321 {showFailedIndicator ? (
2322 <Tooltip>
2323 <TooltipTrigger
2324 render={
2325 <span
2326 className="flex size-4 items-center justify-center"
2327 aria-label="Tool call failed"
2328 />
2329 }
2330 >
2331 <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden />
2332 </TooltipTrigger>
2333 <TooltipPopup>Failed</TooltipPopup>
2334 </Tooltip>
2335 ) : showSuccessIndicator ? (
2336 <Tooltip>
2337 <TooltipTrigger
2338 render={<span className="flex size-4 items-center justify-center" />}
2339 >
2340 <span className="inline-flex size-4 items-center justify-center">
2341 <CheckIcon
2342 className="block size-3 shrink-0 stroke-current"
2343 stroke="currentColor"
2344 aria-hidden
2345 />
2346 </span>
2347 </TooltipTrigger>
2348 <TooltipPopup>Completed</TooltipPopup>
2349 </Tooltip>
2350 ) : showNeutralIndicator ? (
2351 <Tooltip>
2352 <TooltipTrigger
2353 render={<span className="flex size-4 items-center justify-center" />}
2354 >
2355 <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden />
2356 </TooltipTrigger>
2357 <TooltipPopup>Empty</TooltipPopup>
2358 </Tooltip>
2359 ) : null}
2360 </span>
2361 </div>
2538 </div>2362 </div>
2539 </div>2363 </div>
2540 {expanded && canExpand && expandedBody ? (2364 {expanded && canExpand && expandedBody ? (
2541 <div2365 <div
2542 className="mt-1 ms-[1.875rem] cursor-default pt-0.5"2366 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
2543 onClick={stopRowToggle}2367 onClick={stopRowToggle}
2544 onPointerDown={stopRowToggle}2368 onPointerDown={stopRowToggle}
2545 >2369 >
2546 <pre2370 <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">
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 >
2552 {expandedBody}2371 {expandedBody}
2553 </pre>2372 </pre>
2554 </div>2373 </div>
apps/web/src/components/chat/PanelLayoutControls.tsx+5-13
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
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";
5import { Toggle } from "../ui/toggle";4import { Toggle } from "../ui/toggle";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76
...@@ -13,7 +12,6 @@ interface PanelLayoutControlsProps {...@@ -13,7 +12,6 @@ interface PanelLayoutControlsProps {
13 rightPanelAvailable: boolean;12 rightPanelAvailable: boolean;
14 rightPanelOpen: boolean;13 rightPanelOpen: boolean;
15 rightPanelShortcutLabel: string | null;14 rightPanelShortcutLabel: string | null;
16 rightPanelUnavailableLabel?: string;
17 /** Running + waiting subagents in this thread; badges the right panel toggle. */15 /** Running + waiting subagents in this thread; badges the right panel toggle. */
18 liveAgentCount: number;16 liveAgentCount: number;
19 onToggleTerminal: () => void;17 onToggleTerminal: () => void;
...@@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
28 rightPanelAvailable,26 rightPanelAvailable,
29 rightPanelOpen,27 rightPanelOpen,
30 rightPanelShortcutLabel,28 rightPanelShortcutLabel,
31 rightPanelUnavailableLabel = "Right panel is unavailable",
32 liveAgentCount,29 liveAgentCount,
33 onToggleTerminal,30 onToggleTerminal,
34 onToggleRightPanel,31 onToggleRightPanel,
...@@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
43 <TooltipTrigger40 <TooltipTrigger
44 render={41 render={
45 <Toggle42 <Toggle
46 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"43 className="shrink-0 [-webkit-app-region:no-drag]"
47 pressed={terminalOpen}44 pressed={terminalOpen}
48 onPressedChange={onToggleTerminal}45 onPressedChange={onToggleTerminal}
49 aria-label="Toggle terminal drawer"46 aria-label="Toggle terminal drawer"
...@@ -66,7 +63,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -66,7 +63,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
66 <TooltipTrigger63 <TooltipTrigger
67 render={64 render={
68 <Toggle65 <Toggle
69 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag] disabled:opacity-100"66 className="shrink-0 [-webkit-app-region:no-drag]"
70 pressed={rightPanelOpen}67 pressed={rightPanelOpen}
71 onPressedChange={onToggleRightPanel}68 onPressedChange={onToggleRightPanel}
72 aria-label={69 aria-label={
...@@ -78,12 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -78,12 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
78 size="sm"75 size="sm"
79 disabled={!rightPanelAvailable}76 disabled={!rightPanelAvailable}
80 >77 >
81 <PanelRightIcon78 <PanelRightIcon className="size-4" />
82 className={cn(
83 "size-4",
84 !rightPanelAvailable && "text-muted-foreground opacity-100",
85 )}
86 />
87 {liveAgentCount > 0 ? (79 {liveAgentCount > 0 ? (
88 <span80 <span
89 aria-hidden81 aria-hidden
...@@ -102,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -102,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
102 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`94 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
103 : ""95 : ""
104 }`96 }`
105 : rightPanelUnavailableLabel}97 : "Right panel is unavailable"}
106 </TooltipPopup>98 </TooltipPopup>
107 </Tooltip>99 </Tooltip>
108 </div>100 </div>
...@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl...@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl
122 <TooltipTrigger114 <TooltipTrigger
123 render={115 render={
124 <Toggle116 <Toggle
125 className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]"117 className="shrink-0 [-webkit-app-region:no-drag]"
126 pressed={maximized}118 pressed={maximized}
127 onPressedChange={onToggle}119 onPressedChange={onToggle}
128 aria-label={label}120 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 h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] font-medium leading-none text-foreground align-middle";5 "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle";
66
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 =14export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85";
15 "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block";
1615
17export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";16export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
1817
19export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =18export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
20 "block self-center truncate leading-none select-none";
2119
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;20// The skill label is smaller than the surrounding prompt text; offset its
21// glyphs without moving the pill box or changing the editor's line height.
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`;
2323
24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
25 "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300";25 "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300";
2626
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+332-285
...@@ -25,7 +25,6 @@ import {...@@ -25,7 +25,6 @@ import {
25 GitPullRequestDraftIcon,25 GitPullRequestDraftIcon,
26 GitPullRequestIcon,26 GitPullRequestIcon,
27 HammerIcon,27 HammerIcon,
28 LayersIcon,
29 MessageCircleQuestionIcon,28 MessageCircleQuestionIcon,
30 MessageSquareIcon,29 MessageSquareIcon,
31 LinkIcon,30 LinkIcon,
...@@ -51,7 +50,6 @@ import {...@@ -51,7 +50,6 @@ import {
51import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";50import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
52import { useNewThreadHandler } from "~/hooks/useHandleNewThread";51import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
53import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";52import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
55import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";53import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
56import { cn } from "~/lib/utils";54import { cn } from "~/lib/utils";
57import { readLocalApi } from "~/localApi";55import { readLocalApi } from "~/localApi";
...@@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query";
62import { useLiveRefresh } from "~/hooks/useLiveRefresh";60import { useLiveRefresh } from "~/hooks/useLiveRefresh";
63import { pullRequestEnvironment } from "~/state/pullRequests";61import { pullRequestEnvironment } from "~/state/pullRequests";
64import { useAtomCommand } from "~/state/use-atom-command";62import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
66import { formatRelativeTimeLabel } from "~/timestampFormat";63import { formatRelativeTimeLabel } from "~/timestampFormat";
6764
68import {65import {
...@@ -77,7 +74,6 @@ import {...@@ -77,7 +74,6 @@ import {
77import { Badge } from "../ui/badge";74import { Badge } from "../ui/badge";
78import { Button } from "../ui/button";75import { Button } from "../ui/button";
79import { Input } from "../ui/input";76import { Input } from "../ui/input";
80import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";
81import {77import {
82 Menu,78 Menu,
83 MenuItem,79 MenuItem,
...@@ -120,7 +116,6 @@ import {...@@ -120,7 +116,6 @@ import {
120} from "./pullRequestProjectAssignment.logic";116} from "./pullRequestProjectAssignment.logic";
121import { PullRequestChecksPopover } from "./PullRequestChecksPopover";117import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
122import {118import {
123 PullRequestActorAvatar,
124 PullRequestActorLabel,119 PullRequestActorLabel,
125 PullRequestDiffStat,120 PullRequestDiffStat,
126 PullRequestMetaLine,121 PullRequestMetaLine,
...@@ -354,6 +349,7 @@ export function PullRequestDetailPanel({...@@ -354,6 +349,7 @@ export function PullRequestDetailPanel({
354 onClose,349 onClose,
355 onStateChange,350 onStateChange,
356 context = "page",351 context = "page",
352 chromeVariant = "full",
357 composerDraftTarget,353 composerDraftTarget,
358}: {354}: {
359 environmentId: EnvironmentId;355 environmentId: EnvironmentId;
...@@ -385,6 +381,12 @@ export function PullRequestDetailPanel({...@@ -385,6 +381,12 @@ export function PullRequestDetailPanel({
385 * again is at best a no-op and at worst git refusing a branch two checkouts.381 * again is at best a no-op and at worst git refusing a branch two checkouts.
386 */382 */
387 context?: "page" | "thread";383 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";
388 /**390 /**
389 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs391 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
390 * land here instead of opening a new thread — the branch is already under the reader's feet.392 * land here instead of opening a new thread — the branch is already under the reader's feet.
...@@ -421,13 +423,26 @@ export function PullRequestDetailPanel({...@@ -421,13 +423,26 @@ export function PullRequestDetailPanel({
421 );423 );
422 }, [tab]);424 }, [tab]);
423 const [chromeCondensed, setChromeCondensed] = useState(false);425 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.
424 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});431 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
425 useEffect(() => {432 useEffect(() => {
426 setChromeCondensed(chromeStateByTab.current[tab] ?? false);433 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
427 }, [tab]);434 }, [tab]);
428 const condensed = chromeCondensed;435 const condensed = chromeVariant === "collapse" && chromeCondensed;
436 // Collapsing removes the fold's height from the chrome, which would otherwise hand that
437 // height to the scrollport and leap the content up by it mid-scroll. The cure is exact
438 // compensation: collapse only once the reader has scrolled at least the fold's height,
439 // then give that height back to `scrollTop` before the next paint — the content under
440 // their eyes does not move, and the collapse itself is the only thing that changes.
429 const scrollerRef = useRef<HTMLElement | null>(null);441 const scrollerRef = useRef<HTMLElement | null>(null);
430 const foldRef = useRef<HTMLDivElement | null>(null);442 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.
431 const condensedRowRef = useRef<HTMLDivElement | null>(null);446 const condensedRowRef = useRef<HTMLDivElement | null>(null);
432 const compensationRef = useRef<number | null>(null);447 const compensationRef = useRef<number | null>(null);
433 useLayoutEffect(() => {448 useLayoutEffect(() => {
...@@ -448,6 +463,7 @@ export function PullRequestDetailPanel({...@@ -448,6 +463,7 @@ export function PullRequestDetailPanel({
448 target: "branch name",463 target: "branch name",
449 timeout: 1600,464 timeout: 1600,
450 });465 });
466
451 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be467 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
452 // clicked, so a reader who does click it lands on a chunk already in the module cache.468 // clicked, so a reader who does click it lands on a chunk already in the module cache.
453 useEffect(() => {469 useEffect(() => {
...@@ -486,30 +502,6 @@ export function PullRequestDetailPanel({...@@ -486,30 +502,6 @@ export function PullRequestDetailPanel({
486 },502 },
487 [activity, coreDetail],503 [activity, coreDetail],
488 );504 );
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);
513 const activityPending = activityQuery.isPending && activity === null;505 const activityPending = activityQuery.isPending && activity === null;
514 const activityError = activity === null ? activityQuery.error : null;506 const activityError = activity === null ? activityQuery.error : null;
515 const refreshDetail = useCallback(() => {507 const refreshDetail = useCallback(() => {
...@@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({...@@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({
1027 const can = (action: PullRequestAction) =>1019 const can = (action: PullRequestAction) =>
1028 detail?.capabilities.actions.includes(action) === true &&1020 detail?.capabilities.actions.includes(action) === true &&
1029 detail.viewerPermissions.actions.includes(action);1021 detail.viewerPermissions.actions.includes(action);
1030 // One live action holds the slot. Conflicts take priority because every other completion action1022 // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes
1031 // depends on resolving them first, even for a reader who cannot merge on the host themselves.1023 // to the thing that would help instead of a Merge button that only ever says no.
1032 const primaryAction =1024 const primaryAction =
1033 detail === null || detail.state !== "open"1025 detail === null || detail.state !== "open"
1034 ? null1026 ? null
1035 : conflicting1027 : detail.isDraft && can("ready")
1036 ? "resolve"1028 ? "ready"
1037 : detail.isDraft && can("ready")1029 : !can("merge")
1038 ? "ready"1030 ? null
1039 : !can("merge")1031 : conflicting
1040 ? null1032 ? "resolve"
1041 : allowedMergeMethods.length > 01033 : allowedMergeMethods.length > 0
1042 ? "merge"1034 ? "merge"
1043 : null;1035 : null;
1044 // The pull request number carries this state in the overview and the right-panel tab mirrors1036 // The pull request number carries this state in the overview and the right-panel tab mirrors
1045 // it. The conflict action is separate from this state: an open pull request remains green.1037 // it. Conflicts keep their own row below: an open pull request remains green there.
1046 const statePresentation = detail1038 const statePresentation = detail
1047 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })1039 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
1048 : null;1040 : null;
1049 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;1041 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
1050 const checksState = detail ? pullRequestChecksState(detail.checks) : null;1042 const checksState = detail ? pullRequestChecksState(detail.checks) : null;
10511043
1052 if (detailQuery.isPending && !detail) {
1053 return <PullRequestDetailGhost />;
1054 }
1055
1056 return (1044 return (
1057 <div className="flex h-full min-h-0 w-full flex-col bg-background">1045 <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. */}
1058 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">1049 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
1059 <div className="ml-4 grid h-7 min-w-0 items-center">1050 {/* The fixed height lives on the two top-row cells — not the grid, whose later rows
1051 are the fold — so the actions have one immovable home in both states. */}
1052 <div className="ml-4 grid h-11 min-w-0 items-center">
1060 <div1053 <div
1061 aria-hidden={condensed}1054 aria-hidden={condensed}
1062 inert={condensed}1055 inert={condensed}
1063 className={cn(1056 className={cn(
1064 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none",1057 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none",
1058 // Sequenced, not simultaneous: the leaving layer clears quickly before the
1059 // arriving one lands, so no frame shows both texts superimposed at half opacity.
1065 condensed1060 condensed
1066 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"1061 ? "pointer-events-none opacity-0 duration-100"
1067 : "translate-y-0 opacity-100 delay-50 duration-150",1062 : "opacity-100 delay-75 duration-150",
1068 )}1063 )}
1069 >1064 >
1070 {detail && statePresentation ? (1065 {detail && statePresentation ? (
1071 <>1066 <>
1072 {repositoryUrl ? (1067 <span className="min-w-0 truncate" title={detail.repository}>
1073 <button1068 {detail.repository}
1074 type="button"1069 </span>
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 )}
1086 <button1070 <button
1087 type="button"1071 type="button"
1088 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1072 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
...@@ -1103,10 +1087,10 @@ export function PullRequestDetailPanel({...@@ -1103,10 +1087,10 @@ export function PullRequestDetailPanel({
1103 aria-hidden={!condensed}1087 aria-hidden={!condensed}
1104 inert={!condensed}1088 inert={!condensed}
1105 className={cn(1089 className={cn(
1106 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none",1090 "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none",
1107 condensed1091 condensed
1108 ? "translate-y-0 opacity-100 delay-50 duration-150"1092 ? "opacity-100 delay-75 duration-150"
1109 : "pointer-events-none translate-y-1 opacity-0 duration-100",1093 : "pointer-events-none opacity-0 duration-100",
1110 )}1094 )}
1111 >1095 >
1112 {detail && statePresentation ? (1096 {detail && statePresentation ? (
...@@ -1125,14 +1109,30 @@ export function PullRequestDetailPanel({...@@ -1125,14 +1109,30 @@ export function PullRequestDetailPanel({
1125 >1109 >
1126 #{detail.number}1110 #{detail.number}
1127 </button>1111 </button>
1128 <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}>1112 <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
1129 {detail.title}1113 {detail.title}
1130 </h1>1114 </span>
1115 {conflicting ? (
1116 <Badge
1117 variant="error"
1118 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
1119 >
1120 <TriangleAlertIcon className="size-3" />
1121 Conflicts
1122 </Badge>
1123 ) : checksSummary ? (
1124 <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
1125 {detail && checksState !== null ? (
1126 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1127 ) : null}
1128 {checksSummary}
1129 </span>
1130 ) : null}
1131 </>1131 </>
1132 ) : null}1132 ) : null}
1133 </div>1133 </div>
1134 </div>1134 </div>
1135 <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">1135 <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
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="order-last size-6"1143 className="size-6"
1144 size="icon-xs"1144 size="icon-xs"
1145 variant="ghost-muted"1145 variant="ghost-muted"
1146 />1146 />
...@@ -1312,15 +1312,16 @@ export function PullRequestDetailPanel({...@@ -1312,15 +1312,16 @@ export function PullRequestDetailPanel({
1312 <MenuTrigger1312 <MenuTrigger
1313 disabled={handoff !== null}1313 disabled={handoff !== null}
1314 render={1314 render={
1315 <Button size="xs" variant="outline" className="gap-0 p-0">1315 <Button size="xs" variant="outline">
1316 <span className="inline-flex h-full items-center gap-1.5 ps-[8.5px] pe-2">1316 {handoff?.startsWith("checkout") ? (
1317 <GitBranchIcon aria-hidden className="size-3.5" />1317 "Checking out..."
1318 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}1318 ) : (
1319 </span>1319 <>
1320 <span aria-hidden className="h-4 w-px shrink-0 bg-input" />1320 <GitBranchIcon className="size-3" />
1321 <span className="inline-flex h-full w-6 shrink-0 items-center justify-center">1321 Check out
1322 <ChevronDownIcon aria-hidden className="size-4 text-muted-foreground" />1322 <ChevronDownIcon className="size-3 text-muted-foreground" />
1323 </span>1323 </>
1324 )}
1324 </Button>1325 </Button>
1325 }1326 }
1326 />1327 />
...@@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({...@@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({
1366 Auto-merge1367 Auto-merge
1367 </Badge>1368 </Badge>
1368 ) : null}1369 ) : null}
1369 {primaryAction === "resolve" ? (1370 {primaryAction === "ready" ? (
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" ? (
1385 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>1371 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
1386 Ready for review1372 Ready for review
1387 </Button>1373 </Button>
...@@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({...@@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({
1408 ) : null}1394 ) : null}
1409 </div>1395 </div>
14101396
1411 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>1397 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,
1398 and compact copies of the branch pair and diff stat so they stay in sight while
1399 the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
1400 <div
1401 className={cn(
1402 "col-span-2 grid",
1403 // Inverse of the fold's track: closing eases while the fold above eases open,
1404 // so the chrome trades rows in one motion instead of two jumps.
1405 condensed
1406 ? "grid-rows-[1fr]"
1407 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1408 )}
1409 >
1412 <div1410 <div
1413 ref={condensedRowRef}1411 ref={condensedRowRef}
1414 className={cn(1412 className={cn(
1415 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",1413 "min-h-0 overflow-hidden",
1416 condensed1414 condensed
1417 ? "translate-y-0 opacity-100 delay-50"1415 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
1418 : "translate-y-1 opacity-0 duration-100",1416 : "opacity-0",
1419 )}1417 )}
1420 inert={!condensed}1418 inert={!condensed}
1421 >1419 >
1422 {detail ? (1420 {detail ? (
1423 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">1421 <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
1424 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">1422 <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
1425 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">1423 {visibleTabs.map((item) => (
1426 <PullRequestActorAvatar actor={detail.author} className="shrink-0" />1424 <button
1427 <span className="sr-only">{detail.author?.login ?? "ghost"}</span>1425 key={item.value}
1428 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>1426 type="button"
1429 </span>1427 tabIndex={condensed ? 0 : -1}
1430 <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />1428 aria-pressed={tab === item.value}
1431 <span1429 onClick={() => setTab(item.value)}
1432 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65"1430 className={cn(
1433 title={`${detail.baseBranch} ← ${detail.headBranch}`}1431 "rounded-md px-2 py-1 text-[11px] transition-colors",
1434 >1432 tab === item.value
1435 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">1433 ? "bg-accent text-foreground"
1436 {isStackedPullRequest ? (1434 : "text-muted-foreground hover:text-foreground",
1437 <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" />1435 )}
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 }`}
1462 >1436 >
1463 <FileDiffIcon aria-hidden className="size-3" />1437 {item.label}
1464 {detail.changedFiles.toLocaleString()}1438 </button>
1465 </span>1439 ))}
1466 <PullRequestDiffStat1440 </nav>
1467 additions={detail.additions}1441 <span
1468 deletions={detail.deletions}1442 className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"
1469 className="shrink-0 font-mono text-[11px]"1443 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1444 >
1445 <span className="truncate">{detail.baseBranch}</span>
1446 {freshness ? (
1447 <PullRequestBaseFreshnessWarning
1448 baseBranch={detail.baseBranch}
1449 freshness={freshness}
1450 pending={actionPending}
1451 onUpdate={(method) => void perform("update-branch", undefined, method)}
1452 iconClassName="size-3"
1470 />1453 />
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()}
1471 </span>1462 </span>
1472 </div>1463 <PullRequestDiffStat
1464 additions={detail.additions}
1465 deletions={detail.deletions}
1466 className="shrink-0 font-mono text-[11px]"
1467 />
1468 </span>
1473 </div>1469 </div>
1474 ) : null}1470 ) : null}
1475 </div>1471 </div>
1476 </div>1472 </div>
14771473
1478 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}>1474 {/* Folding is a grid track going to zero: the rows below stay mounted, the track
1475 animates closed over them, and `inert` takes the hidden controls out of the tab
1476 order for as long as the chrome is condensed. */}
1477 <div
1478 className={cn(
1479 "col-span-2 grid",
1480 // Collapse is instant: it happens mid-scroll, and the compensation that keeps the
1481 // content pinned would fight an animated track frame by frame. Reopening happens
1482 // at the top with no compensation, so the fold can ease back in instead of
1483 // snapping the content down.
1484 condensed
1485 ? "grid-rows-[0fr]"
1486 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1487 )}
1488 >
1479 <div1489 <div
1480 ref={foldRef}1490 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.
1481 className={cn(1494 className={cn(
1482 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",1495 "min-h-0 overflow-hidden",
1483 condensed1496 condensed
1484 ? "-translate-y-1 opacity-0 duration-100"1497 ? "opacity-0"
1485 : "translate-y-0 opacity-100 delay-50",1498 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",
1486 )}1499 )}
1487 inert={condensed}1500 inert={condensed}
1488 >1501 >
1489 {detail ? (1502 {detail ? (
1490 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">1503 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">
1491 {titleDraft === null ? (1504 {titleDraft === null ? (
1492 <div className="group flex min-w-0 items-start gap-1">1505 <div className="group flex min-w-0 items-start gap-1">
1493 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">1506 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
...@@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({...@@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({
1554 </PullRequestMetaLine>1567 </PullRequestMetaLine>
15551568
1556 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">1569 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1557 <span1570 <code
1558 className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"1571 className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"
1559 title={`${detail.baseBranch} ← ${detail.headBranch}`}1572 title={detail.baseBranch}
1560 >1573 >
1561 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">1574 {detail.baseBranch}
1562 {isStackedPullRequest ? (1575 </code>
1563 <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" />1576 {freshness ? (
1564 ) : null}1577 <PullRequestBaseFreshnessWarning
1565 <code className="min-w-0 truncate">{detail.baseBranch}</code>1578 baseBranch={detail.baseBranch}
1566 </span>1579 freshness={freshness}
1567 {freshness ? (1580 pending={actionPending}
1568 <PullRequestBaseFreshnessWarning1581 onUpdate={(method) => void perform("update-branch", undefined, method)}
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"
1578 />1582 />
1579 <button1583 ) : null}
1580 type="button"1584 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1581 className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"1585 <button
1582 aria-label={1586 type="button"
1583 isBranchCopied ? "Branch name copied" : "Copy pull request branch"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"
1584 }1588 aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"}
1585 title={isBranchCopied ? "Copied" : "Copy pull request branch"}1589 title={isBranchCopied ? "Copied" : "Copy pull request branch"}
1586 onClick={() => copyBranchToClipboard(detail.headBranch)}1590 onClick={() => copyBranchToClipboard(detail.headBranch)}
1591 >
1592 <code
1593 className={cn(
1594 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1595 isBranchCopied ? "opacity-0" : "opacity-100",
1596 )}
1597 title={detail.headBranch}
1587 >1598 >
1588 <code1599 {detail.headBranch}
1589 className={cn(1600 </code>
1590 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",1601 <span
1591 isBranchCopied ? "opacity-0" : "opacity-100",1602 aria-hidden="true"
1592 )}1603 className={cn(
1593 >1604 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1594 {detail.headBranch}1605 isBranchCopied ? "opacity-100" : "opacity-0",
1595 </code>1606 )}
1596 <span1607 >
1597 aria-hidden="true"1608 Copied
1598 className={cn(1609 </span>
1599 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",1610 </button>
1600 isBranchCopied ? "opacity-100" : "opacity-0",
1601 )}
1602 >
1603 Copied
1604 </span>
1605 </button>
1606 </span>
1607 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">1611 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
1608 <span className="inline-flex items-center gap-1.5 tabular-nums">1612 <span className="inline-flex items-center gap-1.5 tabular-nums">
1609 <FileDiffIcon className="size-3.5" />1613 <FileDiffIcon className="size-3.5" />
...@@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({...@@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({
1619 </div>1623 </div>
1620 </div>1624 </div>
1621 ) : null}1625 ) : null}
1622 </div>
1623 </div>
16241626
1625 {detail ? (1627 {detail && conflicting ? (
1626 <nav1628 <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
1627 className={cn(1629 <Badge
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",1630 variant="error"
1629 condensed ? "py-1.5" : "py-2",1631 className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
1630 )}
1631 aria-label="Pull request tabs"
1632 >
1633 <SegmentedTabList>
1634 {visibleTabs.map((item) => (
1635 <SegmentedTab
1636 key={item.value}
1637 selected={tab === item.value}
1638 onClick={() => setTab(item.value)}
1639 >
1640 {item.label}
1641 </SegmentedTab>
1642 ))}
1643 </SegmentedTabList>
1644 {tab === "summary" ? (
1645 <span
1646 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1647 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1648 >
1649 {checksState !== null ? (
1650 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1651 ) : (
1652 <CircleDotIcon aria-hidden className="size-3.5" />
1653 )}
1654 {checksSummary}
1655 </span>
1656 ) : tab === "timeline" ? (
1657 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1658 <PullRequestMetaLine
1659 className={cn(
1660 "whitespace-nowrap text-[11px] transition-opacity",
1661 (activityPending || activityError) && "opacity-35",
1662 )}
1663 >1632 >
1664 <span1633 <TriangleAlertIcon className="size-3.5" />
1665 className="inline-flex items-center gap-1"1634 Merge conflicts
1666 aria-label={1635 </Badge>
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>
1699 <Button1636 <Button
1700 size="xs"1637 size="xs"
1701 variant="ghost"1638 variant="ghost"
1702 className="h-7 px-2 text-[10px] text-muted-foreground"1639 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"
1703 aria-label={1640 disabled={handoff !== null}
1704 timelineOrder === "newest"1641 onClick={startResolveConflicts}
1705 ? "Show oldest activity first"
1706 : "Show newest activity first"
1707 }
1708 onClick={() =>
1709 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1710 }
1711 >1642 >
1712 <ArrowDownUpIcon aria-hidden className="size-3" />1643 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}
1713 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}1644 <ArrowUpRightIcon className="size-3.5 text-destructive" />
1714 </Button>1645 </Button>
1715 </div>1646 </div>
1716 ) : null}1647 ) : null}
1717 </nav>1648
1718 ) : null}1649 {detail ? (
1650 <nav
1651 className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1652 aria-label="Pull request tabs"
1653 >
1654 {visibleTabs.map((item) => (
1655 <button
1656 key={item.value}
1657 type="button"
1658 aria-pressed={tab === item.value}
1659 onClick={() => setTab(item.value)}
1660 className={cn(
1661 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",
1662 tab === item.value
1663 ? "bg-accent text-foreground"
1664 : "text-muted-foreground hover:text-foreground",
1665 )}
1666 >
1667 {item.label}
1668 </button>
1669 ))}
1670 {tab === "summary" ? (
1671 <span
1672 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1673 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1674 >
1675 {/* The rollup icon opens the checks behind the summary; with none reported
1676 there is nothing to open, so the plain glyph stays. */}
1677 {detail && checksState !== null ? (
1678 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1679 ) : (
1680 <CircleDotIcon aria-hidden className="size-3.5" />
1681 )}
1682 {checksSummary}
1683 </span>
1684 ) : tab === "timeline" ? (
1685 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1686 <PullRequestMetaLine
1687 className={cn(
1688 "whitespace-nowrap text-[11px] transition-opacity",
1689 (activityPending || activityError) && "opacity-35",
1690 )}
1691 >
1692 <span
1693 className="inline-flex items-center gap-1"
1694 aria-label={
1695 activityError
1696 ? "Comments unavailable"
1697 : `${detail.commentCount.toLocaleString()} ${
1698 detail.commentCount === 1 ? "comment" : "comments"
1699 }`
1700 }
1701 >
1702 <MessageSquareIcon aria-hidden className="size-3" />
1703 {activityError
1704 ? "—"
1705 : activityPending
1706 ? "…"
1707 : detail.commentCount.toLocaleString()}
1708 </span>
1709 <span
1710 className="inline-flex items-center gap-1"
1711 aria-label={
1712 activityError
1713 ? "Commits unavailable"
1714 : `${detail.commits.length.toLocaleString()} ${
1715 detail.commits.length === 1 ? "commit" : "commits"
1716 }`
1717 }
1718 >
1719 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1720 {activityError
1721 ? "—"
1722 : activityPending
1723 ? "…"
1724 : detail.commits.length.toLocaleString()}
1725 </span>
1726 </PullRequestMetaLine>
1727 <Button
1728 size="xs"
1729 variant="ghost"
1730 className="h-7 px-2 text-[10px] text-muted-foreground"
1731 aria-label={
1732 timelineOrder === "newest"
1733 ? "Show oldest activity first"
1734 : "Show newest activity first"
1735 }
1736 onClick={() =>
1737 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1738 }
1739 >
1740 <ArrowDownUpIcon aria-hidden className="size-3" />
1741 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1742 </Button>
1743 </div>
1744 ) : null}
1745 </nav>
1746 ) : null}
1747 </div>
1748 </div>
1719 </div>1749 </div>
17201750
1721 <div1751 <div
1722 className="relative min-h-0 flex-1 overflow-hidden"1752 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.
1723 onScrollCapture={(event) => {1756 onScrollCapture={(event) => {
1757 if (chromeVariant !== "collapse") return;
1724 const scroller = event.target as HTMLElement;1758 const scroller = event.target as HTMLElement;
1725 scrollerRef.current = scroller;1759 scrollerRef.current = scroller;
1726 const top = scroller.scrollTop;1760 const top = scroller.scrollTop;
1727 setChromeCondensed((previous) => {1761 setChromeCondensed((previous) => {
1728 let next = previous;1762 let next = previous;
1763 // `scrollHeight` reads the fold's natural height whichever state the track is in.
1729 const foldHeight = foldRef.current?.scrollHeight ?? 0;1764 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.
1730 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);1767 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
1731 if (previous) {1768 if (previous) {
1732 // The hard top reopens the chrome with no refund: the reader asked for the top,1769 // The hard top reopens the chrome with no refund: the reader asked for the top,
...@@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({...@@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({
1744 });1781 });
1745 }}1782 }}
1746 >1783 >
1747 {detailQuery.error && !detail ? (1784 {detailQuery.isPending && !detail ? (
1785 // The ghost wears the shape of the tab being waited on, so switching tabs mid-load
1786 // does not flash a summary outline under a timeline heading.
1787 tab === "timeline" ? (
1788 <PullRequestTimelineGhost />
1789 ) : tab === "code" ? (
1790 <DiffPanelLoadingState label="Loading pull request diff..." />
1791 ) : (
1792 <PullRequestDetailGhost />
1793 )
1794 ) : detailQuery.error && !detail ? (
1748 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />1795 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
1749 ) : detail ? (1796 ) : detail ? (
1750 <>1797 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+24-91
...@@ -45,11 +45,13 @@ export function PullRequestListGhost({...@@ -45,11 +45,13 @@ 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 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />48 <GhostBar
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
49 </div>51 </div>
50 <div className="flex flex-col items-end gap-1.5">52 <div className="flex flex-col items-end gap-1.5">
51 <GhostBar className="w-12" />53 <GhostBar className="w-12" />
52 <GhostBar className="w-16" />54 <GhostBar className="w-16 bg-muted-foreground/10" />
53 </div>55 </div>
54 </div>56 </div>
55 ))}57 ))}
...@@ -57,101 +59,32 @@ export function PullRequestListGhost({...@@ -57,101 +59,32 @@ export function PullRequestListGhost({
57 );59 );
58}60}
5961
60/**62/** The summary's own shape: a title, a byline, the facts rows, the description. */
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 */
65export function PullRequestDetailGhost() {63export function PullRequestDetailGhost() {
66 return (64 return (
67 <div65 <div
68 role="status"66 role="status"
69 aria-label="Loading pull request"67 aria-label="Loading pull request"
70 className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background"68 className="animate-ghost-pulse space-y-6 px-4 py-5"
71 >69 >
72 <div className="shrink-0 border-b border-border/60">70 <div className="space-y-2">
73 <div className="flex h-7 items-center justify-between gap-3 px-4">71 <GhostBar className="h-5 w-4/5" />
74 <div className="flex min-w-0 flex-1 items-center gap-1.5">72 <GhostBar className="w-2/5 bg-muted-foreground/10" />
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>
109 </div>73 </div>
11074 <div className="space-y-3">
111 <div className="min-h-0 flex-1 overflow-hidden">75 {Array.from({ length: 4 }, (_, index) => (
112 <section className="px-4 py-3">76 <div key={index} className="flex items-center gap-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" />
146 <GhostBar className="size-3.5 rounded-full" />77 <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]} />
147 </div>80 </div>
148 <div className="space-y-2 px-4 pb-4">81 ))}
149 <GhostBar className="w-full" />82 </div>
150 <GhostBar className="w-11/12" />83 <div className="space-y-2 pt-1">
151 <GhostBar className="w-4/5" />84 <GhostBar className="w-full bg-muted-foreground/10" />
152 <GhostBar className="w-2/3" />85 <GhostBar className="w-11/12 bg-muted-foreground/10" />
153 </div>86 <GhostBar className="w-4/5 bg-muted-foreground/10" />
154 </section>87 <GhostBar className="w-2/3 bg-muted-foreground/10" />
155 </div>88 </div>
156 </div>89 </div>
157 );90 );
...@@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {...@@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
180 <div key={index} className="relative pb-5">113 <div key={index} className="relative pb-5">
181 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />114 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
182 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />115 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
183 <GhostBar className="mt-1.5 w-16" />116 <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" />
184 </div>117 </div>
185 ))}118 ))}
186 </div>119 </div>
...@@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {...@@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
201 <GhostBar className="size-5 shrink-0 rounded-full" />134 <GhostBar className="size-5 shrink-0 rounded-full" />
202 <div className="flex-1 space-y-1.5">135 <div className="flex-1 space-y-1.5">
203 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />136 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
204 <GhostBar className="w-full" />137 <GhostBar className="w-full bg-muted-foreground/10" />
205 <GhostBar className="w-3/4" />138 <GhostBar className="w-3/4 bg-muted-foreground/10" />
206 </div>139 </div>
207 </div>140 </div>
208 ))}141 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+6-9
...@@ -25,7 +25,6 @@ import { cn } from "~/lib/utils";...@@ -25,7 +25,6 @@ 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";
2928
30import {29import {
31 Menu,30 Menu,
...@@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({...@@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({
262 return (261 return (
263 <Menu>262 <Menu>
264 <MenuTrigger263 <MenuTrigger
265 render={264 className={cn(
266 <Button265 // The icon-button size that pairs with a full-height input, so the two read as one strip.
267 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}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",
268 size="icon"267 filtered && "text-foreground",
269 variant="outline"268 )}
270 aria-label="Filter pull requests"269 aria-label="Filter pull requests"
271 />
272 }
273 >270 >
274 <ListFilterIcon className="size-4" />271 <ListFilterIcon className="size-4" />
275 {filtered ? (272 {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="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">199 <div className="flex items-center gap-2 py-1.5 text-xs">
200 <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">200 <span className="flex w-24 shrink-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 text-foreground">{children}</span>204 <span className="min-w-0 flex-1 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 width="expanded" className="gap-10">995 <SettingsPageContainer className="max-w-6xl 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 width="wide">1198 <SettingsPageContainer className="max-w-5xl">
1199 <SettingsSection1199 <SettingsSection
1200 {...searchableSetting("keybindings")}1200 {...searchableSetting("keybindings")}
1201 headerAction={1201 headerAction={
apps/web/src/components/settings/ProjectSettingsPanel.tsx+22-4
...@@ -81,6 +81,8 @@ import {...@@ -81,6 +81,8 @@ 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";
84import { Button } from "../ui/button";86import { Button } from "../ui/button";
85import { Input } from "../ui/input";87import { Input } from "../ui/input";
86import {88import {
...@@ -100,7 +102,6 @@ import {...@@ -100,7 +102,6 @@ import {
100 WorkspaceBreadcrumbItem,102 WorkspaceBreadcrumbItem,
101 WorkspaceBreadcrumbSeparator,103 WorkspaceBreadcrumbSeparator,
102} from "../WorkspaceBreadcrumb";104} from "../WorkspaceBreadcrumb";
103import { WorkspacePageHeader } from "../WorkspacePageContainer";
104import {105import {
105 SettingResetButton,106 SettingResetButton,
106 SettingsPageContainer,107 SettingsPageContainer,
...@@ -173,9 +174,26 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {...@@ -173,9 +174,26 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
173 return (174 return (
174 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">175 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
175 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">176 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
176 <WorkspacePageHeader electron={isElectron}>177 {!isElectron && (
177 <ProjectSettingsBreadcrumb projectKey={projectKey} />178 <header
178 </WorkspacePageHeader>179 className={cn(
180 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
181 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
182 )}
183 >
184 <ProjectSettingsBreadcrumb projectKey={projectKey} />
185 </header>
186 )}
187 {isElectron && (
188 <div
189 className={cn(
190 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
191 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
192 )}
193 >
194 <ProjectSettingsBreadcrumb projectKey={projectKey} />
195 </div>
196 )}
179 <ProjectSettingsPanel projectKey={projectKey} />197 <ProjectSettingsPanel projectKey={projectKey} />
180 </div>198 </div>
181 </SidebarInset>199 </SidebarInset>
apps/web/src/components/settings/SettingsSidebarNav.tsx+22-3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "react";9} from "react";
10import {10import {
11 ArchiveIcon,11 ArchiveIcon,
12 ArrowLeftIcon,
12 BotIcon,13 BotIcon,
13 GitBranchIcon,14 GitBranchIcon,
14 KeyboardIcon,15 KeyboardIcon,
...@@ -18,7 +19,7 @@ import {...@@ -18,7 +19,7 @@ import {
18 Settings2Icon,19 Settings2Icon,
19 XIcon,20 XIcon,
20} from "lucide-react";21} from "lucide-react";
21import { useLocation, useNavigate } from "@tanstack/react-router";22import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
2223
23import { Button } from "../ui/button";24import { Button } from "../ui/button";
24import { Input } from "../ui/input";25import { Input } from "../ui/input";
...@@ -33,7 +34,6 @@ import {...@@ -33,7 +34,6 @@ import {
33 useSidebar,34 useSidebar,
34} from "../ui/sidebar";35} from "../ui/sidebar";
35import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";36import { 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,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {...@@ -72,6 +72,7 @@ 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();
75 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();76 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
76 const searchInputRef = useRef<HTMLInputElement>(null);77 const searchInputRef = useRef<HTMLInputElement>(null);
77 const [query, setQuery] = useState("");78 const [query, setQuery] = useState("");
...@@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
175 },176 },
176 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],177 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
177 );178 );
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
178 return (190 return (
179 <>191 <>
180 <SidebarContent className="overflow-x-hidden">192 <SidebarContent className="overflow-x-hidden">
...@@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
284 <SidebarFooter className="p-[var(--sidebar-content-inset)]">296 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
285 <T3ConnectSidebarSignIn />297 <T3ConnectSidebarSignIn />
286 <div className="flex items-center gap-1">298 <div className="flex items-center gap-1">
287 <SidebarUtilityMenu />299 <SidebarMenu className="min-w-0 flex-1">
300 <SidebarMenuItem>
301 <SidebarMenuButton onClick={handleBackClick}>
302 <ArrowLeftIcon />
303 <span>Back</span>
304 </SidebarMenuButton>
305 </SidebarMenuItem>
306 </SidebarMenu>
288 <T3ConnectSidebarAvatar />307 <T3ConnectSidebarAvatar />
289 </div>308 </div>
290 </SidebarFooter>309 </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%, 16rem), 1fr))" }}771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 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+3-6
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12} from "react";12} from "react";
1313
14import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
16import { Button } from "../ui/button";15import { Button } from "../ui/button";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1817
...@@ -234,11 +233,9 @@ export function SettingResetButton({...@@ -234,11 +233,9 @@ export function SettingResetButton({
234export function SettingsPageContainer({233export function SettingsPageContainer({
235 children,234 children,
236 className,235 className,
237 width = "readable",
238}: {236}: {
239 children: ReactNode;237 children: ReactNode;
240 className?: string;238 className?: string;
241 width?: WorkspacePageWidth;
242}) {239}) {
243 const navigate = useNavigate();240 const navigate = useNavigate();
244 const hash = useLocation({ select: (location) => location.hash });241 const hash = useLocation({ select: (location) => location.hash });
...@@ -250,12 +247,12 @@ export function SettingsPageContainer({...@@ -250,12 +247,12 @@ export function SettingsPageContainer({
250 return (247 return (
251 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>248 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
252 <div249 <div
253 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]"250 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto px-4 pt-10 pb-7 sm:px-8 sm:pt-12 sm:pb-10"
254 data-settings-page-scroll251 data-settings-page-scroll
255 >252 >
256 <WorkspacePageContainer width={width} className={cn("gap-12", className)}>253 <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}>
257 {children}254 {children}
258 </WorkspacePageContainer>255 </div>
259 </div>256 </div>
260 </SettingsSearchTargetProvider>257 </SettingsSearchTargetProvider>
261 );258 );
apps/web/src/components/sidebar/SidebarChrome.tsx+68-78
...@@ -4,9 +4,8 @@ import {...@@ -4,9 +4,8 @@ import {
4 GitPullRequestIcon,4 GitPullRequestIcon,
5 SettingsIcon,5 SettingsIcon,
6} from "lucide-react";6} from "lucide-react";
7import type { ReactNode } from "react";
8import { memo, useCallback } from "react";7import { memo, useCallback } from "react";
9import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";8import { Link, useLocation, useNavigate } from "@tanstack/react-router";
109
11import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
12import { cn } from "../../lib/utils";11import { cn } from "../../lib/utils";
...@@ -118,44 +117,16 @@ function T3Wordmark() {...@@ -118,44 +117,16 @@ function T3Wordmark() {
118 );117 );
119}118}
120119
121function SidebarUtilityItem({120export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
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() {
147 const navigate = useNavigate();121 const navigate = useNavigate();
148 const canGoBack = useCanGoBack();
149 const { isMobile, setOpenMobile } = useSidebar();122 const { isMobile, setOpenMobile } = useSidebar();
150 const currentFooterPage = useLocation({123 const currentFooterPage = useLocation({
151 select: (location) =>124 select: (location) =>
152 /^\/settings(?:\/|$)/.test(location.pathname)125 location.pathname === "/usage"
153 ? "settings"126 ? "usage"
154 : location.pathname === "/usage"127 : location.pathname === "/pull-requests"
155 ? "usage"128 ? "pull-requests"
156 : location.pathname === "/pull-requests"129 : null,
157 ? "pull-requests"
158 : null,
159 });130 });
160 const { environments } = useEnvironments();131 const { environments } = useEnvironments();
161 // The page reads every connected server, so one of them offering pull requests is enough for132 // The page reads every connected server, so one of them offering pull requests is enough for
...@@ -186,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -186,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
186157
187 const handleBackClick = useCallback(() => {158 const handleBackClick = useCallback(() => {
188 closeMobileSidebar();159 closeMobileSidebar();
189 if (canGoBack) {
190 window.history.back();
191 return;
192 }
193 void navigate({ to: "/" });160 void navigate({ to: "/" });
194 }, [canGoBack, closeMobileSidebar, navigate]);161 }, [closeMobileSidebar, navigate]);
195
196 return (
197 <SidebarMenu className="flex-row items-center">
198 {currentFooterPage ? (
199 <SidebarMenuItem className="min-w-0 flex-1">
200 <SidebarMenuButton onClick={handleBackClick}>
201 <ArrowLeftIcon />
202 <span>Back</span>
203 </SidebarMenuButton>
204 </SidebarMenuItem>
205 ) : (
206 <>
207 <SidebarUtilityItem
208 icon={<SettingsIcon />}
209 label="Settings"
210 onClick={handleSettingsClick}
211 />
212 {pullRequestsSupported ? (
213 <SidebarUtilityItem
214 icon={<GitPullRequestIcon />}
215 label="Pull Requests"
216 onClick={handlePullRequestsClick}
217 />
218 ) : null}
219 <SidebarUtilityItem
220 icon={<ChartNoAxesColumnIcon />}
221 label="Usage"
222 onClick={handleUsageClick}
223 />
224 </>
225 )}
226 <SidebarUpdatePill />
227 </SidebarMenu>
228 );
229});
230162
231export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
232 return (163 return (
233 <SidebarFooter className="p-[var(--sidebar-content-inset)]">164 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
234 <SidebarProviderUpdatePill />165 <SidebarProviderUpdatePill />
235 <SidebarUpdateArchitectureWarning />166 <SidebarUpdateArchitectureWarning />
236 <SidebarUtilityMenu />167 <SidebarMenu className="flex-row items-center">
168 {currentFooterPage ? (
169 <SidebarMenuItem className="min-w-0 flex-1">
170 <SidebarMenuButton onClick={handleBackClick}>
171 <ArrowLeftIcon />
172 <span>Back</span>
173 </SidebarMenuButton>
174 </SidebarMenuItem>
175 ) : (
176 <>
177 <SidebarMenuItem className="shrink-0">
178 <Tooltip>
179 <TooltipTrigger
180 render={
181 <SidebarMenuButton
182 aria-label="Settings"
183 onClick={handleSettingsClick}
184 size="icon"
185 >
186 <SettingsIcon />
187 </SidebarMenuButton>
188 }
189 />
190 <TooltipPopup side="top">Settings</TooltipPopup>
191 </Tooltip>
192 </SidebarMenuItem>
193 {pullRequestsSupported ? (
194 <SidebarMenuItem className="shrink-0">
195 <Tooltip>
196 <TooltipTrigger
197 render={
198 <SidebarMenuButton
199 aria-label="Pull Requests"
200 onClick={handlePullRequestsClick}
201 size="icon"
202 >
203 <GitPullRequestIcon />
204 </SidebarMenuButton>
205 }
206 />
207 <TooltipPopup side="top">Pull Requests</TooltipPopup>
208 </Tooltip>
209 </SidebarMenuItem>
210 ) : null}
211 <SidebarMenuItem className="shrink-0">
212 <Tooltip>
213 <TooltipTrigger
214 render={
215 <SidebarMenuButton aria-label="Usage" onClick={handleUsageClick} size="icon">
216 <ChartNoAxesColumnIcon />
217 </SidebarMenuButton>
218 }
219 />
220 <TooltipPopup side="top">Usage</TooltipPopup>
221 </Tooltip>
222 </SidebarMenuItem>
223 </>
224 )}
225 <SidebarUpdatePill />
226 </SidebarMenu>
237 </SidebarFooter>227 </SidebarFooter>
238 );228 );
239});229});
apps/web/src/components/threadActionMenu.logic.test.ts+4-10
...@@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] {...@@ -19,12 +19,6 @@ 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
28describe("buildThreadActionMenuItems", () => {22describe("buildThreadActionMenuItems", () => {
29 it("hides lifecycle items when the environment lacks the capabilities", () => {23 it("hides lifecycle items when the environment lacks the capabilities", () => {
30 expect(24 expect(
...@@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => {...@@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => {
32 ...baseState,26 ...baseState,
33 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },27 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
34 }),28 }),
35 ).toEqual(["rename", "mark-unread", "copy", "delete"]);29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);
36 });30 });
3731
38 it("includes branch items only for threads with a branch", () => {32 it("includes branch items only for threads with a branch", () => {
39 const withBranch = allIds({ ...baseState, branch: "feat/menu" });33 const withBranch = ids({ ...baseState, branch: "feat/menu" });
40 expect(withBranch).toContain("new-thread-on-branch");34 expect(withBranch).toContain("new-thread-on-branch");
41 expect(withBranch).toContain("copy-branch");35 expect(withBranch).toContain("copy-branch");
42 expect(allIds(baseState)).not.toContain("new-thread-on-branch");36 expect(ids(baseState)).not.toContain("new-thread-on-branch");
43 expect(allIds(baseState)).not.toContain("copy-branch");37 expect(ids(baseState)).not.toContain("copy-branch");
44 });38 });
4539
46 it("flips lifecycle labels with thread state", () => {40 it("flips lifecycle labels with thread state", () => {
apps/web/src/components/threadActionMenu.logic.ts+11-31
...@@ -18,7 +18,6 @@ export type ThreadActionMenuId =...@@ -18,7 +18,6 @@ export type ThreadActionMenuId =
18 | "rename"18 | "rename"
19 | "regenerate-title"19 | "regenerate-title"
20 | "mark-unread"20 | "mark-unread"
21 | "copy"
22 | "copy-path"21 | "copy-path"
23 | "copy-branch"22 | "copy-branch"
24 | "copy-thread-id"23 | "copy-thread-id"
...@@ -54,15 +53,14 @@ export function buildThreadActionMenuItems(...@@ -54,15 +53,14 @@ export function buildThreadActionMenuItems(
54 {53 {
55 id: "new-thread-on-branch" as const,54 id: "new-thread-on-branch" as const,
56 label: `New thread on ${state.branch}`,55 label: `New thread on ${state.branch}`,
57 icon: "message-square-plus",
58 },56 },
59 ]57 ]
60 : []),58 : []),
61 ...(state.supports.pinning59 ...(state.supports.pinning
62 ? [60 ? [
63 state.isPinned61 state.isPinned
64 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }62 ? { id: "unpin" as const, label: "Unpin thread" }
65 : { id: "pin" as const, label: "Pin thread", icon: "pin" },63 : { id: "pin" as const, label: "Pin thread" },
66 ]64 ]
67 : []),65 : []),
68 // Both lifecycle actions stay available on pinned threads: settling66 // Both lifecycle actions stay available on pinned threads: settling
...@@ -71,18 +69,17 @@ export function buildThreadActionMenuItems(...@@ -71,18 +69,17 @@ export function buildThreadActionMenuItems(
71 ...(state.supports.settlement69 ...(state.supports.settlement
72 ? [70 ? [
73 state.isSettled71 state.isSettled
74 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }72 ? { id: "unsettle" as const, label: "Un-settle thread" }
75 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },73 : { id: "settle" as const, label: "Settle thread" },
76 ]74 ]
77 : []),75 : []),
78 ...(state.supports.snooze76 ...(state.supports.snooze
79 ? [77 ? [
80 state.isSnoozed78 state.isSnoozed
81 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }79 ? { id: "unsnooze" as const, label: "Wake thread" }
82 : {80 : {
83 id: "snooze" as const,81 id: "snooze" as const,
84 label: "Snooze",82 label: "Snooze",
85 icon: "clock",
86 disabled: !state.canSnoozeNow,83 disabled: !state.canSnoozeNow,
87 children: state.snoozePresets.map((preset) => ({84 children: state.snoozePresets.map((preset) => ({
88 id: `snooze:${preset.id}` as const,85 id: `snooze:${preset.id}` as const,
...@@ -91,37 +88,20 @@ export function buildThreadActionMenuItems(...@@ -91,37 +88,20 @@ export function buildThreadActionMenuItems(
91 },88 },
92 ]89 ]
93 : []),90 : []),
94 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },91 { id: "rename", label: "Rename thread" },
95 ...(state.supports.titleRegeneration92 ...(state.supports.titleRegeneration
96 ? [93 ? [
97 {94 {
98 id: "regenerate-title" as const,95 id: "regenerate-title" as const,
99 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",96 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
100 icon: "refresh-cw",
101 disabled: state.isRegeneratingTitle,97 disabled: state.isRegeneratingTitle,
102 },98 },
103 ]99 ]
104 : []),100 : []),
105 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },101 { id: "mark-unread", label: "Mark unread" },
106 {102 { id: "copy-path", label: "Copy path", icon: "copy" },
107 id: "copy",103 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
108 label: "Copy",104 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
109 icon: "copy",105 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
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 },
126 ];106 ];
127}107}
apps/web/src/components/ui/segmented-tabs.tsx deleted-40
...@@ -1,40 +0,0 @@
1import type { ComponentProps, HTMLAttributes } from "react";
2
3import { cn } from "~/lib/utils";
4import { Toggle } from "~/components/ui/toggle";
5
6function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
7 return (
8 <div
9 className={cn(
10 "inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
11 className,
12 )}
13 role="group"
14 {...props}
15 />
16 );
17}
18
19function SegmentedTab({
20 selected,
21 density = "default",
22 className,
23 ...props
24}: {
25 selected: boolean;
26 density?: "default" | "compact";
27} & Omit<ComponentProps<typeof Toggle>, "aria-pressed" | "pressed" | "size" | "type" | "variant">) {
28 return (
29 <Toggle
30 type="button"
31 pressed={selected}
32 size={density === "compact" ? "segmented-compact" : "segmented"}
33 variant="segmented"
34 className={className}
35 {...props}
36 />
37 );
38}
39
40export { SegmentedTab, SegmentedTabList };
apps/web/src/components/ui/toggle.tsx-6
...@@ -18,10 +18,6 @@ const toggleVariants = cva(...@@ -18,10 +18,6 @@ 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)]",
25 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",21 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
26 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",22 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
27 },23 },
...@@ -31,8 +27,6 @@ const toggleVariants = cva(...@@ -31,8 +27,6 @@ const toggleVariants = cva(
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",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",
32 outline:28 outline:
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",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",
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",
36 },30 },
37 },31 },
38 },32 },
apps/web/src/components/usage/UsagePage.tsx+243-150
...@@ -19,22 +19,13 @@ import {...@@ -19,22 +19,13 @@ import {
19 formatUsd,19 formatUsd,
20 makeWindow,20 makeWindow,
21} from "@t3tools/shared/usageFormat";21} from "@t3tools/shared/usageFormat";
22import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";22import { ScrollArea } from "../ui/scroll-area";
23import { Button } from "../ui/button";
24import { SidebarInset } from "../ui/sidebar";24import { SidebarInset } from "../ui/sidebar";
25import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs";25import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
26import {26import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
27 WorkspaceBreadcrumb,27import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
28 WorkspaceBreadcrumbItem,28import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
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";
3829
39const WINDOW_OPTIONS = [30const WINDOW_OPTIONS = [
40 { days: 1, label: "Past 24h" },31 { days: 1, label: "Past 24h" },
...@@ -75,6 +66,21 @@ export function UsagePage() {...@@ -75,6 +66,21 @@ export function UsagePage() {
75 [isPast24Hours, merged.daily, merged.hourly],66 [isPast24Hours, merged.daily, merged.hourly],
76 );67 );
7768
69 // Ranked by whatever the toggle is showing, so the bars always descend.
70 const orderedProviders = useMemo(
71 () =>
72 merged.providers.toSorted((a, b) =>
73 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
74 ),
75 [merged.providers, metric],
76 );
77
78 const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter(
79 (period) => period.totalTokens > 0,
80 ).length;
81 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
82 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
83 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
78 const selectWindow = (days: number) => {84 const selectWindow = (days: number) => {
79 setWindowSelection({85 setWindowSelection({
80 days,86 days,
...@@ -94,66 +100,78 @@ export function UsagePage() {...@@ -94,66 +100,78 @@ export function UsagePage() {
94 setWindowSelection({ days: windowDays, window: nextWindow });100 setWindowSelection({ days: windowDays, window: nextWindow });
95 }101 }
96 };102 };
97 const windowLabel =
98 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
99 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
100 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
101 const topbarContent = (
102 <div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
103 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
104 <WorkspaceBreadcrumbItem current>
105 <h1>Usage</h1>
106 </WorkspaceBreadcrumbItem>
107 <WorkspaceBreadcrumbSeparator />
108 <WorkspaceBreadcrumbItem className="min-w-0 shrink">
109 <span className="truncate">{windowLabel}</span>
110 </WorkspaceBreadcrumbItem>
111 </WorkspaceBreadcrumb>
112 <div className="ms-auto flex min-w-0 basis-full flex-wrap items-center justify-end gap-2 sm:basis-auto">
113 <SegmentedTabList aria-label="Usage metric">
114 {(["cost", "tokens"] as const).map((option) => (
115 <SegmentedTab
116 key={option}
117 selected={option === metric}
118 onClick={() => setMetric(option)}
119 >
120 {option === "cost" ? "Cost" : "Tokens"}
121 </SegmentedTab>
122 ))}
123 </SegmentedTabList>
124 <SegmentedTabList aria-label="Usage period">
125 {WINDOW_OPTIONS.map((option) => (
126 <SegmentedTab
127 key={option.days}
128 selected={option.days === windowDays}
129 onClick={() => selectWindow(option.days)}
130 >
131 {option.label}
132 </SegmentedTab>
133 ))}
134 </SegmentedTabList>
135 <WorkspacePageHeaderEdgeControl>
136 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
137 <RefreshCwIcon className="size-3.5" />
138 </Button>
139 </WorkspacePageHeaderEdgeControl>
140 </div>
141 </div>
142 );
143103
144 return (104 return (
145 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">105 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
146 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">106 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
147 <WorkspacePageHeader electron={isElectron} className="h-auto py-2">107 {!isElectron && (
148 {topbarContent}108 <header
149 </WorkspacePageHeader>109 className={cn(
110 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
111 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
112 )}
113 >
114 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
115 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
116 </WorkspaceBreadcrumb>
117 </header>
118 )}
119
120 {isElectron && (
121 <div
122 className={cn(
123 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
124 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
125 )}
126 >
127 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
128 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
129 </WorkspaceBreadcrumb>
130 </div>
131 )}
150132
151 <ScrollArea className="min-h-0 flex-1">133 <ScrollArea className="min-h-0 flex-1">
152 <WorkspacePageContainer width="wide">134 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">
135 <div className="flex flex-wrap items-center justify-between gap-4">
136 <p className="text-sm text-muted-foreground">
137 {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
138 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
139 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`}
140 </p>
141 <div className="flex items-center gap-2">
142 <div className="flex rounded-md border border-border">
143 {WINDOW_OPTIONS.map((option) => (
144 <button
145 key={option.days}
146 type="button"
147 aria-pressed={option.days === windowDays}
148 onClick={() => selectWindow(option.days)}
149 className={cn(
150 "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
151 option.days === windowDays
152 ? "bg-muted text-foreground"
153 : "text-muted-foreground hover:text-foreground",
154 )}
155 >
156 {option.label}
157 </button>
158 ))}
159 </div>
160 <Button
161 size="icon"
162 variant="outline"
163 onClick={refreshWindow}
164 aria-label="Refresh usage"
165 >
166 <RefreshCwIcon className="size-3.5" />
167 </Button>
168 </div>
169 </div>
170
153 {settling ? (171 {settling ? (
154 <>172 <>
155 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}173 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
156 <UsageSkeleton />174 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
157 </>175 </>
158 ) : (176 ) : (
159 <>177 <>
...@@ -163,62 +181,88 @@ export function UsagePage() {...@@ -163,62 +181,88 @@ export function UsagePage() {
163 staleEnvironments={merged.staleEnvironments}181 staleEnvironments={merged.staleEnvironments}
164 />182 />
165183
166 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">184 {/* Cost first: the financial answer, then the provider split. */}
167 <div className="flex min-w-0 flex-col gap-5">185 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
186 {/* The summary follows the chart toggle, so the headline and the
187 series are always reading the same units. */}
188 <div className="flex flex-col gap-5">
168 <div className="flex flex-col gap-1">189 <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>
169 <span className="text-4xl font-semibold text-foreground tabular-nums">193 <span className="text-4xl font-semibold text-foreground tabular-nums">
170 {metric === "cost"194 {metric === "cost"
171 ? formatUsd(merged.costUsd)195 ? `${formatUsd(merged.costUsd)}*`
172 : formatTokens(merged.totalTokens)}196 : formatTokens(merged.totalTokens)}
173 </span>197 </span>
174 <span className="text-xs text-muted-foreground">198 <span className="text-xs text-muted-foreground">
175 {metric === "cost"199 {metric === "cost"
176 ? `${formatCount(merged.sessions)} sessions · API estimate`200 ? "* if billed at full API rate"
177 : `${formatCount(merged.sessions)} sessions`}201 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
178 </span>202 </span>
179 </div>203 </div>
180204
181 {PROVIDER_ORDER.map((provider) => {205 {orderedProviders.map((provider) => {
182 const totals = merged.providers.find((entry) => entry.provider === provider);206 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
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 }`;
189 return (207 return (
190 <div key={provider} className="flex flex-col gap-1">208 <div key={provider.provider} className="flex flex-col gap-1.5">
191 <div className="flex items-baseline justify-between gap-4">209 <div className="flex items-baseline justify-between">
192 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">210 <span className="flex items-center gap-2 text-sm text-foreground">
193 <ProviderMark provider={provider} className="size-4" />211 <ProviderMark provider={provider.provider} className="size-4" />
194 <span className="flex min-w-0 items-baseline gap-1.5">212 {PROVIDER_LABEL[provider.provider]}
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>
200 </span>213 </span>
201 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">214 <span className="text-sm text-foreground tabular-nums">
202 {metric === "cost"215 {metric === "cost"
203 ? formatUsd(totals?.costUsd ?? 0)216 ? formatUsd(provider.costUsd)
204 : formatTokens(totals?.totalTokens ?? 0)}217 : formatTokens(provider.totalTokens)}
205 </span>218 </span>
206 </div>219 </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>
207 <span className="text-xs text-muted-foreground">229 <span className="text-xs text-muted-foreground">
208 {metric === "cost"230 {metric === "cost"
209 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`231 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
210 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}232 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
211 </span>233 </span>
212 </div>234 </div>
213 );235 );
214 })}236 })}
215 </div>237 </div>
216238
217 <div className="flex min-w-0 flex-col gap-3">239 <div className="flex flex-col gap-3">
218 <h2 className="text-sm font-medium text-foreground">240 <div className="flex flex-wrap items-center justify-between gap-3">
219 {isPast24Hours ? "Hourly" : "Daily"}{" "}241 <h2 className="text-sm font-medium text-foreground">
220 {metric === "tokens" ? "processed tokens" : "cost"}242 {isPast24Hours ? "Hourly" : "Daily"}{" "}
221 </h2>243 {metric === "tokens" ? "processed tokens" : "cost"}
244 </h2>
245 <div className="flex items-center gap-4">
246 <div className="flex overflow-hidden rounded-md border border-border">
247 {(["cost", "tokens"] as const).map((option) => (
248 <button
249 key={option}
250 type="button"
251 onClick={() => setMetric(option)}
252 className={cn(
253 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
254 option === metric
255 ? "bg-muted text-foreground"
256 : "text-muted-foreground hover:text-foreground",
257 )}
258 >
259 {option}
260 </button>
261 ))}
262 </div>
263 <UsageChartLegend />
264 </div>
265 </div>
222 <UsageProviderChart266 <UsageProviderChart
223 days={days}267 days={days}
224 daily={merged.daily}268 daily={merged.daily}
...@@ -232,42 +276,63 @@ export function UsagePage() {...@@ -232,42 +276,63 @@ export function UsagePage() {
232 </div>276 </div>
233 </section>277 </section>
234278
235 <section className="flex flex-col gap-2">279 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
236 <h2 className="text-sm font-medium text-foreground">Totals</h2>280 <Metric
237 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">281 label="Processed tokens"
238 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />282 value={formatTokens(merged.totalTokens)}
239 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />283 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}
240 <Metric284 />
241 label="Uncached input"285 <Metric
242 value={formatTokens(merged.uncachedInputTokens)}286 label="Cached input"
243 />287 value={formatTokens(merged.cachedInputTokens)}
244 <Metric label="Output" value={formatTokens(merged.outputTokens)} />288 detail={`${formatPercent(cachedShare)} of observed input`}
245 <Metric289 />
246 label="Cache savings"290 <Metric
247 value={formatUsd(merged.costQuality.cacheSavingsUsd)}291 label="Uncached input"
248 />292 value={formatTokens(merged.uncachedInputTokens)}
249 </div>293 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
294 />
295 <Metric
296 label="Output"
297 value={formatTokens(merged.outputTokens)}
298 detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`}
299 />
300 <Metric
301 label="Cache savings"
302 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
303 detail={
304 merged.costUsd > 0
305 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost`
306 : "vs full input rates"
307 }
308 />
250 </section>309 </section>
251310
252 <section className="flex flex-col gap-3">311 <section className="flex flex-col gap-3">
253 <div className="flex items-center justify-between gap-3">312 <div className="flex items-center justify-between gap-3">
254 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>313 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
255 <SegmentedTabList aria-label="Usage breakdown">314 <div className="flex overflow-hidden rounded-md border border-border">
256 {(315 {(
257 [316 [
258 { value: "model", label: "Model" },317 { value: "model", label: "model" },
259 { value: "time", label: isPast24Hours ? "Hour" : "Day" },318 { value: "time", label: isPast24Hours ? "hour" : "day" },
260 ] as const319 ] as const
261 ).map((option) => (320 ).map((option) => (
262 <SegmentedTab321 <button
263 key={option.value}322 key={option.value}
264 selected={option.value === breakdown}323 type="button"
265 onClick={() => setBreakdown(option.value)}324 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 )}
266 >331 >
267 {option.label}332 {option.label}
268 </SegmentedTab>333 </button>
269 ))}334 ))}
270 </SegmentedTabList>335 </div>
271 </div>336 </div>
272337
273 {breakdown === "model" ? (338 {breakdown === "model" ? (
...@@ -291,7 +356,7 @@ export function UsagePage() {...@@ -291,7 +356,7 @@ export function UsagePage() {
291 merged.models.map((model) => (356 merged.models.map((model) => (
292 <tr357 <tr
293 key={`${model.provider}:${model.model}`}358 key={`${model.provider}:${model.model}`}
294 className="hover:bg-muted/20"359 className="border-b border-border/50"
295 >360 >
296 <td className="py-2 text-foreground">361 <td className="py-2 text-foreground">
297 <span className="flex items-center gap-2">362 <span className="flex items-center gap-2">
...@@ -338,7 +403,7 @@ export function UsagePage() {...@@ -338,7 +403,7 @@ export function UsagePage() {
338 recentPeriods.map((period) => (403 recentPeriods.map((period) => (
339 <tr404 <tr
340 key={"hourStart" in period ? period.hourStart : period.day}405 key={"hourStart" in period ? period.hourStart : period.day}
341 className="hover:bg-muted/20"406 className="border-b border-border/50"
342 >407 >
343 <td className="py-2 text-foreground">408 <td className="py-2 text-foreground">
344 {"hourStart" in period409 {"hourStart" in period
...@@ -368,7 +433,7 @@ export function UsagePage() {...@@ -368,7 +433,7 @@ export function UsagePage() {
368 </section>433 </section>
369 </>434 </>
370 )}435 )}
371 </WorkspacePageContainer>436 </div>
372 </ScrollArea>437 </ScrollArea>
373 </div>438 </div>
374 </SidebarInset>439 </SidebarInset>
...@@ -387,11 +452,20 @@ function ProviderMark({...@@ -387,11 +452,20 @@ function ProviderMark({
387 return <Mark className={cn("shrink-0", className)} aria-hidden />;452 return <Mark className={cn("shrink-0", className)} aria-hidden />;
388}453}
389454
390function Metric({ label, value }: { readonly label: string; readonly value: string }) {455function Metric({
456 label,
457 value,
458 detail,
459}: {
460 readonly label: string;
461 readonly value: string;
462 readonly detail: string;
463}) {
391 return (464 return (
392 <div className="flex min-w-0 flex-col gap-0.5">465 <div className="flex flex-col gap-0.5 bg-background px-4 py-3">
393 <span className="text-xs text-muted-foreground">{label}</span>466 <span className="text-xs text-muted-foreground">{label}</span>
394 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>467 <span className="text-lg text-foreground tabular-nums">{value}</span>
468 <span className="text-xs text-muted-foreground">{detail}</span>
395 </div>469 </div>
396 );470 );
397}471}
...@@ -495,51 +569,70 @@ function UsageDeviceStrip({...@@ -495,51 +569,70 @@ function UsageDeviceStrip({
495 );569 );
496}570}
497571
572/** Deterministic bar heights (each unique: they double as keys). */
573const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
574
498/**575/**
499 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in576 * Static stand-in with the loaded page's shape: headline, provider split,
500 * exactly once when the last device answers.577 * chart and metrics strip. No shimmer; blocks fill in exactly once when the
578 * last device answers.
501 */579 */
502function UsageSkeleton() {580function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
503 return (581 return (
504 <>582 <>
505 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">583 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
506 <div className="flex flex-col gap-5">584 <div className="flex flex-col gap-5">
507 <div className="flex flex-col gap-1">585 <div className="flex flex-col gap-1">
508 <div className="h-8 w-36 rounded-sm bg-muted" />586 <span className="text-xs tracking-wide text-muted-foreground uppercase">
509 <div className="h-3 w-32 rounded-sm bg-muted" />587 Raw token cost
588 </span>
589 <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
590 <div className="h-3 w-28 rounded-sm bg-muted" />
510 </div>591 </div>
592
511 {PROVIDER_ORDER.map((provider) => (593 {PROVIDER_ORDER.map((provider) => (
512 <div key={provider} className="flex flex-col gap-1">594 <div key={provider} className="flex flex-col gap-1.5">
513 <div className="flex items-center justify-between gap-4">595 <div className="flex items-center justify-between">
514 <span className="flex items-center gap-2">596 <span className="flex items-center gap-2 text-sm text-foreground">
515 <ProviderMark provider={provider} className="size-4" />597 <ProviderMark provider={provider} className="size-4" />
516 <div className="h-3.5 w-20 rounded-sm bg-muted" />598 {PROVIDER_LABEL[provider]}
517 </span>599 </span>
518 <div className="h-3.5 w-14 rounded-sm bg-muted" />600 <div className="h-3.5 w-14 rounded-sm bg-muted" />
519 </div>601 </div>
602 <div className="h-1 w-full rounded-full bg-muted" />
520 <div className="h-3 w-36 rounded-sm bg-muted" />603 <div className="h-3 w-36 rounded-sm bg-muted" />
521 </div>604 </div>
522 ))}605 ))}
523 </div>606 </div>
524607
525 <div className="flex flex-col gap-3">608 <div className="flex flex-col gap-3">
526 <div className="h-4 w-24 rounded-sm bg-muted" />609 <h2 className="py-1 text-sm font-medium text-foreground">
527 <div className="h-56 rounded-sm bg-muted/35" />610 {resolution === "hour" ? "Hourly" : "Daily"} cost
611 </h2>
612 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
613 relayout when the real chart swaps in. */}
614 <div className="flex h-56 items-end gap-1 pl-16">
615 {SKELETON_BAR_HEIGHTS.map((height) => (
616 <div
617 key={height}
618 className="flex-1 rounded-sm bg-muted"
619 style={{ height: `${height}%` }}
620 />
621 ))}
622 </div>
528 </div>623 </div>
529 </section>624 </section>
530625
531 <section className="flex flex-col gap-2">626 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
532 <h2 className="text-sm font-medium text-foreground">Totals</h2>627 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
533 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">628 (label) => (
534 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(629 <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">
535 (label) => (630 <span className="text-xs text-muted-foreground">{label}</span>
536 <div key={label} className="flex flex-col gap-0.5">631 <div className="my-1 h-5 w-16 rounded-sm bg-muted" />
537 <span className="text-xs text-muted-foreground">{label}</span>632 <div className="h-3 w-24 rounded-sm bg-muted" />
538 <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" />633 </div>
539 </div>634 ),
540 ),635 )}
541 )}
542 </div>
543 </section>636 </section>
544 </>637 </>
545 );638 );
apps/web/src/components/usage/UsageProviderChart.tsx+65-72
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";2import { useCallback, 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,7 +68,13 @@ function buildPeriodColumns(...@@ -68,7 +68,13 @@ function buildPeriodColumns(
68 });68 });
69}69}
7070
71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */71/**
72 * Monotone cubic tangents (Fritsch-Carlson).
73 *
74 * Plain cubic smoothing overshoots on spiky daily data and would dip the area
75 * below zero between points, which reads as negative spend. This variant is
76 * shape-preserving, so a smoothed series never leaves the range of its samples.
77 */
72function monotoneTangents(points: readonly Point[]): readonly number[] {78function monotoneTangents(points: readonly Point[]): readonly number[] {
73 const count = points.length;79 const count = points.length;
74 if (count < 2) return [0];80 if (count < 2) return [0];
...@@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {...@@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
109 return tangents;115 return tangents;
110}116}
111117
118/** One cubic segment of a smoothed boundary. */
112interface CurveSegment {119interface CurveSegment {
113 readonly from: Point;120 readonly from: Point;
114 readonly c1: Point;121 readonly c1: Point;
...@@ -116,6 +123,7 @@ interface CurveSegment {...@@ -116,6 +123,7 @@ interface CurveSegment {
116 readonly to: Point;123 readonly to: Point;
117}124}
118125
126/** Smoothed polyline through `points`, as explicit cubic control points. */
119function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {127function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
120 if (points.length < 2) return [];128 if (points.length < 2) return [];
121 const tangents = monotoneTangents(points);129 const tangents = monotoneTangents(points);
...@@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {...@@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
136 return segments;144 return segments;
137}145}
138146
139function curvePath(segments: readonly CurveSegment[]): string {147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
140 const first = segments[0];148 const first = segments[0];
141 if (first === undefined) return "";149 if (first === undefined) return "";
142 let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;150 let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
143 for (const segment of segments) {151 for (const segment of segments) {
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)}`;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)}`;
145 }153 }
...@@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re...@@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
171/**179/**
172 * Turns the merged daily totals into one column per day.180 * Turns the merged daily totals into one column per day.
173 *181 *
174 * Values are absolute, not cumulative: each provider is drawn from the same182 * Values are absolute, not cumulative: the series are layered from a shared
175 * zero baseline so the chart never implies that one provider is always larger.183 * zero baseline rather than stacked. A stacked chart puts whichever provider is
184 * drawn last permanently above the other, which reads as "that one is bigger"
185 * even on days where it is not.
176 *186 *
177 * The chart paths and the hover readout both consume this, so the number under187 * The chart paths and the hover readout both consume this, so the number under
178 * the cursor is by construction the number that was plotted rather than a188 * the cursor is by construction the number that was plotted rather than a
...@@ -206,39 +216,42 @@ export function UsageProviderChart({...@@ -206,39 +216,42 @@ export function UsageProviderChart({
206 );216 );
207 const [hoverIndex, setHoverIndex] = useState<number | null>(null);217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
208 const plotRef = useRef<HTMLDivElement | null>(null);218 const plotRef = useRef<HTMLDivElement | null>(null);
209 const tooltipRef = useRef<HTMLDivElement | null>(null);
210 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
211219
212 const { paths, series, stepX, ticks, toY } = useMemo(() => {220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
213 if (periods.length === 0) {221 if (periods.length === 0) {
214 return {222 return {
215 paths: [],223 paths: [],
216 series: [] as readonly DayColumn[],
217 stepX: 0,
218 ticks: [0] as readonly number[],224 ticks: [0] as readonly number[],
225 stepX: 0,
219 toY: () => VIEW_HEIGHT,226 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
220 };228 };
221 }229 }
222230
223 const columns = buildPeriodColumns(periods, byPeriod, metric);231 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.
224 const peak = columns.reduce(236 const peak = columns.reduce(
225 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),237 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
226 0,238 0,
227 );239 );
228 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);240 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
229 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);241 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.
230 const toY = (value: number) =>244 const toY = (value: number) =>
231 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);245 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
232246
233 const built = PROVIDER_ORDER.map((provider, providerIndex) => {247 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
234 const line = curvePath(248 const curve = smoothCurve(
235 smoothCurve(249 columns.map((column, dayIndex) => ({
236 columns.map((column, periodIndex) => ({250 x: dayIndex * step,
237 x: periodIndex * step,251 y: toY(column.bands[providerIndex]?.value ?? 0),
238 y: toY(column.bands[providerIndex]?.value ?? 0),252 })),
239 })),
240 ),
241 );253 );
254 const line = curvePath(curve, "M");
242 return {255 return {
243 provider,256 provider,
244 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),257 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
...@@ -247,65 +260,30 @@ export function UsageProviderChart({...@@ -247,65 +260,30 @@ export function UsageProviderChart({
247 };260 };
248 });261 });
249262
250 return {263 // Paint the heavier series first so the lighter one is never buried under
251 paths: built.toSorted((a, b) => b.total - a.total),264 // it. The fills are faint enough that the order barely shows, but the
252 series: columns,265 // strokes are drawn in a second pass regardless, so neither can be hidden.
253 stepX: step,266 const ordered = [...built].sort((a, b) => b.total - a.total);
254 ticks: tickValues,267
255 toY,268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
256 };
257 }, [byPeriod, metric, periods]);269 }, [byPeriod, metric, periods]);
258270
259 const format = metric === "tokens" ? formatTokens : formatUsd;271 const format = metric === "tokens" ? formatTokens : formatUsd;
260272
261 const positionTooltip = useCallback(() => {
262 const plot = plotRef.current;
263 const tooltip = tooltipRef.current;
264 const hoverPosition = hoverPositionRef.current;
265 if (plot === null || tooltip === null || hoverPosition === null) return;
266
267 const gap = 12;
268 const tooltipWidth = tooltip.offsetWidth;
269 const tooltipHeight = tooltip.offsetHeight;
270 const plotWidth = plot.clientWidth;
271 const plotHeight = plot.clientHeight;
272 const preferredLeft =
273 hoverPosition.x + gap + tooltipWidth <= plotWidth
274 ? hoverPosition.x + gap
275 : hoverPosition.x - gap - tooltipWidth;
276 const preferredTop =
277 hoverPosition.y + gap + tooltipHeight <= plotHeight
278 ? hoverPosition.y + gap
279 : hoverPosition.y - gap - tooltipHeight;
280 const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
281 const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
282 plot.style.setProperty("--usage-tooltip-left", `${left}px`);
283 plot.style.setProperty("--usage-tooltip-top", `${top}px`);
284 }, []);
285
286 useLayoutEffect(() => {
287 if (hoverIndex !== null) positionTooltip();
288 }, [hoverIndex, positionTooltip]);
289
290 const handleMove = useCallback(273 const handleMove = useCallback(
291 (event: React.MouseEvent<HTMLDivElement>) => {274 (event: React.MouseEvent<HTMLDivElement>) => {
292 const plot = plotRef.current;275 const bounds = plotRef.current?.getBoundingClientRect();
293 if (plot === null || periods.length === 0) return;276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;
294 const bounds = plot.getBoundingClientRect();277 const fraction = (event.clientX - bounds.left) / bounds.width;
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;
299 const index = Math.round(fraction * (periods.length - 1));278 const index = Math.round(fraction * (periods.length - 1));
300 hoverPositionRef.current = { x: localX, y: localY };
301 positionTooltip();
302 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
303 },280 },
304 [periods.length, positionTooltip],281 [periods.length],
305 );282 );
306283
307 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
308 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];285 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
309 const formatPeriod = (period: string) =>287 const formatPeriod = (period: string) =>
310 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);288 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
311 const formatTooltipPeriod = (period: string) =>289 const formatTooltipPeriod = (period: string) =>
...@@ -333,10 +311,7 @@ export function UsageProviderChart({...@@ -333,10 +311,7 @@ export function UsageProviderChart({
333 ref={plotRef}311 ref={plotRef}
334 className="relative h-56 flex-1"312 className="relative h-56 flex-1"
335 onMouseMove={handleMove}313 onMouseMove={handleMove}
336 onMouseLeave={() => {314 onMouseLeave={() => setHoverIndex(null)}
337 hoverPositionRef.current = null;
338 setHoverIndex(null);
339 }}
340 >315 >
341 <svg316 <svg
342 className="h-full w-full"317 className="h-full w-full"
...@@ -362,6 +337,7 @@ export function UsageProviderChart({...@@ -362,6 +337,7 @@ export function UsageProviderChart({
362 );337 );
363 })}338 })}
364339
340 {/* Fills first, then every stroke, so no series covers another's line. */}
365 {paths.map(({ provider, area }) => (341 {paths.map(({ provider, area }) => (
366 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />342 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
367 ))}343 ))}
...@@ -392,11 +368,10 @@ export function UsageProviderChart({...@@ -392,11 +368,10 @@ export function UsageProviderChart({
392368
393 {hoveredPeriod === undefined ? null : (369 {hoveredPeriod === undefined ? null : (
394 <div370 <div
395 ref={tooltipRef}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"
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"
397 style={{372 style={{
398 left: "var(--usage-tooltip-left, 0px)",373 left: `${hoverLeft}%`,
399 top: "var(--usage-tooltip-top, 0px)",374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
400 }}375 }}
401 >376 >
402 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>377 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
...@@ -443,3 +418,21 @@ export function UsageProviderChart({...@@ -443,3 +418,21 @@ export function UsageProviderChart({
443 </div>418 </div>
444 );419 );
445}420}
421
422export function UsageChartLegend() {
423 return (
424 <div className="flex items-center gap-4">
425 {PROVIDER_ORDER.map((provider) => {
426 // The marks carry the same fills as the bands, so they key the chart
427 // just as a colour swatch would.
428 const Mark = PROVIDER_MARK[provider];
429 return (
430 <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground">
431 <Mark className="size-3.5 shrink-0" aria-hidden />
432 {PROVIDER_LABEL[provider]}
433 </span>
434 );
435 })}
436 </div>
437 );
438}
apps/web/src/components/usage/usageProviders.ts+3-1
...@@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";...@@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";3import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
5/**5/**
6 * Stable provider reading order across summaries, tables, and hover rows.6 * Series and table order. The chart layers both providers from a shared zero
7 * baseline, so this only fixes the reading order of legends, tables and hover
8 * rows; it does not decide which series sits above the other.
7 */9 */
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];10export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
911
apps/web/src/contextMenuFallback.ts+6-92
...@@ -4,15 +4,6 @@ const SVG_NS = "http://www.w3.org/2000/svg";...@@ -4,15 +4,6 @@ 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 ],
16 pencil: [7 pencil: [
17 {8 {
18 tag: "path",9 tag: "path",
...@@ -26,71 +17,6 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -26,71 +17,6 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
26 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },17 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "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" } },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" } },
28 ],19 ],
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 ],
94 "folder-tree": [20 "folder-tree": [
95 {21 {
96 tag: "path",22 tag: "path",
...@@ -118,7 +44,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -118,7 +44,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
11844
119function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {45function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
120 const paths = ICON_PATHS[name];46 const paths = ICON_PATHS[name];
121 if (!paths || typeof document.createElementNS !== "function") {47 if (!paths) {
122 return null;48 return null;
123 }49 }
124 const svg = document.createElementNS(SVG_NS, "svg");50 const svg = document.createElementNS(SVG_NS, "svg");
...@@ -131,7 +57,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV...@@ -131,7 +57,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
131 svg.setAttribute("stroke-linejoin", "round");57 svg.setAttribute("stroke-linejoin", "round");
132 svg.setAttribute(58 svg.setAttribute(
133 "class",59 "class",
134 tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground",60 tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
135 );61 );
136 for (const node of paths) {62 for (const node of paths) {
137 const child = document.createElementNS(SVG_NS, node.tag);63 const child = document.createElementNS(SVG_NS, node.tag);
...@@ -274,16 +200,6 @@ export function showContextMenuFallback<T extends string>(...@@ -274,16 +200,6 @@ export function showContextMenuFallback<T extends string>(
274 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";200 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
275201
276 for (const item of entries) {202 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
287 if (item.header === true) {203 if (item.header === true) {
288 const header = document.createElement("div");204 const header = document.createElement("div");
289 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";205 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
...@@ -331,12 +247,10 @@ export function showContextMenuFallback<T extends string>(...@@ -331,12 +247,10 @@ export function showContextMenuFallback<T extends string>(
331 button.appendChild(label);247 button.appendChild(label);
332248
333 if (hasChildren) {249 if (hasChildren) {
334 const chevron = createIconElement("chevron-right", "neutral");250 const chevron = document.createElement("span");
335 if (chevron) {251 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";
336 chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80");252 chevron.textContent = ">";
337 chevron.dataset.contextMenuChevron = "true";253 button.appendChild(chevron);
338 button.appendChild(chevron);
339 }
340 }254 }
341255
342 if (!isDisabled) {256 if (!isDisabled) {
apps/web/src/index.css+5-78
...@@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -241,22 +241,6 @@ 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 }
260 @keyframes status-ping {244 @keyframes status-ping {
261 /* Burst first (immediate feedback for click ripples), then hold245 /* Burst first (immediate feedback for click ripples), then hold
262 invisible for the rest of the cycle. Mirrors animate-ping's246 invisible for the rest of the cycle. Mirrors animate-ping's
...@@ -447,62 +431,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -447,62 +431,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
447 }431 }
448}432}
449433
450@utility live-activity-focus {
451 --live-activity-focus-width: 4.5rem;
452
453 right: auto;
454 left: calc(-1 * var(--live-activity-focus-width));
455 width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width));
456 -webkit-mask-image: linear-gradient(
457 to right,
458 transparent 0,
459 rgb(0 0 0 / 12%) 0.675rem,
460 rgb(0 0 0 / 55%) 1.575rem,
461 black 2.25rem,
462 rgb(0 0 0 / 55%) 2.925rem,
463 rgb(0 0 0 / 12%) 3.825rem,
464 transparent var(--live-activity-focus-width),
465 transparent 100%
466 );
467 -webkit-mask-repeat: no-repeat;
468 mask-image: linear-gradient(
469 to right,
470 transparent 0,
471 rgb(0 0 0 / 12%) 0.675rem,
472 rgb(0 0 0 / 55%) 1.575rem,
473 black 2.25rem,
474 rgb(0 0 0 / 55%) 2.925rem,
475 rgb(0 0 0 / 12%) 3.825rem,
476 transparent var(--live-activity-focus-width),
477 transparent 100%
478 );
479 mask-repeat: no-repeat;
480 animation: live-activity-focus 2.2s linear infinite;
481 will-change: transform;
482
483 @media (prefers-reduced-motion: reduce) {
484 animation: none;
485 opacity: 0;
486 will-change: auto;
487 }
488}
489
490@utility live-activity-focus-counter {
491 width: 100%;
492 animation: live-activity-focus-counter 2.2s linear infinite;
493 will-change: transform;
494
495 @media (prefers-reduced-motion: reduce) {
496 animation: none;
497 will-change: auto;
498 }
499}
500
501@utility live-activity-focus-aligned {
502 width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width));
503 margin-left: var(--live-activity-focus-width);
504}
505
506@layer base {434@layer base {
507 :root {435 :root {
508 /* Keep the original T3 Code artwork palettes as the defaults. Built-in436 /* Keep the original T3 Code artwork palettes as the defaults. Built-in
...@@ -1421,16 +1349,15 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] {...@@ -1421,16 +1349,15 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] {
14211349
1422/* The panel layout toggles stay ghost: they render both inside the header and1350/* The panel layout toggles stay ghost: they render both inside the header and
1423 in the titlebar strip, so filling them would make them change appearance as1351 in the titlebar strip, so filling them would make them change appearance as
1424 the panel opens. Their icons use the same themed foreground as the toolbar1352 the panel opens. They only take the themed foreground; hover and pressed
1425 action text; hover and pressed keep the base ghost accent. The tooltip1353 keep the base ghost accent. The tooltip trigger's data-slot wins over the
1426 trigger's data-slot wins over the toggle's when it renders the toggle, so1354 toggle's when the trigger renders the toggle, so match both. */
1427 match both. */
1428html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],1355html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
1429html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],1356html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
1430html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],1357html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
1431html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {1358html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1432 --control-icon-color: var(--toolbar-control-foreground);1359 --control-icon-color: var(--toolbar-foreground);
1433 color: var(--toolbar-control-foreground);1360 color: var(--toolbar-foreground);
1434}1361}
14351362
1436html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,1363html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
apps/web/src/lib/openPullRequestLink.ts-11
...@@ -118,17 +118,6 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu...@@ -118,17 +118,6 @@ 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
132function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {121function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
133 const repository = match?.[1];122 const repository = match?.[1];
134 const number = Number(match?.[2]);123 const number = Number(match?.[2]);
apps/web/src/routes/-chatIndexTitlebar.test.ts+3-1
...@@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => {...@@ -15,7 +15,9 @@ 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('<WorkspacePageHeader className="border-b border-border">');18 expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]");
19 expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]");
20 expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS");
19 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);21 expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/);
20 });22 });
21});23});
apps/web/src/routes/_chat.index.tsx+9-3
...@@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";...@@ -8,7 +8,6 @@ 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";
12import { useNewThreadHandler } from "../hooks/useHandleNewThread";11import { useNewThreadHandler } from "../hooks/useHandleNewThread";
13import {12import {
14 useAllEnvironmentShellsBootstrapped,13 useAllEnvironmentShellsBootstrapped,
...@@ -18,6 +17,8 @@ import {...@@ -18,6 +17,8 @@ import {
18import { useEnvironments } from "../state/environments";17import { useEnvironments } from "../state/environments";
19import { APP_DISPLAY_NAME } from "~/branding";18import { APP_DISPLAY_NAME } from "~/branding";
20import { hasCloudPublicConfig } from "~/cloud/publicConfig";19import { hasCloudPublicConfig } from "~/cloud/publicConfig";
20import { cn } from "~/lib/utils";
21import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
2122
22function ChatIndexRouteView() {23function ChatIndexRouteView() {
23 const { authGateState } = Route.useRouteContext();24 const { authGateState } = Route.useRouteContext();
...@@ -142,13 +143,18 @@ function HostedStaticOnboardingState() {...@@ -142,13 +143,18 @@ function HostedStaticOnboardingState() {
142 return (143 return (
143 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">144 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
144 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">145 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
145 <WorkspacePageHeader className="border-b border-border">146 <header
147 className={cn(
148 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
149 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
150 )}
151 >
146 <div className="flex items-center gap-2">152 <div className="flex items-center gap-2">
147 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">153 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
148 {APP_DISPLAY_NAME}154 {APP_DISPLAY_NAME}
149 </span>155 </span>
150 </div>156 </div>
151 </WorkspacePageHeader>157 </header>
152158
153 <Empty className="flex-1">159 <Empty className="flex-1">
154 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">160 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">
apps/web/src/routes/_chat.pull-requests.tsx+37-66
...@@ -74,12 +74,6 @@ import {...@@ -74,12 +74,6 @@ 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";
83import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";77import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
84import { Button } from "../components/ui/button";78import { Button } from "../components/ui/button";
85import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";79import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
...@@ -105,6 +99,7 @@ import {...@@ -105,6 +99,7 @@ import {
105import { useAtomCommand } from "../state/use-atom-command";99import { useAtomCommand } from "../state/use-atom-command";
106import { cn } from "~/lib/utils";100import { cn } from "~/lib/utils";
107import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";101import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
102import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
108103
109export interface PullRequestsSearch {104export interface PullRequestsSearch {
110 readonly involvement: PullRequestInvolvement;105 readonly involvement: PullRequestInvolvement;
...@@ -1186,17 +1181,6 @@ function PullRequestsRouteView() {...@@ -1186,17 +1181,6 @@ function PullRequestsRouteView() {
1186 : null,1181 : null,
1187 [search.number, search.repository, selectedProject],1182 [search.number, search.repository, selectedProject],
1188 );1183 );
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;
1200 useEffect(() => {1184 useEffect(() => {
1201 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;1185 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
1202 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);1186 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
...@@ -1310,10 +1294,9 @@ function PullRequestsRouteView() {...@@ -1310,10 +1294,9 @@ function PullRequestsRouteView() {
1310 terminalAvailable={false}1294 terminalAvailable={false}
1311 terminalOpen={false}1295 terminalOpen={false}
1312 terminalShortcutLabel={null}1296 terminalShortcutLabel={null}
1313 rightPanelAvailable={rightPanelAvailable}1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}
1314 rightPanelOpen={rightPanelState.isOpen}1298 rightPanelOpen={rightPanelState.isOpen}
1315 rightPanelShortcutLabel={null}1299 rightPanelShortcutLabel={null}
1316 rightPanelUnavailableLabel="Select a pull request first"
1317 liveAgentCount={0}1300 liveAgentCount={0}
1318 onToggleTerminal={() => undefined}1301 onToggleTerminal={() => undefined}
1319 onToggleRightPanel={toggleRightPanel}1302 onToggleRightPanel={toggleRightPanel}
...@@ -1620,6 +1603,7 @@ function PullRequestsRouteView() {...@@ -1620,6 +1603,7 @@ function PullRequestsRouteView() {
1620 reviewingQuery.refresh();1603 reviewingQuery.refresh();
1621 }}1604 }}
1622 onStateChange={handlePullRequestTabStatusChange}1605 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
1623 />1607 />
1624 </RightPanelTabs>1608 </RightPanelTabs>
1625 ) : null}1609 ) : null}
...@@ -1843,10 +1827,18 @@ function PullRequestsColumn({...@@ -1843,10 +1827,18 @@ function PullRequestsColumn({
1843 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a1827 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
1844 // content surface that lets it show reads as a different background than every thread.1828 // content surface that lets it show reads as a different background than every thread.
1845 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">1829 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
1846 {/* A closed right panel leaves this column full-width, so the shared header1830 <header
1847 reserves native window controls. While the panel is open, the column ends1831 className={cn(
1848 at the panel and the absolute controls strip owns the top-right corner. */}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",
1849 <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}>1833 // A closed right panel leaves this column full-width, so its header runs
1834 // underneath the native window controls on Windows; reserve the inset the
1835 // way Settings and the chat view do. While the panel is open the column
1836 // ends at the panel's left edge and the absolute controls strip (already
1837 // WCO-aware) owns the top-right corner.
1838 !rightPanelOpen && "wco:pr-[var(--workspace-native-controls-inset)]",
1839 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1840 )}
1841 >
1850 {condensed ? (1842 {condensed ? (
1851 <WorkspaceBreadcrumb ariaLabel="Pull request scope">1843 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
1852 {/* The page name remains the foreground anchor in both states; the live filters are1844 {/* The page name remains the foreground anchor in both states; the live filters are
...@@ -1888,24 +1880,27 @@ function PullRequestsColumn({...@@ -1888,24 +1880,27 @@ function PullRequestsColumn({
1888 )}1880 )}
1889 <div className="min-w-0 flex-1" />1881 <div className="min-w-0 flex-1" />
1890 {condensed ? (1882 {condensed ? (
1891 <div className="flex shrink-0 items-center gap-1.5">1883 <ExpandableSearch
1892 <ExpandableSearch1884 searchInput={searchInput}
1893 searchInput={searchInput}1885 searchValue={searchValue}
1894 searchValue={searchValue}1886 open={searchOpen}
1895 open={searchOpen}1887 onOpenChange={setSearchOpen}
1896 onOpenChange={setSearchOpen}1888 focusToken={searchFocusToken}
1897 focusToken={searchFocusToken}1889 onFocusWithin={(focused) => {
1898 onFocusWithin={(focused) => {1890 topbarSearchFocusedRef.current = focused;
1899 topbarSearchFocusedRef.current = focused;1891 }}
1900 }}1892 />
1901 />
1902 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
1903 </div>
1904 ) : null}
1905 {rightPanelControl ? (
1906 <WorkspacePageHeaderEdgeControl>{rightPanelControl}</WorkspacePageHeaderEdgeControl>
1907 ) : null}1893 ) : null}
1908 </WorkspacePageHeader>1894 <Button
1895 size="icon-sm"
1896 variant="ghost"
1897 aria-label="Refresh pull requests"
1898 onClick={onRefresh}
1899 >
1900 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1901 </Button>
1902 {rightPanelControl}
1903 </header>
19091904
1910 <div1905 <div
1911 ref={scrollRef}1906 ref={scrollRef}
...@@ -1914,43 +1909,19 @@ function PullRequestsColumn({...@@ -1914,43 +1909,19 @@ function PullRequestsColumn({
1914 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the1909 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
1915 settings page makes: at rest the controls sit fully below the mask, and only1910 settings page makes: at rest the controls sit fully below the mask, and only
1916 content actually passing under the chrome fades. */}1911 content actually passing under the chrome fades. */}
1917 <WorkspacePageContainer className="gap-4">1912 <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
1918 <div className="flex flex-col gap-3">1913 <div className="flex flex-col gap-3">
1919 <div ref={inFlowSearchRef} className="flex items-center gap-2">1914 <div ref={inFlowSearchRef} className="flex items-center gap-2">
1920 {searchInput}1915 {searchInput}
1921 {filtersMenu}1916 {filtersMenu}
1922 {!condensed ? (
1923 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1924 ) : null}
1925 </div>1917 </div>
1926 {/* Scrolled past this marker, the controls are gone and the title takes over. */}1918 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
1927 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />1919 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
1928 </div>1920 </div>
19291921
1930 {listBody}1922 {listBody}
1931 </WorkspacePageContainer>1923 </div>
1932 </div>1924 </div>
1933 </div>1925 </div>
1934 );1926 );
1935}1927}
1936
1937function PullRequestRefreshControl({
1938 compact = false,
1939 refreshing,
1940 onRefresh,
1941}: {
1942 compact?: boolean;
1943 refreshing: boolean;
1944 onRefresh: () => void;
1945}) {
1946 return (
1947 <Button
1948 size={compact ? "icon-sm" : "icon"}
1949 variant={compact ? "ghost" : "outline"}
1950 aria-label="Refresh pull requests"
1951 onClick={onRefresh}
1952 >
1953 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1954 </Button>
1955 );
1956}
apps/web/src/routes/settings.tsx+36-10
...@@ -13,8 +13,9 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels";...@@ -13,8 +13,9 @@ 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";
17import { isElectron } from "../env";16import { isElectron } from "../env";
17import { cn } from "~/lib/utils";
18import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1819
19function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {20function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
20 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);21 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
...@@ -71,16 +72,41 @@ function SettingsContentLayout() {...@@ -71,16 +72,41 @@ function SettingsContentLayout() {
71 return (72 return (
72 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">73 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
73 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">74 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
74 <WorkspacePageHeader electron={isElectron}>75 {!isElectron && (
75 <div className="flex w-full items-center gap-3">76 <header
76 <SettingsBreadcrumb pathname={location.pathname} />77 className={cn(
77 {showRestoreDefaults ? (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",
78 <div className="ms-auto flex items-center gap-2">79 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
79 <RestoreDefaultsButton onRestored={handleRestored} />80 )}
80 </div>81 >
81 ) : null}82 <div className="flex w-full items-center gap-2">
83 <SettingsBreadcrumb pathname={location.pathname} />
84 {showRestoreDefaults ? (
85 <div className="ms-auto flex items-center gap-2">
86 <RestoreDefaultsButton onRestored={handleRestored} />
87 </div>
88 ) : null}
89 </div>
90 </header>
91 )}
92
93 {isElectron && (
94 <div
95 className={cn(
96 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
97 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
98 )}
99 >
100 <div className="flex w-full items-center gap-2">
101 <SettingsBreadcrumb pathname={location.pathname} />
102 {showRestoreDefaults ? (
103 <div className="ms-auto flex items-center gap-2">
104 <RestoreDefaultsButton onRestored={handleRestored} />
105 </div>
106 ) : null}
107 </div>
82 </div>108 </div>
83 </WorkspacePageHeader>109 )}
84110
85 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">111 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
86 <Outlet />112 <Outlet />
apps/web/src/session-logic.test.ts+6-127
...@@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => {...@@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => {
722});722});
723723
724describe("deriveWorkLogEntries", () => {724describe("deriveWorkLogEntries", () => {
725 it("shows a command from its start event while it is still running", () => {725 it("omits tool started entries and keeps completed entries", () => {
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 }),
784 makeActivity({727 makeActivity({
785 id: "tool-complete",728 id: "tool-complete",
786 createdAt: "2026-02-23T00:00:03.000Z",729 createdAt: "2026-02-23T00:00:03.000Z",
787 summary: "Command run",730 summary: "Tool call complete",
788 kind: "tool.completed",
789 payload: {
790 itemType: "command_execution",
791 toolCallId: "call-1",
792 status: "completed",
793 title: "Command run",
794 },
795 }),
796 makeActivity({
797 id: "other-tool-complete",
798 createdAt: "2026-02-23T00:00:04.000Z",
799 summary: "Other command",
800 kind: "tool.completed",731 kind: "tool.completed",
801 payload: {
802 itemType: "command_execution",
803 toolCallId: "call-2",
804 status: "completed",
805 title: "Other command",
806 },
807 }),732 }),
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({733 makeActivity({
831 id: "unkeyed-start-1",734 id: "tool-start",
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",
839 createdAt: "2026-02-23T00:00:02.000Z",735 createdAt: "2026-02-23T00:00:02.000Z",
840 summary: "Command started",736 summary: "Tool call",
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",
853 kind: "tool.started",737 kind: "tool.started",
854 payload: { itemType: "search", title: "Search", status: "inProgress" },
855 }),738 }),
856 ];739 ];
857740
858 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([741 const entries = deriveWorkLogEntries(activities);
859 "unkeyed-start-1",742 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
860 "keyed-start",
861 "unkeyed-start-2",
862 ]);
863 });743 });
864744
865 it("omits task.started but shows task.progress and task.completed", () => {745 it("omits task.started but shows task.progress and task.completed", () => {
...@@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => {...@@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => {
1359 expect(entries).toHaveLength(1);1239 expect(entries).toHaveLength(1);
1360 expect(entries[0]).toMatchObject({1240 expect(entries[0]).toMatchObject({
1361 id: "grep-complete",1241 id: "grep-complete",
1362 toolCallId: "tool-grep-1",
1363 toolTitle: "grep",1242 toolTitle: "grep",
1364 detail: "19 files",1243 detail: "19 files",
1365 itemType: "web_search",1244 itemType: "web_search",
apps/web/src/session-logic.ts+7-98
...@@ -65,8 +65,6 @@ export interface WorkLogEntry {...@@ -65,8 +65,6 @@ 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;
70 label: string;68 label: string;
71 detail?: string;69 detail?: string;
72 command?: string;70 command?: string;
...@@ -750,6 +748,7 @@ export function deriveWorkLogEntries(...@@ -750,6 +748,7 @@ export function deriveWorkLogEntries(
750 const ordered = [...activities].toSorted(compareActivitiesByOrder);748 const ordered = [...activities].toSorted(compareActivitiesByOrder);
751 const entries: DerivedWorkLogEntry[] = [];749 const entries: DerivedWorkLogEntry[] = [];
752 for (const activity of ordered) {750 for (const activity of ordered) {
751 if (activity.kind === "tool.started") continue;
753 // Agent task.started rows are CTA seeds: they carry the true spawn turn,752 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
754 // which is the batch key (completions of background subagents arrive753 // which is the batch key (completions of background subagents arrive
755 // under later synthetic turns and must not start new batches). They754 // under later synthetic turns and must not start new batches). They
...@@ -758,13 +757,8 @@ export function deriveWorkLogEntries(...@@ -758,13 +757,8 @@ export function deriveWorkLogEntries(
758 if (activity.kind === "task.updated") continue;757 if (activity.kind === "task.updated") continue;
759 if (activity.kind === "tool.progress") continue;758 if (activity.kind === "tool.progress") continue;
760 if (activity.kind === "context-window.updated") continue;759 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;
765 if (activity.summary === "Checkpoint captured") continue;760 if (activity.summary === "Checkpoint captured") continue;
766 if (isPlanBoundaryToolActivity(activity)) continue;761 if (isPlanBoundaryToolActivity(activity)) continue;
767 if (isCodexTerminalInteractionActivity(activity)) continue;
768 if (isAgentInternalActivity(activity)) continue;762 if (isAgentInternalActivity(activity)) continue;
769 entries.push(toDerivedWorkLogEntry(activity));763 entries.push(toDerivedWorkLogEntry(activity));
770 }764 }
...@@ -775,11 +769,7 @@ export function deriveWorkLogEntries(...@@ -775,11 +769,7 @@ export function deriveWorkLogEntries(
775}769}
776770
777function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {771function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
778 if (772 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
779 activity.kind !== "tool.started" &&
780 activity.kind !== "tool.updated" &&
781 activity.kind !== "tool.completed"
782 ) {
783 return false;773 return false;
784 }774 }
785775
...@@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
790 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");780 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
791}781}
792782
793/**
794 * Codex terminal interactions report bytes written to an already-running PTY.
795 * Some thread histories contain them as generic tool.updated rows, so filter
796 * their exact wire shape from the presentation model. This repairs existing
797 * history without deleting or rewriting persisted activities.
798 */
799function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean {
800 if (activity.kind !== "tool.updated") {
801 return false;
802 }
803 const payload = asRecord(activity.payload);
804 const data = asRecord(payload?.data);
805 return (
806 payload?.itemType === "command_execution" &&
807 typeof data?.itemId === "string" &&
808 typeof data.processId === "string" &&
809 typeof data.stdin === "string" &&
810 typeof data.threadId === "string" &&
811 typeof data.turnId === "string"
812 );
813}
814
815function extractWorkLogToolLifecycleStatus(783function extractWorkLogToolLifecycleStatus(
816 payload: Record<string, unknown> | null,784 payload: Record<string, unknown> | null,
817): WorkLogToolLifecycleStatus | undefined {785): WorkLogToolLifecycleStatus | undefined {
...@@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
910 entry.toolCallId = toolCallId;878 entry.toolCallId = toolCallId;
911 }879 }
912 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);880 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
913 if (!toolLifecycleStatus && activity.kind === "tool.started") {
914 toolLifecycleStatus = "inProgress";
915 }
916 if (!toolLifecycleStatus && activity.kind === "tool.completed") {881 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
917 toolLifecycleStatus = "completed";882 toolLifecycleStatus = "completed";
918 }883 }
...@@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {...@@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
968 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;933 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
969}934}
970935
971function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
972 if (
973 entry.activityKind !== "tool.started" &&
974 entry.activityKind !== "tool.updated" &&
975 entry.activityKind !== "tool.completed"
976 ) {
977 return undefined;
978 }
979 return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined;
980}
981
982function collapseDerivedWorkLogEntries(936function collapseDerivedWorkLogEntries(
983 entries: ReadonlyArray<DerivedWorkLogEntry>,937 entries: ReadonlyArray<DerivedWorkLogEntry>,
984): DerivedWorkLogEntry[] {938): DerivedWorkLogEntry[] {
...@@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries(...@@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries(
995 // own turn splintered one batch into a stream of "Kicked off N subagents"949 // own turn splintered one batch into a stream of "Kicked off N subagents"
996 // rows (live-test finding, thread 7ac7ef05).950 // rows (live-test finding, thread 7ac7ef05).
997 const groupKeyByTaskId = new Map<string, string>();951 const groupKeyByTaskId = new Map<string, string>();
998 const toolLifecycleRowIndex = new Map<string, number>();
999 for (const entry of entries) {952 for (const entry of entries) {
1000 const isTaskRow =953 const isTaskRow =
1001 entry.taskId !== undefined &&954 entry.taskId !== undefined &&
...@@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries(...@@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries(
1040 });993 });
1041 continue;994 continue;
1042 }995 }
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 }
1060 const previous = collapsed.at(-1);996 const previous = collapsed.at(-1);
1061 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {997 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
1062 const previousIndex = collapsed.length - 1;998 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
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 }
1071 continue;999 continue;
1072 }1000 }
1073 collapsed.push(entry);1001 collapsed.push(entry);
1074 if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") {
1075 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1076 }
1077 }1002 }
1078 return collapsed;1003 return collapsed;
1079}1004}
...@@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries(...@@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries(
1082 previous: DerivedWorkLogEntry,1007 previous: DerivedWorkLogEntry,
1083 next: DerivedWorkLogEntry,1008 next: DerivedWorkLogEntry,
1084): boolean {1009): boolean {
1085 if (1010 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
1086 previous.activityKind !== "tool.started" &&
1087 previous.activityKind !== "tool.updated" &&
1088 previous.activityKind !== "tool.completed"
1089 ) {
1090 return false;1011 return false;
1091 }1012 }
1092 if (1013 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
1093 next.activityKind !== "tool.started" &&
1094 next.activityKind !== "tool.updated" &&
1095 next.activityKind !== "tool.completed"
1096 ) {
1097 return false;1014 return false;
1098 }1015 }
1099 if (previous.activityKind === "tool.completed") {1016 if (previous.activityKind === "tool.completed") {
...@@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un...@@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
1163 ) {1080 ) {
1164 return `task${entry.taskId}`;1081 return `task${entry.taskId}`;
1165 }1082 }
1166 if (1083 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
1167 entry.activityKind !== "tool.started" &&
1168 entry.activityKind !== "tool.updated" &&
1169 entry.activityKind !== "tool.completed"
1170 ) {
1171 return undefined;1084 return undefined;
1172 }1085 }
1173 if (entry.toolCallId) {1086 if (entry.toolCallId) {
...@@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {...@@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
1370 const item = asRecord(data?.item);1283 const item = asRecord(data?.item);
1371 const itemResult = asRecord(item?.result);1284 const itemResult = asRecord(item?.result);
1372 const itemInput = asRecord(item?.input);1285 const itemInput = asRecord(item?.input);
1373 const dataInput = asRecord(data?.input);
1374 const stateInput = asRecord(asRecord(data?.state)?.input);
1375 const itemType = asTrimmedString(payload?.itemType);1286 const itemType = asTrimmedString(payload?.itemType);
1376 const detail = asTrimmedString(payload?.detail);1287 const detail = asTrimmedString(payload?.detail);
1377 const candidates: unknown[] = [1288 const candidates: unknown[] = [
...@@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {...@@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
1379 itemInput?.command,1290 itemInput?.command,
1380 itemResult?.command,1291 itemResult?.command,
1381 data?.command,1292 data?.command,
1382 dataInput?.command,
1383 stateInput?.command,
1384 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,1293 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
1385 ];1294 ];
13861295
...@@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul...@@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
14071316
1408function extractToolCallId(payload: Record<string, unknown> | null): string | null {1317function extractToolCallId(payload: Record<string, unknown> | null): string | null {
1409 const data = asRecord(payload?.data);1318 const data = asRecord(payload?.data);
1410 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);1319 return asTrimmedString(data?.toolCallId);
1411}1320}
14121321
1413function normalizeInlinePreview(value: string): string {1322function normalizeInlinePreview(value: string): string {
apps/web/src/terminalUiStateStore.test.ts-8
...@@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => {...@@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => {
18 useTerminalUiStateStore.persist.clearStorage();18 useTerminalUiStateStore.persist.clearStorage();
19 useTerminalUiStateStore.setState({19 useTerminalUiStateStore.setState({
20 terminalUiStateByThreadKey: {},20 terminalUiStateByThreadKey: {},
21 terminalCustomLabelsByThreadKey: {},
22 suppressedTerminalIdsByThreadKey: {},21 suppressedTerminalIdsByThreadKey: {},
23 });22 });
24 });23 });
...@@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => {...@@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => {
249 it("reconciles terminal ids from an external ordered list", () => {248 it("reconciles terminal ids from an external ordered list", () => {
250 const store = useTerminalUiStateStore.getState();249 const store = useTerminalUiStateStore.getState();
251 store.setTerminalOpen(THREAD_REF, true);250 store.setTerminalOpen(THREAD_REF, true);
252 store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server");
253 store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task");
254 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);251 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
255252
256 const terminalUiState = selectThreadTerminalUiState(253 const terminalUiState = selectThreadTerminalUiState(
...@@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => {...@@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => {
263 { id: "group-term-a", terminalIds: ["term-a"] },260 { id: "group-term-a", terminalIds: ["term-a"] },
264 { id: "group-term-b", terminalIds: ["term-b"] },261 { id: "group-term-b", terminalIds: ["term-b"] },
265 ]);262 ]);
266 expect(
267 useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[
268 scopedThreadKey(THREAD_REF)
269 ],
270 ).toEqual({ "term-a": "API server" });
271 });263 });
272264
273 it("does not import a closed panel terminal from stale metadata", () => {265 it("does not import a closed panel terminal from stale metadata", () => {
apps/web/src/terminalUiStateStore.ts+16-154
...@@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1";...@@ -32,11 +32,8 @@ 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>>;
36}35}
3736
38const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({});
39
40export function migratePersistedTerminalUiStateStoreState(37export function migratePersistedTerminalUiStateStoreState(
41 persistedState: unknown,38 persistedState: unknown,
42 _version: number,39 _version: number,
...@@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState(...@@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState(
53 parseScopedThreadKey(threadKey),50 parseScopedThreadKey(threadKey),
54 ),51 ),
55 );52 );
56 const terminalCustomLabelsByThreadKey = Object.fromEntries(
57 Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap(
58 ([threadKey, labels]) => {
59 if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return [];
60 const normalizedLabels = Object.fromEntries(
61 Object.entries(labels).flatMap(([terminalId, label]) => {
62 const normalizedTerminalId = terminalId.trim();
63 const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : "";
64 return normalizedTerminalId && normalizedLabel
65 ? [[normalizedTerminalId, normalizedLabel] as const]
66 : [];
67 }),
68 );
69 return Object.keys(normalizedLabels).length > 0
70 ? [[threadKey, normalizedLabels] as const]
71 : [];
72 },
73 ),
74 );
7553
76 return {54 return { terminalUiStateByThreadKey };
77 terminalUiStateByThreadKey,
78 ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0
79 ? { terminalCustomLabelsByThreadKey }
80 : {}),
81 };
82}55}
8356
84function createTerminalUiStateStorage() {57function createTerminalUiStateStorage() {
...@@ -516,18 +489,6 @@ export function selectThreadTerminalUiState(...@@ -516,18 +489,6 @@ export function selectThreadTerminalUiState(
516 );489 );
517}490}
518491
519export function selectThreadTerminalCustomLabels(
520 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>,
521 threadRef: ScopedThreadRef | null | undefined,
522): Readonly<Record<string, string>> {
523 if (!threadRef || threadRef.threadId.length === 0) {
524 return EMPTY_TERMINAL_CUSTOM_LABELS;
525 }
526 return (
527 terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS
528 );
529}
530
531function updateTerminalUiStateByThreadKey(492function updateTerminalUiStateByThreadKey(
532 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,493 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
533 threadRef: ScopedThreadRef,494 threadRef: ScopedThreadRef,
...@@ -601,7 +562,6 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st...@@ -601,7 +562,6 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st
601562
602interface TerminalUiStateStoreState {563interface TerminalUiStateStoreState {
603 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;564 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
604 terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>;
605 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */565 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */
606 suppressedTerminalIdsByThreadKey: Record<string, string[]>;566 suppressedTerminalIdsByThreadKey: Record<string, string[]>;
607 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;567 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
...@@ -615,11 +575,6 @@ interface TerminalUiStateStoreState {...@@ -615,11 +575,6 @@ interface TerminalUiStateStoreState {
615 options?: { open?: boolean; active?: boolean },575 options?: { open?: boolean; active?: boolean },
616 ) => void;576 ) => void;
617 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;577 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
618 setTerminalCustomLabel: (
619 threadRef: ScopedThreadRef,
620 terminalId: string,
621 label: string | null,
622 ) => void;
623 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;578 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
624 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;579 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
625 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;580 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
...@@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
636 state: ThreadTerminalUiState,591 state: ThreadTerminalUiState,
637 suppressedTerminalIds: readonly string[],592 suppressedTerminalIds: readonly string[],
638 ) => ThreadTerminalUiState,593 ) => ThreadTerminalUiState,
639 suppression?: {594 suppression?: { terminalId: string; suppressed: boolean },
640 terminalId: string;
641 suppressed: boolean;
642 clearCustomLabel?: boolean;
643 },
644 pruneCustomLabels = false,
645 ) => {595 ) => {
646 set((state) => {596 set((state) => {
647 const threadKey = terminalThreadKey(threadRef);597 const threadKey = terminalThreadKey(threadRef);
...@@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
659 suppression.suppressed,609 suppression.suppressed,
660 )610 )
661 : state.suppressedTerminalIdsByThreadKey;611 : 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 }
695 if (612 if (
696 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&613 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
697 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey &&614 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey
698 nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey
699 ) {615 ) {
700 return state;616 return state;
701 }617 }
702 return {618 return {
703 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,619 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
704 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,620 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
705 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
706 };621 };
707 });622 });
708 };623 };
709624
710 return {625 return {
711 terminalUiStateByThreadKey: {},626 terminalUiStateByThreadKey: {},
712 terminalCustomLabelsByThreadKey: {},
713 suppressedTerminalIdsByThreadKey: {},627 suppressedTerminalIdsByThreadKey: {},
714 setTerminalOpen: (threadRef, open) => {628 setTerminalOpen: (threadRef, open) => {
715 const terminalState = selectThreadTerminalUiState(629 const terminalState = selectThreadTerminalUiState(
...@@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
768 ),682 ),
769 setActiveTerminal: (threadRef, terminalId) =>683 setActiveTerminal: (threadRef, terminalId) =>
770 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),684 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 }),
799 closeTerminal: (threadRef, terminalId) =>685 closeTerminal: (threadRef, terminalId) =>
800 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {686 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {
801 terminalId,687 terminalId,
802 suppressed: true,688 suppressed: true,
803 clearCustomLabel: true,
804 }),689 }),
805 reconcileTerminalIds: (threadRef, nextIds) =>690 reconcileTerminalIds: (threadRef, nextIds) =>
806 updateTerminal(691 updateTerminal(threadRef, (state, suppressedTerminalIds) => {
807 threadRef,692 if (suppressedTerminalIds.length === 0) {
808 (state, suppressedTerminalIds) => {693 return reconcileThreadTerminalSessionIds(state, nextIds);
809 if (suppressedTerminalIds.length === 0) {694 }
810 return reconcileThreadTerminalSessionIds(state, nextIds);695 const suppressedIds = new Set(suppressedTerminalIds);
811 }696 return reconcileThreadTerminalSessionIds(
812 const suppressedIds = new Set(suppressedTerminalIds);697 state,
813 return reconcileThreadTerminalSessionIds(698 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),
814 state,699 );
815 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),700 }),
816 );
817 },
818 undefined,
819 true,
820 ),
821 clearTerminalUiState: (threadRef) =>701 clearTerminalUiState: (threadRef) =>
822 set((state) => {702 set((state) => {
823 const threadKey = terminalThreadKey(threadRef);703 const threadKey = terminalThreadKey(threadRef);
...@@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
828 );708 );
829 const hadSuppressedTerminalIds =709 const hadSuppressedTerminalIds =
830 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;710 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
831 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;
832 if (711 if (
833 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&712 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
834 !hadSuppressedTerminalIds &&713 !hadSuppressedTerminalIds
835 !hadCustomLabels
836 ) {714 ) {
837 return state;715 return state;
838 }716 }
839 return {717 return {
840 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,718 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
841 terminalCustomLabelsByThreadKey: removeRecordEntry(
842 state.terminalCustomLabelsByThreadKey,
843 threadKey,
844 ),
845 suppressedTerminalIdsByThreadKey: removeRecordEntry(719 suppressedTerminalIdsByThreadKey: removeRecordEntry(
846 state.suppressedTerminalIdsByThreadKey,720 state.suppressedTerminalIdsByThreadKey,
847 threadKey,721 threadKey,
...@@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
854 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;728 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
855 const hadSuppressedTerminalIds =729 const hadSuppressedTerminalIds =
856 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;730 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
857 const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined;731 if (!hadTerminalUiState && !hadSuppressedTerminalIds) {
858 if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) {
859 return state;732 return state;
860 }733 }
861 return {734 return {
...@@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
863 state.terminalUiStateByThreadKey,736 state.terminalUiStateByThreadKey,
864 threadKey,737 threadKey,
865 ),738 ),
866 terminalCustomLabelsByThreadKey: removeRecordEntry(
867 state.terminalCustomLabelsByThreadKey,
868 threadKey,
869 ),
870 suppressedTerminalIdsByThreadKey: removeRecordEntry(739 suppressedTerminalIdsByThreadKey: removeRecordEntry(
871 state.suppressedTerminalIdsByThreadKey,740 state.suppressedTerminalIdsByThreadKey,
872 threadKey,741 threadKey,
...@@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
878 const orphanedIds = new Set(747 const orphanedIds = new Set(
879 [748 [
880 ...Object.keys(state.terminalUiStateByThreadKey),749 ...Object.keys(state.terminalUiStateByThreadKey),
881 ...Object.keys(state.terminalCustomLabelsByThreadKey),
882 ...Object.keys(state.suppressedTerminalIdsByThreadKey),750 ...Object.keys(state.suppressedTerminalIdsByThreadKey),
883 ].filter((key) => !activeThreadKeys.has(key)),751 ].filter((key) => !activeThreadKeys.has(key)),
884 );752 );
...@@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
889 const nextSuppressedTerminalIdsByThreadKey = {757 const nextSuppressedTerminalIdsByThreadKey = {
890 ...state.suppressedTerminalIdsByThreadKey,758 ...state.suppressedTerminalIdsByThreadKey,
891 };759 };
892 const nextTerminalCustomLabelsByThreadKey = {
893 ...state.terminalCustomLabelsByThreadKey,
894 };
895 for (const id of orphanedIds) {760 for (const id of orphanedIds) {
896 delete nextTerminalUiStateByThreadKey[id];761 delete nextTerminalUiStateByThreadKey[id];
897 delete nextTerminalCustomLabelsByThreadKey[id];
898 delete nextSuppressedTerminalIdsByThreadKey[id];762 delete nextSuppressedTerminalIdsByThreadKey[id];
899 }763 }
900 return {764 return {
901 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,765 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
902 terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey,
903 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,766 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
904 };767 };
905 }),768 }),
...@@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
907 },770 },
908 {771 {
909 name: TERMINAL_UI_STATE_STORAGE_KEY,772 name: TERMINAL_UI_STATE_STORAGE_KEY,
910 version: 5,773 version: 4,
911 storage: createJSONStorage(createTerminalUiStateStorage),774 storage: createJSONStorage(createTerminalUiStateStorage),
912 migrate: migratePersistedTerminalUiStateStoreState,775 migrate: migratePersistedTerminalUiStateStoreState,
913 partialize: (state) => ({776 partialize: (state) => ({
914 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,777 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
915 terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey,
916 }),778 }),
917 },779 },
918 ),780 ),
packages/contracts/src/ipc.ts-4
...@@ -111,8 +111,6 @@ export interface ContextMenuItem<T extends string = string> {...@@ -111,8 +111,6 @@ 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;
116 children?: readonly ContextMenuItem<T>[];114 children?: readonly ContextMenuItem<T>[];
117}115}
118116
...@@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType {...@@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType {
123 readonly disabled?: boolean;121 readonly disabled?: boolean;
124 readonly header?: boolean;122 readonly header?: boolean;
125 readonly icon?: string;123 readonly icon?: string;
126 readonly separatorBefore?: boolean;
127 readonly children?: readonly ContextMenuItemSchemaType[];124 readonly children?: readonly ContextMenuItemSchemaType[];
128}125}
129126
...@@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc...@@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
134 disabled: Schema.optionalKey(Schema.Boolean),131 disabled: Schema.optionalKey(Schema.Boolean),
135 header: Schema.optionalKey(Schema.Boolean),132 header: Schema.optionalKey(Schema.Boolean),
136 icon: Schema.optionalKey(Schema.String),133 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
138 children: Schema.optionalKey(134 children: Schema.optionalKey(
139 Schema.Array(135 Schema.Array(
140 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),136 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
packages/shared/src/usageMerge.test.ts-1
...@@ -248,7 +248,6 @@ describe("mergeUsage", () => {...@@ -248,7 +248,6 @@ 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);
252 });251 });
253252
254 it("returns empty totals with no environments", () => {253 it("returns empty totals with no environments", () => {
packages/shared/src/usageMerge.ts+11-29
...@@ -25,7 +25,6 @@ export interface ProviderTotals {...@@ -25,7 +25,6 @@ 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;
29 readonly costShare: number;28 readonly costShare: number;
30 readonly tokenShare: number;29 readonly tokenShare: number;
31}30}
...@@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): {...@@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
136function ownedContribution(135function ownedContribution(
137 environment: EnvironmentUsage,136 environment: EnvironmentUsage,
138 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,137 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
139): {138): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
140 readonly buckets: readonly UsageBucket[];
141 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
142} {
143 const ownedProviders = new Set<UsageProviderKind>();139 const ownedProviders = new Set<UsageProviderKind>();
144 const sessionsByProvider = new Map<UsageProviderKind, number>();140 let sessions = 0;
145 for (const source of environment.summary.sources) {141 for (const source of environment.summary.sources) {
146 if (source.status === "missing") continue;142 if (source.status === "missing") continue;
147 const key = fingerprintKey(source.fingerprint);143 const key = fingerprintKey(source.fingerprint);
148 if (ownerByFingerprint.get(key) === environment.environmentId) {144 if (ownerByFingerprint.get(key) === environment.environmentId) {
149 const provider = source.fingerprint.provider;145 ownedProviders.add(source.fingerprint.provider);
150 ownedProviders.add(provider);
151 // Distinct within a directory. Summing per-bucket session counts instead146 // Distinct within a directory. Summing per-bucket session counts instead
152 // would count a session once per day and model it spans.147 // would count a session once per day and model it spans.
153 sessionsByProvider.set(148 sessions += source.distinctSessions;
154 provider,
155 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
156 );
157 }149 }
158 }150 }
159 return {151 return {
160 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),152 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
161 sessionsByProvider,153 sessions,
162 };154 };
163}155}
164156
...@@ -236,7 +228,7 @@ export function mergeUsage(...@@ -236,7 +228,7 @@ export function mergeUsage(
236228
237 const providerAccumulator = new Map<229 const providerAccumulator = new Map<
238 UsageProviderKind,230 UsageProviderKind,
239 { costUsd: number; totalTokens: number; records: number; sessions: number }231 { costUsd: number; totalTokens: number; records: number }
240 >();232 >();
241 const modelAccumulator = new Map<233 const modelAccumulator = new Map<
242 string,234 string,
...@@ -263,20 +255,12 @@ export function mergeUsage(...@@ -263,20 +255,12 @@ export function mergeUsage(
263 const contributingEnvironments: EnvironmentId[] = [];255 const contributingEnvironments: EnvironmentId[] = [];
264256
265 for (const environment of current) {257 for (const environment of current) {
266 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);258 const { buckets, sessions: environmentSessions } = ownedContribution(
259 environment,
260 ownerByFingerprint,
261 );
267 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);262 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
268263 sessions += environmentSessions;
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 }
280264
281 for (const bucket of buckets) {265 for (const bucket of buckets) {
282 const tokens = bucketTokens(bucket);266 const tokens = bucketTokens(bucket);
...@@ -296,7 +280,6 @@ export function mergeUsage(...@@ -296,7 +280,6 @@ export function mergeUsage(
296 costUsd: 0,280 costUsd: 0,
297 totalTokens: 0,281 totalTokens: 0,
298 records: 0,282 records: 0,
299 sessions: 0,
300 };283 };
301 provider.costUsd += bucket.costUsd;284 provider.costUsd += bucket.costUsd;
302 provider.totalTokens += tokens;285 provider.totalTokens += tokens;
...@@ -358,7 +341,6 @@ export function mergeUsage(...@@ -358,7 +341,6 @@ export function mergeUsage(
358 costUsd: totals.costUsd,341 costUsd: totals.costUsd,
359 totalTokens: totals.totalTokens,342 totalTokens: totals.totalTokens,
360 records: totals.records,343 records: totals.records,
361 sessions: totals.sessions,
362 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,344 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
363 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,345 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
364 }))346 }))