| author | |
| committer | |
| log | d7abd7f3bb6f392ecb4ca21a1eeaec5bbc2d9393 |
| tree | 5e10f71f70fc228b1ae14edfe1daa42c3169157a |
| parent | 57a299a7852b430613dfdd97ba76249ee9f374d5 |
| signature | Signed by PGP key B5690EEEBB952194 |
52 files changed, 3228 insertions(+), 1727 deletions(-)
apps/desktop/src/electron/ElectronMenu.test.ts+10-1| ... | ... | @@ -98,7 +98,10 @@ describe("ElectronMenu", () => { |
| 98 | 98 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 99 | 99 | const selectedItemId = yield* electronMenu.showContextMenu({ |
| 100 | 100 | window: makeWindow(2), |
| 101 | items: [{ id: "copy", label: "Copy" }], | |
| 101 | items: [ | |
| 102 | { id: "copy", label: "Copy" }, | |
| 103 | { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, | |
| 104 | ], | |
| 102 | 105 | position: Option.some({ x: 10.8, y: 20.2 }), |
| 103 | 106 | }); |
| 104 | 107 | |
| ... | ... | @@ -110,6 +113,12 @@ describe("ElectronMenu", () => { |
| 110 | 113 | enabled: true, |
| 111 | 114 | click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, |
| 112 | 115 | }); |
| 116 | assert.deepEqual( | |
| 117 | buildFromTemplateMock.mock.calls[0]?.[0].map( | |
| 118 | (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, | |
| 119 | ), | |
| 120 | ["Copy", "separator", "Delete"], | |
| 121 | ); | |
| 113 | 122 | }).pipe(Effect.provide(TestLayer)), |
| 114 | 123 | ); |
| 115 | 124 |
apps/desktop/src/electron/ElectronMenu.ts+9-1| ... | ... | @@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM |
| 78 | 78 | label: sourceItem.label, |
| 79 | 79 | destructive: sourceItem.destructive === true, |
| 80 | 80 | disabled: sourceItem.disabled === true, |
| 81 | ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), | |
| 81 | 82 | }; |
| 82 | 83 | |
| 83 | 84 | if (sourceItem.children) { |
| ... | ... | @@ -141,10 +142,17 @@ export const make = Effect.gen(function* () { |
| 141 | 142 | ): Electron.MenuItemConstructorOptions[] => { |
| 142 | 143 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 143 | 144 | let hasInsertedDestructiveSeparator = false; |
| 145 | const appendSeparator = () => { | |
| 146 | if (template.length === 0 || template.at(-1)?.type === "separator") return; | |
| 147 | template.push({ type: "separator" }); | |
| 148 | }; | |
| 144 | 149 | |
| 145 | 150 | for (const item of entries) { |
| 151 | if (item.separatorBefore) { | |
| 152 | appendSeparator(); | |
| 153 | } | |
| 146 | 154 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { |
| 147 | template.push({ type: "separator" }); | |
| 155 | appendSeparator(); | |
| 148 | 156 | hasInsertedDestructiveSeparator = true; |
| 149 | 157 | } |
| 150 | 158 |
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+40-1| ... | ... | @@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity |
| 20 | 20 | * If slimming ever moves to an allowlist over the whole payload, these |
| 21 | 21 | * assertions are the tripwire. |
| 22 | 22 | */ |
| 23 | describe("projectActivityPayload agent-field survival", () => { | |
| 23 | describe("projectActivityPayload", () => { | |
| 24 | 24 | it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { |
| 25 | 25 | const projected = projectActivityPayload( |
| 26 | 26 | activity({ |
| ... | ... | @@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => { |
| 44 | 44 | expect(data.somethingClientNeverReads).toBeUndefined(); |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { | |
| 48 | const claude = projectActivityPayload( | |
| 49 | activity({ | |
| 50 | itemType: "command_execution", | |
| 51 | toolCallId: "claude-call-1", | |
| 52 | data: { | |
| 53 | toolName: "Bash", | |
| 54 | input: { command: "vp test run" }, | |
| 55 | result: { content: "x".repeat(5_000) }, | |
| 56 | }, | |
| 57 | }), | |
| 58 | ); | |
| 59 | const openCode = projectActivityPayload( | |
| 60 | activity({ | |
| 61 | itemType: "command_execution", | |
| 62 | toolCallId: "opencode-call-1", | |
| 63 | data: { | |
| 64 | tool: "bash", | |
| 65 | state: { | |
| 66 | status: "running", | |
| 67 | input: { command: "vp lint" }, | |
| 68 | output: "x".repeat(5_000), | |
| 69 | }, | |
| 70 | }, | |
| 71 | }), | |
| 72 | ); | |
| 73 | ||
| 74 | expect(claude.payload).toMatchObject({ | |
| 75 | toolCallId: "claude-call-1", | |
| 76 | data: { command: "vp test run" }, | |
| 77 | }); | |
| 78 | expect(openCode.payload).toMatchObject({ | |
| 79 | toolCallId: "opencode-call-1", | |
| 80 | data: { command: "vp lint" }, | |
| 81 | }); | |
| 82 | expect(JSON.stringify(claude.payload).length).toBeLessThan(200); | |
| 83 | expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); | |
| 84 | }); | |
| 85 | ||
| 47 | 86 | it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { |
| 48 | 87 | const projected = projectActivityPayload( |
| 49 | 88 | activity({ |
apps/server/src/orchestration/ActivityPayloadProjection.ts+27-7| ... | ... | @@ -104,6 +104,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno |
| 104 | 104 | return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | function 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 | ||
| 107 | 125 | function summarizeToolTextOutput(value: string): string | null { |
| 108 | 126 | const lines: string[] = []; |
| 109 | 127 | for (const rawLine of value.split(/\r?\n/u)) { |
| ... | ... | @@ -287,8 +305,9 @@ export function projectActivityPayload( |
| 287 | 305 | if (item) { |
| 288 | 306 | projectedData.item = item; |
| 289 | 307 | } |
| 290 | if ("command" in data) { | |
| 291 | projectedData.command = data.command; | |
| 308 | const command = projectCommandValue(data); | |
| 309 | if (command !== undefined) { | |
| 310 | projectedData.command = command; | |
| 292 | 311 | } |
| 293 | 312 | |
| 294 | 313 | const changedFiles: string[] = []; |
| ... | ... | @@ -368,10 +387,10 @@ function dropStaleContextWindowActivities( |
| 368 | 387 | /** |
| 369 | 388 | * Identity both clients use to fold a tool lifecycle row into the call it |
| 370 | 389 | * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and |
| 371 | * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter | |
| 372 | * emits one, otherwise the itemType/title/detail triple. Returns null for rows | |
| 373 | * with no identity at all — those never collapse on the client either, so they | |
| 374 | * must not be dropped here. | |
| 390 | * mobile's `threadActivity`): the runtime item id ingestion stamps as | |
| 391 | * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. | |
| 392 | * Returns null for rows with no identity at all — those never collapse on the | |
| 393 | * client either, so they must not be dropped here. | |
| 375 | 394 | */ |
| 376 | 395 | function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { |
| 377 | 396 | const payload = asRecord(activity.payload); |
| ... | ... | @@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | |
| 379 | 398 | return null; |
| 380 | 399 | } |
| 381 | 400 | |
| 382 | const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); | |
| 401 | const toolCallId = | |
| 402 | asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); | |
| 383 | 403 | if (toolCallId) { |
| 384 | 404 | return `id:${toolCallId}`; |
| 385 | 405 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+22-8| ... | ... | @@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => { |
| 2811 | 2811 | createdAt: now, |
| 2812 | 2812 | threadId: asThreadId("thread-1"), |
| 2813 | 2813 | turnId: asTurnId("turn-9"), |
| 2814 | itemId: asItemId("tool-call-9"), | |
| 2814 | 2815 | payload: { |
| 2815 | 2816 | itemType: "command_execution", |
| 2816 | status: "in_progress", | |
| 2817 | title: "Read file", | |
| 2818 | detail: "/tmp/file.ts", | |
| 2817 | status: "inProgress", | |
| 2818 | title: "Command run", | |
| 2819 | detail: "Bash: vp test run", | |
| 2820 | data: { | |
| 2821 | toolName: "Bash", | |
| 2822 | input: { command: "vp test run" }, | |
| 2823 | }, | |
| 2819 | 2824 | }, |
| 2820 | 2825 | }); |
| 2821 | 2826 | |
| ... | ... | @@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => { |
| 2830 | 2835 | ); |
| 2831 | 2836 | |
| 2832 | 2837 | expect(thread.session?.status).toBe("ready"); |
| 2833 | expect( | |
| 2834 | thread.activities.some( | |
| 2835 | (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", | |
| 2836 | ), | |
| 2837 | ).toBe(true); | |
| 2838 | const activity = thread.activities.find( | |
| 2839 | (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", | |
| 2840 | ); | |
| 2841 | const payload = activity?.payload as Record<string, unknown> | undefined; | |
| 2842 | expect(payload).toMatchObject({ | |
| 2843 | itemType: "command_execution", | |
| 2844 | toolCallId: "tool-call-9", | |
| 2845 | status: "inProgress", | |
| 2846 | detail: "Bash: vp test run", | |
| 2847 | data: { | |
| 2848 | toolName: "Bash", | |
| 2849 | input: { command: "vp test run" }, | |
| 2850 | }, | |
| 2851 | }); | |
| 2838 | 2852 | }); |
| 2839 | 2853 | |
| 2840 | 2854 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6| ... | ... | @@ -794,6 +794,7 @@ export function runtimeEventToActivities( |
| 794 | 794 | summary: event.payload.title ?? "Tool updated", |
| 795 | 795 | payload: { |
| 796 | 796 | itemType: event.payload.itemType, |
| 797 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 797 | 798 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 798 | 799 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 799 | 800 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| ... | ... | @@ -821,6 +822,8 @@ export function runtimeEventToActivities( |
| 821 | 822 | summary: event.payload.title ?? "Tool", |
| 822 | 823 | payload: { |
| 823 | 824 | itemType: event.payload.itemType, |
| 825 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 826 | ...(event.payload.status ? { status: event.payload.status } : {}), | |
| 824 | 827 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 825 | 828 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 826 | 829 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| ... | ... | @@ -847,7 +850,10 @@ export function runtimeEventToActivities( |
| 847 | 850 | summary: `${event.payload.title ?? "Tool"} started`, |
| 848 | 851 | payload: { |
| 849 | 852 | itemType: event.payload.itemType, |
| 853 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 854 | ...(event.payload.status ? { status: event.payload.status } : {}), | |
| 850 | 855 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 856 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | |
| 851 | 857 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 852 | 858 | ...(event.payload.parentToolUseId |
| 853 | 859 | ? { parentToolUseId: event.payload.parentToolUseId } |
apps/web/src/components/ChatView.tsx+32-26| ... | ... | @@ -167,7 +167,6 @@ import { |
| 167 | 167 | WifiOffIcon, |
| 168 | 168 | } from "lucide-react"; |
| 169 | 169 | import { cn, randomHex } from "~/lib/utils"; |
| 170 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 171 | 170 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 172 | 171 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 173 | 172 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | ... | @@ -220,7 +219,11 @@ import { |
| 220 | 219 | import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; |
| 221 | 220 | import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; |
| 222 | 221 | import { environmentCatalog } from "../connection/catalog"; |
| 223 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | |
| 222 | import { | |
| 223 | selectThreadTerminalCustomLabels, | |
| 224 | selectThreadTerminalUiState, | |
| 225 | useTerminalUiStateStore, | |
| 226 | } from "../terminalUiStateStore"; | |
| 224 | 227 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 225 | 228 | import { projectEnvironment } from "../state/projects"; |
| 226 | 229 | import { useEnvironmentQuery } from "../state/query"; |
| ... | ... | @@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader"; |
| 256 | 259 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 257 | 260 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 258 | 261 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 262 | import { WorkspacePageHeader } from "./WorkspacePageContainer"; | |
| 259 | 263 | import { |
| 260 | 264 | resolveEffectiveEnvMode, |
| 261 | 265 | resolveLocalCheckoutBranchMismatch, |
| ... | ... | @@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps { |
| 653 | 657 | newShortcutLabel: string | undefined; |
| 654 | 658 | closeShortcutLabel: string | undefined; |
| 655 | 659 | keybindings: ResolvedKeybindingsConfig; |
| 660 | onHide: () => void; | |
| 656 | 661 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 657 | 662 | } |
| 658 | 663 | |
| ... | ... | @@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 667 | 672 | newShortcutLabel, |
| 668 | 673 | closeShortcutLabel, |
| 669 | 674 | keybindings, |
| 675 | onHide, | |
| 670 | 676 | onAddTerminalContext, |
| 671 | 677 | }: PersistentThreadTerminalDrawerProps) { |
| 672 | 678 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| ... | ... | @@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 990 | 996 | onSplitTerminal={splitTerminal} |
| 991 | 997 | onSplitTerminalVertical={splitTerminalVertical} |
| 992 | 998 | onNewTerminal={createNewTerminal} |
| 999 | onHide={onHide} | |
| 993 | 1000 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} |
| 994 | 1001 | splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} |
| 995 | 1002 | newShortcutLabel={visible ? newShortcutLabel : undefined} |
| ... | ... | @@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 1540 | 1547 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1541 | 1548 | const activeThreadId = activeThread?.id ?? null; |
| 1542 | 1549 | const activeThreadEnvironmentId = activeThread?.environmentId ?? null; |
| 1550 | const activeThreadRef = useMemo( | |
| 1551 | () => | |
| 1552 | activeThreadEnvironmentId && activeThreadId | |
| 1553 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | |
| 1554 | : null, | |
| 1555 | [activeThreadEnvironmentId, activeThreadId], | |
| 1556 | ); | |
| 1557 | const activeTerminalCustomLabels = useTerminalUiStateStore((state) => | |
| 1558 | selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), | |
| 1559 | ); | |
| 1543 | 1560 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1544 | 1561 | environmentId: activeThread?.environmentId ?? null, |
| 1545 | 1562 | threadId: activeThreadId, |
| ... | ... | @@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 1569 | 1586 | for (const session of activeThreadKnownSessions) { |
| 1570 | 1587 | labels.set( |
| 1571 | 1588 | session.target.terminalId, |
| 1572 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | |
| 1589 | activeTerminalCustomLabels[session.target.terminalId] ?? | |
| 1590 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | |
| 1573 | 1591 | ); |
| 1574 | 1592 | } |
| 1593 | for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { | |
| 1594 | if (!labels.has(terminalId)) labels.set(terminalId, label); | |
| 1595 | } | |
| 1575 | 1596 | return labels; |
| 1576 | }, [activeThreadKnownSessions]); | |
| 1577 | const activeThreadRef = useMemo( | |
| 1578 | () => | |
| 1579 | activeThreadEnvironmentId && activeThreadId | |
| 1580 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | |
| 1581 | : null, | |
| 1582 | [activeThreadEnvironmentId, activeThreadId], | |
| 1583 | ); | |
| 1597 | }, [activeTerminalCustomLabels, activeThreadKnownSessions]); | |
| 1584 | 1598 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1585 | 1599 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1586 | 1600 | readonly threadKey: string | null; |
| ... | ... | @@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2808 | 2822 | }, |
| 2809 | 2823 | [activeThreadRef, storeSetTerminalOpen], |
| 2810 | 2824 | ); |
| 2825 | const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); | |
| 2811 | 2826 | const toggleTerminalVisibility = useCallback(() => { |
| 2812 | 2827 | if (!activeThreadRef) return; |
| 2813 | 2828 | const nextOpen = !terminalUiState.terminalOpen; |
| ... | ... | @@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6114 | 6129 | ? "thread" |
| 6115 | 6130 | : "page" |
| 6116 | 6131 | } |
| 6117 | chromeVariant="collapse" | |
| 6118 | 6132 | composerDraftTarget={composerDraftTarget} |
| 6119 | 6133 | onStateChange={handlePullRequestTabStatusChange} |
| 6120 | 6134 | /> |
| ... | ... | @@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 6160 | 6174 | data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} |
| 6161 | 6175 | > |
| 6162 | 6176 | {/* Top bar */} |
| 6163 | <header | |
| 6177 | <WorkspacePageHeader | |
| 6164 | 6178 | data-chat-header |
| 6165 | className={cn( | |
| 6166 | "bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none", | |
| 6167 | isElectron | |
| 6168 | ? cn( | |
| 6169 | "drag-region relative flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5", | |
| 6170 | reserveTitleBarControlInset && | |
| 6171 | !inlineRightPanelOwnsTitleBar && | |
| 6172 | "wco:pr-[var(--workspace-native-controls-inset)]", | |
| 6173 | ) | |
| 6174 | : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]", | |
| 6175 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 6176 | )} | |
| 6179 | electron={isElectron} | |
| 6180 | reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar} | |
| 6181 | className="relative bg-background" | |
| 6177 | 6182 | > |
| 6178 | 6183 | {!rightPanelOpen ? panelLayoutControls : null} |
| 6179 | 6184 | <ChatHeader |
| ... | ... | @@ -6204,7 +6209,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6204 | 6209 | onUpdateProjectScript={updateProjectScript} |
| 6205 | 6210 | onDeleteProjectScript={deleteProjectScript} |
| 6206 | 6211 | /> |
| 6207 | </header> | |
| 6212 | </WorkspacePageHeader> | |
| 6208 | 6213 | |
| 6209 | 6214 | <ThreadErrorBanner |
| 6210 | 6215 | error={visibleThreadError} |
| ... | ... | @@ -6542,6 +6547,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6542 | 6547 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 6543 | 6548 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 6544 | 6549 | keybindings={keybindings} |
| 6550 | onHide={hideTerminal} | |
| 6545 | 6551 | onAddTerminalContext={addTerminalContextToDraft} |
| 6546 | 6552 | /> |
| 6547 | 6553 | ))} |
apps/web/src/components/NoActiveThreadState.tsx+4-15| ... | ... | @@ -1,26 +1,15 @@ |
| 1 | 1 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; |
| 2 | 2 | import { SidebarInset } from "./ui/sidebar"; |
| 3 | 3 | import { isElectron } from "../env"; |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 4 | import { WorkspacePageHeader } from "./WorkspacePageContainer"; | |
| 6 | 5 | |
| 7 | 6 | export function NoActiveThreadState() { |
| 8 | 7 | return ( |
| 9 | 8 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 10 | 9 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 11 | <header | |
| 12 | className={cn( | |
| 13 | "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | |
| 14 | isElectron | |
| 15 | ? "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center" | |
| 16 | : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center", | |
| 17 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 18 | )} | |
| 19 | > | |
| 10 | <WorkspacePageHeader electron={isElectron} className="border-b border-border"> | |
| 20 | 11 | {isElectron ? ( |
| 21 | <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]"> | |
| 22 | No active thread | |
| 23 | </span> | |
| 12 | <span className="text-xs text-muted-foreground/50">No active thread</span> | |
| 24 | 13 | ) : ( |
| 25 | 14 | <div className="flex items-center gap-2"> |
| 26 | 15 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| ... | ... | @@ -28,7 +17,7 @@ export function NoActiveThreadState() { |
| 28 | 17 | </span> |
| 29 | 18 | </div> |
| 30 | 19 | )} |
| 31 | </header> | |
| 20 | </WorkspacePageHeader> | |
| 32 | 21 | |
| 33 | 22 | <Empty className="flex-1"> |
| 34 | 23 | <div className="w-full max-w-lg px-8 py-12"> |
apps/web/src/components/Sidebar.logic.ts+2-1| ... | ... | @@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { |
| 299 | 299 | export function shouldCreateNewThreadInCurrentProject( |
| 300 | 300 | shiftKey: boolean, |
| 301 | 301 | projectGroupCount: number, |
| 302 | hasProjectScope = false, | |
| 302 | 303 | ): boolean { |
| 303 | return shiftKey || projectGroupCount <= 1; | |
| 304 | return hasProjectScope || shiftKey || projectGroupCount <= 1; | |
| 304 | 305 | } |
| 305 | 306 | |
| 306 | 307 | export function orderItemsByPreferredIds<TItem, TId>(input: { |
apps/web/src/components/Sidebar.tsx+159-157| ... | ... | @@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; |
| 184 | 184 | // Keep the v2 key so existing preferences survive the v2-to-default rename. |
| 185 | 185 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; |
| 186 | 186 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; |
| 187 | const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; | |
| 187 | 188 | |
| 188 | 189 | function compactSidebarTimeLabel(label: string): string { |
| 189 | 190 | if (label === "just now") return "now"; |
| ... | ... | @@ -366,26 +367,20 @@ function SnoozePopoverButton(props: { |
| 366 | 367 | ); |
| 367 | 368 | return ( |
| 368 | 369 | <Popover open={open} onOpenChange={onOpenChange}> |
| 369 | <Tooltip> | |
| 370 | <TooltipTrigger | |
| 371 | render={ | |
| 372 | <PopoverTrigger | |
| 373 | render={ | |
| 374 | <button | |
| 375 | type="button" | |
| 376 | aria-label="Snooze thread" | |
| 377 | onClick={(event) => event.stopPropagation()} | |
| 378 | onDoubleClick={(event) => event.stopPropagation()} | |
| 379 | className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" | |
| 380 | /> | |
| 381 | } | |
| 382 | /> | |
| 383 | } | |
| 384 | > | |
| 385 | <ClockIcon className="size-3" /> | |
| 386 | </TooltipTrigger> | |
| 387 | <TooltipPopup>Snooze thread</TooltipPopup> | |
| 388 | </Tooltip> | |
| 370 | <PopoverTrigger | |
| 371 | render={ | |
| 372 | <Button | |
| 373 | size="icon-micro" | |
| 374 | variant="ghost-muted" | |
| 375 | aria-label="Snooze thread" | |
| 376 | title="Snooze thread" | |
| 377 | onClick={(event) => event.stopPropagation()} | |
| 378 | onDoubleClick={(event) => event.stopPropagation()} | |
| 379 | /> | |
| 380 | } | |
| 381 | > | |
| 382 | <ClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 383 | </PopoverTrigger> | |
| 389 | 384 | <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> |
| 390 | 385 | {presets.map((preset) => ( |
| 391 | 386 | <button |
| ... | ... | @@ -1199,9 +1194,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1199 | 1194 | aria-label="Dismiss Woke notification" |
| 1200 | 1195 | title="Dismiss Woke notification" |
| 1201 | 1196 | onClick={handleAcknowledgeWokeClick} |
| 1202 | className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" | |
| 1197 | className="inline-flex h-6 cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" | |
| 1203 | 1198 | > |
| 1204 | <AlarmClockIcon aria-hidden className="size-3" /> | |
| 1199 | <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1205 | 1200 | <span role="status">Woke</span> |
| 1206 | 1201 | </button> |
| 1207 | 1202 | ) : ( |
| ... | ... | @@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1223 | 1218 | isWoke && "group-hover/sidebar-row:static", |
| 1224 | 1219 | )} |
| 1225 | 1220 | > |
| 1226 | <AlarmClockOffIcon className="mb-px size-3" /> | |
| 1221 | <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1227 | 1222 | </button> |
| 1228 | 1223 | ) |
| 1229 | 1224 | ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( |
| ... | ... | @@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1236 | 1231 | isWoke && "group-hover/sidebar-row:static", |
| 1237 | 1232 | )} |
| 1238 | 1233 | > |
| 1239 | <Undo2Icon className="mb-px size-3.5" /> | |
| 1234 | <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1240 | 1235 | </button> |
| 1241 | 1236 | ) : ( |
| 1242 | 1237 | <button |
| ... | ... | @@ -1248,7 +1243,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1248 | 1243 | isWoke && "group-hover/sidebar-row:static", |
| 1249 | 1244 | )} |
| 1250 | 1245 | > |
| 1251 | <CheckIcon className="size-3" /> | |
| 1246 | <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1252 | 1247 | </button> |
| 1253 | 1248 | )} |
| 1254 | 1249 | </span> |
| ... | ... | @@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1317 | 1312 | ) : ( |
| 1318 | 1313 | <span className="flex-1" /> |
| 1319 | 1314 | )} |
| 1320 | {props.isPinned ? ( | |
| 1321 | props.pinningSupported ? ( | |
| 1322 | <Tooltip> | |
| 1323 | <TooltipTrigger | |
| 1324 | render={ | |
| 1325 | <button | |
| 1326 | type="button" | |
| 1327 | aria-label="Unpin thread" | |
| 1328 | onClick={handleUnpinClick} | |
| 1329 | className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 1330 | /> | |
| 1331 | } | |
| 1315 | <span className="ml-auto flex h-5 shrink-0 items-center"> | |
| 1316 | {props.isPinned ? ( | |
| 1317 | props.pinningSupported ? ( | |
| 1318 | <Button | |
| 1319 | size="icon-micro" | |
| 1320 | variant="ghost-muted" | |
| 1321 | aria-label="Unpin thread" | |
| 1322 | title="Unpin thread" | |
| 1323 | onClick={handleUnpinClick} | |
| 1332 | 1324 | > |
| 1333 | <PinIcon aria-hidden className="size-3 shrink-0" /> | |
| 1334 | </TooltipTrigger> | |
| 1335 | <TooltipPopup>Unpin thread</TooltipPopup> | |
| 1336 | </Tooltip> | |
| 1337 | ) : ( | |
| 1338 | <PinIcon | |
| 1339 | aria-label="Pinned" | |
| 1340 | role="img" | |
| 1341 | className="size-3 shrink-0 text-muted-foreground/65" | |
| 1342 | /> | |
| 1343 | ) | |
| 1344 | ) : null} | |
| 1345 | {/* The visible state owns this slot's width: status at rest, | |
| 1346 | actions on hover/keyboard focus or while the popover is open. Keeping | |
| 1347 | the hidden state out of flow lets the project label reclaim | |
| 1348 | space without either state overlapping it. */} | |
| 1349 | <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs"> | |
| 1350 | {/* Read-only status labels yield to the hover actions. Woke is | |
| 1351 | itself an action, so it stays pointer-enabled and visible | |
| 1352 | while the other controls appear beside it. */} | |
| 1325 | <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1326 | </Button> | |
| 1327 | ) : ( | |
| 1328 | <span className="inline-flex size-5 shrink-0 items-center justify-center"> | |
| 1329 | <PinIcon | |
| 1330 | aria-label="Pinned" | |
| 1331 | role="img" | |
| 1332 | className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")} | |
| 1333 | /> | |
| 1334 | </span> | |
| 1335 | ) | |
| 1336 | ) : null} | |
| 1337 | {/* Only the visible state owns this slot's width: the pin stays | |
| 1338 | directly beside the idle status and beside the first action | |
| 1339 | when the hover controls replace it. */} | |
| 1353 | 1340 | <span |
| 1354 | 1341 | className={cn( |
| 1355 | isWokeStatus | |
| 1356 | ? "pointer-events-auto" | |
| 1357 | : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", | |
| 1358 | "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", | |
| 1359 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", | |
| 1342 | "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs", | |
| 1343 | props.isPinned ? "min-w-0" : "min-w-8", | |
| 1360 | 1344 | )} |
| 1361 | 1345 | > |
| 1362 | {topStatus ? ( | |
| 1363 | isWokeStatus ? ( | |
| 1364 | <button | |
| 1365 | type="button" | |
| 1366 | aria-label="Dismiss Woke notification" | |
| 1367 | title="Dismiss Woke notification" | |
| 1368 | onClick={handleAcknowledgeWokeClick} | |
| 1369 | className={cn( | |
| 1370 | "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", | |
| 1371 | topStatus.className, | |
| 1372 | )} | |
| 1373 | > | |
| 1374 | <AlarmClockIcon aria-hidden className="size-4 shrink-0" /> | |
| 1375 | <span role="status">{topStatus.label}</span> | |
| 1376 | </button> | |
| 1377 | ) : ( | |
| 1378 | <span | |
| 1379 | className={cn( | |
| 1380 | "inline-flex items-center gap-1 font-medium", | |
| 1381 | topStatus.className, | |
| 1382 | )} | |
| 1383 | > | |
| 1384 | {topStatus.icon === "working" ? ( | |
| 1385 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> | |
| 1386 | ) : topStatus.icon === "done" ? ( | |
| 1387 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> | |
| 1388 | ) : null} | |
| 1389 | {/* The label alone is the live region: a role="status" | |
| 1390 | wrapper around the ticking duration would make | |
| 1391 | screen readers announce every second. */} | |
| 1392 | <span role="status">{topStatus.label}</span> | |
| 1393 | {status === "working" ? ( | |
| 1394 | <span aria-hidden> | |
| 1395 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> | |
| 1396 | </span> | |
| 1397 | ) : null} | |
| 1398 | </span> | |
| 1399 | ) | |
| 1400 | ) : ( | |
| 1401 | threadTimeLabel(thread) | |
| 1402 | )} | |
| 1403 | </span> | |
| 1404 | {props.settlementSupported || showSnoozeButton ? ( | |
| 1346 | {/* Read-only status labels yield to the hover actions. Woke is | |
| 1347 | itself an action, so it stays pointer-enabled and visible | |
| 1348 | while the other controls appear beside it. */} | |
| 1405 | 1349 | <span |
| 1406 | 1350 | className={cn( |
| 1407 | // focus-visible, not focus-within: a mouse click leaves | |
| 1408 | // the Settle button focused, and a plain focus-within | |
| 1409 | // would keep the controls pinned over the status label | |
| 1410 | // once the pointer moves away (e.g. after a failed | |
| 1411 | // settle) instead of cross-fading back. | |
| 1412 | "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", | |
| 1413 | snoozeMenuOpen && "pointer-events-auto static opacity-100", | |
| 1351 | isWokeStatus | |
| 1352 | ? "pointer-events-auto" | |
| 1353 | : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", | |
| 1354 | "self-center tabular-nums text-secondary-label transition-opacity", | |
| 1355 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", | |
| 1414 | 1356 | )} |
| 1415 | 1357 | > |
| 1416 | {showSnoozeButton ? ( | |
| 1417 | <SnoozePopoverButton | |
| 1418 | open={snoozeMenuOpen} | |
| 1419 | onOpenChange={setSnoozeMenuOpen} | |
| 1420 | onSnooze={handleSnoozePreset} | |
| 1421 | timestampFormat={props.timestampFormat} | |
| 1422 | /> | |
| 1423 | ) : null} | |
| 1424 | {props.settlementSupported ? ( | |
| 1425 | <Tooltip> | |
| 1426 | <TooltipTrigger | |
| 1427 | render={ | |
| 1428 | <button | |
| 1429 | type="button" | |
| 1430 | aria-label="Settle thread" | |
| 1431 | onClick={handleSettleClick} | |
| 1432 | className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" | |
| 1358 | {topStatus ? ( | |
| 1359 | isWokeStatus ? ( | |
| 1360 | <button | |
| 1361 | type="button" | |
| 1362 | aria-label="Dismiss Woke notification" | |
| 1363 | title="Dismiss Woke notification" | |
| 1364 | onClick={handleAcknowledgeWokeClick} | |
| 1365 | className={cn( | |
| 1366 | "inline-flex h-5 cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", | |
| 1367 | topStatus.className, | |
| 1368 | )} | |
| 1369 | > | |
| 1370 | <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1371 | <span role="status">{topStatus.label}</span> | |
| 1372 | </button> | |
| 1373 | ) : ( | |
| 1374 | <span | |
| 1375 | className={cn( | |
| 1376 | "inline-flex items-center gap-1 font-medium", | |
| 1377 | topStatus.className, | |
| 1378 | )} | |
| 1379 | > | |
| 1380 | {topStatus.icon === "working" ? ( | |
| 1381 | <CircleDashedIcon | |
| 1382 | aria-hidden | |
| 1383 | className={SIDEBAR_LIFECYCLE_ICON_CLASS} | |
| 1433 | 1384 | /> |
| 1434 | } | |
| 1385 | ) : topStatus.icon === "done" ? ( | |
| 1386 | <CircleCheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1387 | ) : null} | |
| 1388 | {/* The label alone is the live region: a role="status" | |
| 1389 | wrapper around the ticking duration would make | |
| 1390 | screen readers announce every second. */} | |
| 1391 | <span role="status">{topStatus.label}</span> | |
| 1392 | {status === "working" ? ( | |
| 1393 | <span aria-hidden> | |
| 1394 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> | |
| 1395 | </span> | |
| 1396 | ) : null} | |
| 1397 | </span> | |
| 1398 | ) | |
| 1399 | ) : ( | |
| 1400 | threadTimeLabel(thread) | |
| 1401 | )} | |
| 1402 | </span> | |
| 1403 | {props.settlementSupported || showSnoozeButton ? ( | |
| 1404 | <span | |
| 1405 | className={cn( | |
| 1406 | // focus-visible, not focus-within: a mouse click leaves | |
| 1407 | // the Settle button focused, and a plain focus-within | |
| 1408 | // would keep the controls pinned over the status label | |
| 1409 | // once the pointer moves away (e.g. after a failed | |
| 1410 | // settle) instead of cross-fading back. | |
| 1411 | "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", | |
| 1412 | snoozeMenuOpen && "pointer-events-auto static opacity-100", | |
| 1413 | )} | |
| 1414 | > | |
| 1415 | {showSnoozeButton ? ( | |
| 1416 | <SnoozePopoverButton | |
| 1417 | open={snoozeMenuOpen} | |
| 1418 | onOpenChange={setSnoozeMenuOpen} | |
| 1419 | onSnooze={handleSnoozePreset} | |
| 1420 | timestampFormat={props.timestampFormat} | |
| 1421 | /> | |
| 1422 | ) : null} | |
| 1423 | {props.settlementSupported ? ( | |
| 1424 | <button | |
| 1425 | type="button" | |
| 1426 | aria-label="Settle thread" | |
| 1427 | onClick={handleSettleClick} | |
| 1428 | className="-mr-1 inline-flex h-full cursor-pointer items-center gap-1 rounded-md bg-transparent ps-1 pe-1.5 text-xs text-muted-foreground hover:text-foreground" | |
| 1435 | 1429 | > |
| 1436 | <CheckIcon className="size-3.5" /> | |
| 1430 | <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | |
| 1437 | 1431 | Settle |
| 1438 | </TooltipTrigger> | |
| 1439 | <TooltipPopup>Settle thread</TooltipPopup> | |
| 1440 | </Tooltip> | |
| 1441 | ) : null} | |
| 1442 | </span> | |
| 1443 | ) : null} | |
| 1432 | </button> | |
| 1433 | ) : null} | |
| 1434 | </span> | |
| 1435 | ) : null} | |
| 1436 | </span> | |
| 1444 | 1437 | </span> |
| 1445 | 1438 | </div> |
| 1446 | 1439 | <div className="mt-1 flex min-w-0"> |
| ... | ... | @@ -3218,17 +3211,25 @@ export default function Sidebar() { |
| 3218 | 3211 | autoAnimate(node, { duration: 150, easing: "ease-out" }); |
| 3219 | 3212 | }, []); |
| 3220 | 3213 | |
| 3221 | // New thread defaults to the project you're in (active thread's project, | |
| 3222 | // falling back to the top project) — same resolution the command palette | |
| 3223 | // uses. The command palette already offers a "New thread in..." submenu | |
| 3224 | // for multi-project setups. | |
| 3214 | // A selected project scope owns creation: users should not have to choose | |
| 3215 | // the same project twice. "All projects" keeps the picker in multi-project | |
| 3216 | // setups, while Shift+click retains the direct-create shortcut. | |
| 3225 | 3217 | const handleNewThreadClick = useCallback( |
| 3226 | 3218 | (event?: ReactMouseEvent) => { |
| 3227 | // One project: nothing to pick, create immediately. Shift+click creates | |
| 3228 | // directly in the current project even with several projects, skipping | |
| 3229 | // the palette picker. | |
| 3230 | if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { | |
| 3219 | if ( | |
| 3220 | shouldCreateNewThreadInCurrentProject( | |
| 3221 | event?.shiftKey ?? false, | |
| 3222 | projectGroups.length, | |
| 3223 | scopedProjectGroup !== null, | |
| 3224 | ) | |
| 3225 | ) { | |
| 3231 | 3226 | if (isMobile) setOpenMobile(false); |
| 3227 | if (scopedProjectGroup) { | |
| 3228 | void newThreadContext.handleNewThread( | |
| 3229 | scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), | |
| 3230 | ); | |
| 3231 | return; | |
| 3232 | } | |
| 3232 | 3233 | void startNewThreadFromContext({ |
| 3233 | 3234 | activeDraftThread: newThreadContext.activeDraftThread, |
| 3234 | 3235 | activeThread: newThreadContext.activeThread ?? undefined, |
| ... | ... | @@ -3240,20 +3241,19 @@ export default function Sidebar() { |
| 3240 | 3241 | if (isMobile) setOpenMobile(false); |
| 3241 | 3242 | openCommandPalette({ open: "new-thread-in" }); |
| 3242 | 3243 | }, |
| 3243 | [isMobile, newThreadContext, projectGroups.length, setOpenMobile], | |
| 3244 | [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], | |
| 3244 | 3245 | ); |
| 3245 | 3246 | |
| 3246 | // The button mirrors chat.new: in multi-project setups both route through | |
| 3247 | // the command palette's "New thread in..." picker, and in single-project | |
| 3248 | // setups both create immediately. In multi-project setups the label is only | |
| 3249 | // the picker's shortcut: falling back to chat.newLocal would advertise the | |
| 3250 | // same shortcut for both the picker and direct create. In single-project | |
| 3251 | // setups both commands create directly, so chat.newLocal is a valid | |
| 3252 | // fallback. The second tooltip line (multi-project only) advertises | |
| 3253 | // shift+click and its keyboard twin chat.newLocal for direct create. | |
| 3247 | // With no explicit scope the button mirrors chat.new. A scoped button has | |
| 3248 | // intentionally more specific behavior, so it does not advertise the | |
| 3249 | // broader command's shortcut. | |
| 3254 | 3250 | const newThreadShortcutLabel = |
| 3255 | shortcutLabelForCommand(keybindings, "chat.new") ?? | |
| 3256 | (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); | |
| 3251 | scopedProjectGroup === null | |
| 3252 | ? (shortcutLabelForCommand(keybindings, "chat.new") ?? | |
| 3253 | (projectGroups.length <= 1 | |
| 3254 | ? shortcutLabelForCommand(keybindings, "chat.newLocal") | |
| 3255 | : undefined)) | |
| 3256 | : undefined; | |
| 3257 | 3257 | const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); |
| 3258 | 3258 | return ( |
| 3259 | 3259 | <> |
| ... | ... | @@ -3332,7 +3332,9 @@ export default function Sidebar() { |
| 3332 | 3332 | /> |
| 3333 | 3333 | </TooltipTrigger> |
| 3334 | 3334 | <TooltipPopup side="right"> |
| 3335 | {projectGroups.length > 1 ? ( | |
| 3335 | {scopedProjectGroup ? ( | |
| 3336 | `New thread in ${scopedProjectGroup.displayName}` | |
| 3337 | ) : projectGroups.length > 1 ? ( | |
| 3336 | 3338 | <span className="flex flex-col gap-0.5"> |
| 3337 | 3339 | <span> |
| 3338 | 3340 | {newThreadShortcutLabel |
apps/web/src/components/ThreadTerminalDrawer.tsx+356-308| ... | ... | @@ -5,11 +5,11 @@ import { |
| 5 | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | 6 | import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; |
| 7 | 7 | import { |
| 8 | PanelBottomCloseIcon, | |
| 8 | 9 | Plus, |
| 9 | 10 | SquareSplitHorizontal, |
| 10 | 11 | SquareSplitVertical, |
| 11 | 12 | TerminalSquare, |
| 12 | Trash2, | |
| 13 | 13 | XIcon, |
| 14 | 14 | } from "lucide-react"; |
| 15 | 15 | import { |
| ... | ... | @@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 21 | 21 | import * as Schema from "effect/Schema"; |
| 22 | 22 | import { |
| 23 | 23 | type PointerEvent as ReactPointerEvent, |
| 24 | type ReactNode, | |
| 25 | 24 | type SetStateAction, |
| 26 | 25 | useCallback, |
| 27 | 26 | useEffect, |
| ... | ... | @@ -30,9 +29,9 @@ import { |
| 30 | 29 | useRef, |
| 31 | 30 | useState, |
| 32 | 31 | } from "react"; |
| 33 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | |
| 34 | 32 | import { Button } from "~/components/ui/button"; |
| 35 | 33 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | import { useResizableWidth } from "~/hooks/useResizableWidth"; | |
| 36 | 35 | import { cn } from "~/lib/utils"; |
| 37 | 36 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 38 | 37 | import { |
| ... | ... | @@ -60,6 +59,7 @@ import { |
| 60 | 59 | import { readLocalApi } from "~/localApi"; |
| 61 | 60 | import { useClientSettings } from "../hooks/useSettings"; |
| 62 | 61 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 62 | import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; | |
| 63 | 63 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 64 | 64 | import { serverEnvironment } from "../state/server"; |
| 65 | 65 | import { previewEnvironment } from "../state/preview"; |
| ... | ... | @@ -72,10 +72,15 @@ import { |
| 72 | 72 | resolveTerminalFontSizePreference, |
| 73 | 73 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 74 | 74 | } from "../appearanceFonts"; |
| 75 | import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; | |
| 75 | 76 | |
| 76 | 77 | const MIN_DRAWER_HEIGHT = 180; |
| 77 | 78 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| 78 | 79 | const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; |
| 80 | const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; | |
| 81 | const TERMINAL_SIDEBAR_MIN_WIDTH = 144; | |
| 82 | const TERMINAL_SIDEBAR_MAX_WIDTH = 320; | |
| 83 | const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; | |
| 79 | 84 | |
| 80 | 85 | function maxDrawerHeight(): number { |
| 81 | 86 | if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; |
| ... | ... | @@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp( |
| 244 | 249 | return selectionGestureActive && button === 0; |
| 245 | 250 | } |
| 246 | 251 | |
| 252 | export function shouldShowTerminalSidebar(terminalCount: number): boolean { | |
| 253 | return terminalCount > 1; | |
| 254 | } | |
| 255 | ||
| 247 | 256 | export function terminalSelectionLineRange(position: { |
| 248 | 257 | start: { y: number }; |
| 249 | 258 | end: { y: number }; |
| ... | ... | @@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps { |
| 876 | 885 | onSplitTerminal: () => void; |
| 877 | 886 | onSplitTerminalVertical: () => void; |
| 878 | 887 | onNewTerminal: () => void; |
| 888 | onHide?: () => void; | |
| 879 | 889 | splitShortcutLabel?: string | undefined; |
| 880 | 890 | splitVerticalShortcutLabel?: string | undefined; |
| 881 | 891 | newShortcutLabel?: string | undefined; |
| ... | ... | @@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps { |
| 891 | 901 | terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>; |
| 892 | 902 | } |
| 893 | 903 | |
| 894 | interface TerminalActionButtonProps { | |
| 895 | label: string; | |
| 896 | className: string; | |
| 897 | onClick: () => void; | |
| 898 | children: ReactNode; | |
| 899 | } | |
| 900 | ||
| 901 | function 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 | ||
| 923 | 904 | export default function ThreadTerminalDrawer({ |
| 924 | 905 | mode = "drawer", |
| 925 | 906 | threadRef, |
| ... | ... | @@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({ |
| 937 | 918 | onSplitTerminal, |
| 938 | 919 | onSplitTerminalVertical, |
| 939 | 920 | onNewTerminal, |
| 921 | onHide, | |
| 940 | 922 | splitShortcutLabel, |
| 941 | 923 | splitVerticalShortcutLabel, |
| 942 | 924 | newShortcutLabel, |
| ... | ... | @@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({ |
| 950 | 932 | terminalLaunchLocationsById, |
| 951 | 933 | }: ThreadTerminalDrawerProps) { |
| 952 | 934 | const isPanel = mode === "panel"; |
| 935 | const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = | |
| 936 | useResizableWidth({ | |
| 937 | storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, | |
| 938 | defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, | |
| 939 | minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, | |
| 940 | maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, | |
| 941 | edge: "left", | |
| 942 | }); | |
| 943 | const terminalCustomLabels = useTerminalUiStateStore((state) => | |
| 944 | selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), | |
| 945 | ); | |
| 946 | const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); | |
| 947 | const [renamingTerminalId, setRenamingTerminalId] = useState<string | null>(null); | |
| 948 | const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); | |
| 949 | const cancelTerminalRenameRef = useRef(false); | |
| 953 | 950 | const [advancedTypography] = useLocalStorage( |
| 954 | 951 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 955 | 952 | false, |
| ... | ... | @@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({ |
| 1098 | 1095 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 1099 | 1096 | const splitDirection = |
| 1100 | 1097 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; |
| 1101 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; | |
| 1098 | const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); | |
| 1102 | 1099 | const isSplitView = visibleTerminalIds.length > 1; |
| 1103 | const showGroupHeaders = | |
| 1104 | resolvedTerminalGroups.length > 1 || | |
| 1105 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); | |
| 1106 | 1100 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; |
| 1107 | const terminalLabelById = useMemo(() => { | |
| 1101 | const automaticTerminalLabelById = useMemo(() => { | |
| 1108 | 1102 | const next = new Map<string, string>(); |
| 1109 | 1103 | for (const terminalId of normalizedTerminalIds) { |
| 1110 | 1104 | next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); |
| 1111 | 1105 | } |
| 1112 | 1106 | return next; |
| 1113 | 1107 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 1108 | const terminalLabelById = useMemo(() => { | |
| 1109 | const next = new Map<string, string>(); | |
| 1110 | for (const terminalId of normalizedTerminalIds) { | |
| 1111 | next.set( | |
| 1112 | terminalId, | |
| 1113 | terminalCustomLabels[terminalId]?.trim() || | |
| 1114 | automaticTerminalLabelById.get(terminalId) || | |
| 1115 | getTerminalLabel(terminalId), | |
| 1116 | ); | |
| 1117 | } | |
| 1118 | return next; | |
| 1119 | }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); | |
| 1114 | 1120 | const resolveTerminalLaunchLocation = useCallback( |
| 1115 | 1121 | (terminalId: string): TerminalLaunchLocation => { |
| 1116 | 1122 | return ( |
| ... | ... | @@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({ |
| 1123 | 1129 | }, |
| 1124 | 1130 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], |
| 1125 | 1131 | ); |
| 1132 | const newTerminalActionLabel = newShortcutLabel | |
| 1133 | ? `New Terminal (${newShortcutLabel})` | |
| 1134 | : "New Terminal"; | |
| 1126 | 1135 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 1127 | 1136 | ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| 1128 | 1137 | : splitShortcutLabel |
| ... | ... | @@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({ |
| 1133 | 1142 | : splitVerticalShortcutLabel |
| 1134 | 1143 | ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` |
| 1135 | 1144 | : "Split Terminal Vertically"; |
| 1136 | const newTerminalActionLabel = newShortcutLabel | |
| 1137 | ? `New Terminal (${newShortcutLabel})` | |
| 1138 | : "New Terminal"; | |
| 1139 | 1145 | const closeTerminalActionLabel = closeShortcutLabel |
| 1140 | 1146 | ? `Close Terminal (${closeShortcutLabel})` |
| 1141 | 1147 | : "Close Terminal"; |
| ... | ... | @@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({ |
| 1147 | 1153 | if (hasReachedSplitLimit) return; |
| 1148 | 1154 | onSplitTerminalVertical(); |
| 1149 | 1155 | }, [hasReachedSplitLimit, onSplitTerminalVertical]); |
| 1150 | const onNewTerminalAction = useCallback(() => { | |
| 1151 | onNewTerminal(); | |
| 1152 | }, [onNewTerminal]); | |
| 1156 | const startTerminalRename = useCallback( | |
| 1157 | (terminalId: string) => { | |
| 1158 | cancelTerminalRenameRef.current = false; | |
| 1159 | setRenamingTerminalId(terminalId); | |
| 1160 | setTerminalRenameDraft( | |
| 1161 | terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", | |
| 1162 | ); | |
| 1163 | }, | |
| 1164 | [terminalCustomLabels, terminalLabelById], | |
| 1165 | ); | |
| 1166 | const finishTerminalRename = useCallback(() => { | |
| 1167 | if (!renamingTerminalId) return; | |
| 1168 | const nextLabel = terminalRenameDraft.trim(); | |
| 1169 | const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; | |
| 1170 | setTerminalCustomLabel( | |
| 1171 | threadRef, | |
| 1172 | renamingTerminalId, | |
| 1173 | nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, | |
| 1174 | ); | |
| 1175 | setRenamingTerminalId(null); | |
| 1176 | }, [ | |
| 1177 | automaticTerminalLabelById, | |
| 1178 | renamingTerminalId, | |
| 1179 | setTerminalCustomLabel, | |
| 1180 | terminalRenameDraft, | |
| 1181 | threadRef, | |
| 1182 | ]); | |
| 1183 | const cancelTerminalRename = useCallback(() => { | |
| 1184 | cancelTerminalRenameRef.current = true; | |
| 1185 | setRenamingTerminalId(null); | |
| 1186 | }, []); | |
| 1187 | ||
| 1188 | useEffect(() => { | |
| 1189 | cancelTerminalRenameRef.current = false; | |
| 1190 | setRenamingTerminalId(null); | |
| 1191 | setTerminalRenameDraft(""); | |
| 1192 | }, [threadRef.environmentId, threadRef.threadId]); | |
| 1153 | 1193 | |
| 1154 | 1194 | useEffect(() => { |
| 1155 | 1195 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({ |
| 1274 | 1314 | ) : null} |
| 1275 | 1315 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> |
| 1276 | 1316 | <p>No terminal sessions for this thread yet.</p> |
| 1277 | <Button size="xs" variant="outline" onClick={onNewTerminalAction}> | |
| 1317 | <Button size="xs" variant="outline" onClick={onNewTerminal}> | |
| 1278 | 1318 | {newTerminalActionLabel} |
| 1279 | 1319 | </Button> |
| 1280 | 1320 | </div> |
| ... | ... | @@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({ |
| 1283 | 1323 | } |
| 1284 | 1324 | |
| 1285 | 1325 | const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); |
| 1286 | ||
| 1326 | const compactTerminalToolbar = ( | |
| 1327 | <> | |
| 1328 | <Button | |
| 1329 | type="button" | |
| 1330 | size="icon-xs" | |
| 1331 | variant="ghost" | |
| 1332 | className="rounded-md" | |
| 1333 | disabled={hasReachedSplitLimit} | |
| 1334 | onClick={onSplitTerminalAction} | |
| 1335 | aria-label={splitTerminalActionLabel} | |
| 1336 | title={splitTerminalActionLabel} | |
| 1337 | > | |
| 1338 | <SquareSplitHorizontal className="size-3.5" /> | |
| 1339 | </Button> | |
| 1340 | <Button | |
| 1341 | type="button" | |
| 1342 | size="icon-xs" | |
| 1343 | variant="ghost" | |
| 1344 | className="rounded-md" | |
| 1345 | disabled={hasReachedSplitLimit} | |
| 1346 | onClick={onSplitTerminalVerticalAction} | |
| 1347 | aria-label={splitTerminalVerticalActionLabel} | |
| 1348 | title={splitTerminalVerticalActionLabel} | |
| 1349 | > | |
| 1350 | <SquareSplitVertical className="size-3.5" /> | |
| 1351 | </Button> | |
| 1352 | <Button | |
| 1353 | type="button" | |
| 1354 | size="icon-xs" | |
| 1355 | variant="ghost" | |
| 1356 | className="rounded-md" | |
| 1357 | onClick={onNewTerminal} | |
| 1358 | aria-label={newTerminalActionLabel} | |
| 1359 | title={newTerminalActionLabel} | |
| 1360 | > | |
| 1361 | <Plus className="size-3.5" /> | |
| 1362 | </Button> | |
| 1363 | <Button | |
| 1364 | type="button" | |
| 1365 | size="icon-xs" | |
| 1366 | variant="ghost" | |
| 1367 | className="rounded-md" | |
| 1368 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 1369 | aria-label={closeTerminalActionLabel} | |
| 1370 | title={closeTerminalActionLabel} | |
| 1371 | > | |
| 1372 | <XIcon className="size-3.5" /> | |
| 1373 | </Button> | |
| 1374 | {!isPanel && onHide ? ( | |
| 1375 | <> | |
| 1376 | <span aria-hidden className="mx-0.5 h-4 w-px bg-border/70" /> | |
| 1377 | <Button | |
| 1378 | type="button" | |
| 1379 | size="icon-xs" | |
| 1380 | variant="ghost" | |
| 1381 | className="rounded-md" | |
| 1382 | onClick={onHide} | |
| 1383 | aria-label="Hide terminal drawer" | |
| 1384 | title="Hide terminal drawer" | |
| 1385 | > | |
| 1386 | <PanelBottomCloseIcon className="size-3.5" /> | |
| 1387 | </Button> | |
| 1388 | </> | |
| 1389 | ) : null} | |
| 1390 | </> | |
| 1391 | ); | |
| 1287 | 1392 | return ( |
| 1288 | 1393 | <aside |
| 1289 | 1394 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} |
| ... | ... | @@ -1303,277 +1408,220 @@ export default function ThreadTerminalDrawer({ |
| 1303 | 1408 | /> |
| 1304 | 1409 | ) : null} |
| 1305 | 1410 | |
| 1306 | {!hasTerminalSidebar && ( | |
| 1307 | <div className="pointer-events-none absolute right-2 top-2 z-20"> | |
| 1308 | <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs"> | |
| 1309 | <TerminalActionButton | |
| 1310 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 1311 | hasReachedSplitLimit | |
| 1312 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1313 | : "hover:bg-accent" | |
| 1314 | }`} | |
| 1315 | onClick={onSplitTerminalAction} | |
| 1316 | label={splitTerminalActionLabel} | |
| 1317 | > | |
| 1318 | <SquareSplitHorizontal className="size-3.25" /> | |
| 1319 | </TerminalActionButton> | |
| 1320 | <div className="h-4 w-px bg-border/80" /> | |
| 1321 | <TerminalActionButton | |
| 1322 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 1323 | hasReachedSplitLimit | |
| 1324 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1325 | : "hover:bg-accent" | |
| 1326 | }`} | |
| 1327 | onClick={onSplitTerminalVerticalAction} | |
| 1328 | label={splitTerminalVerticalActionLabel} | |
| 1329 | > | |
| 1330 | <SquareSplitVertical className="size-3.25" /> | |
| 1331 | </TerminalActionButton> | |
| 1332 | <div className="h-4 w-px bg-border/80" /> | |
| 1333 | <TerminalActionButton | |
| 1334 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | |
| 1335 | onClick={onNewTerminalAction} | |
| 1336 | label={newTerminalActionLabel} | |
| 1337 | > | |
| 1338 | <Plus className="size-3.25" /> | |
| 1339 | </TerminalActionButton> | |
| 1340 | <div className="h-4 w-px bg-border/80" /> | |
| 1341 | <TerminalActionButton | |
| 1342 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | |
| 1343 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 1344 | label={closeTerminalActionLabel} | |
| 1345 | > | |
| 1346 | <Trash2 className="size-3.25" /> | |
| 1347 | </TerminalActionButton> | |
| 1411 | {!hasTerminalSidebar ? ( | |
| 1412 | <div className="pointer-events-none absolute top-2 right-2 z-20"> | |
| 1413 | <div className="pointer-events-auto flex h-8 items-center rounded-lg border border-border/70 bg-background p-0.5 shadow-xs"> | |
| 1414 | {compactTerminalToolbar} | |
| 1348 | 1415 | </div> |
| 1349 | 1416 | </div> |
| 1350 | )} | |
| 1417 | ) : null} | |
| 1351 | 1418 | |
| 1352 | <div className="min-h-0 w-full flex-1"> | |
| 1353 | <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}> | |
| 1354 | <div className="min-w-0 flex-1"> | |
| 1355 | {isSplitView ? ( | |
| 1356 | <div | |
| 1357 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" | |
| 1358 | style={ | |
| 1359 | splitDirection === "vertical" | |
| 1360 | ? { | |
| 1361 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1362 | } | |
| 1363 | : { | |
| 1364 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1365 | } | |
| 1366 | } | |
| 1367 | > | |
| 1368 | {visibleTerminalIds.map((terminalId) => { | |
| 1369 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); | |
| 1370 | return ( | |
| 1371 | <div | |
| 1372 | key={terminalId} | |
| 1373 | className={`min-h-0 min-w-0 ${ | |
| 1374 | splitDirection === "vertical" | |
| 1375 | ? "border-t first:border-t-0" | |
| 1376 | : "border-l first:border-l-0" | |
| 1377 | } ${ | |
| 1378 | terminalId === resolvedActiveTerminalId | |
| 1379 | ? "border-border" | |
| 1380 | : "border-border/70" | |
| 1381 | }`} | |
| 1382 | onMouseDown={() => { | |
| 1383 | if (terminalId !== resolvedActiveTerminalId) { | |
| 1384 | onActiveTerminalChange(terminalId); | |
| 1385 | } | |
| 1386 | }} | |
| 1387 | > | |
| 1388 | <div className="h-full p-1"> | |
| 1389 | <TerminalViewport | |
| 1390 | advancedTypography={advancedTypography} | |
| 1391 | threadRef={threadRef} | |
| 1392 | threadId={threadId} | |
| 1393 | terminalId={terminalId} | |
| 1394 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 1395 | cwd={terminalLaunchLocation.cwd} | |
| 1396 | {...(terminalLaunchLocation.worktreePath !== undefined | |
| 1397 | ? { worktreePath: terminalLaunchLocation.worktreePath } | |
| 1398 | : {})} | |
| 1399 | {...(terminalLaunchLocation.runtimeEnv | |
| 1400 | ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } | |
| 1401 | : {})} | |
| 1402 | onSessionExited={() => onCloseTerminal(terminalId)} | |
| 1403 | onAddTerminalContext={onAddTerminalContext} | |
| 1404 | focusRequestId={focusRequestId} | |
| 1405 | autoFocus={terminalId === resolvedActiveTerminalId} | |
| 1406 | resizeEpoch={resizeEpoch} | |
| 1407 | drawerHeight={drawerHeight} | |
| 1408 | keybindings={keybindings} | |
| 1409 | /> | |
| 1410 | </div> | |
| 1419 | <div className="flex min-h-0 w-full flex-1"> | |
| 1420 | <div className="min-w-0 flex-1"> | |
| 1421 | {isSplitView ? ( | |
| 1422 | <div | |
| 1423 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" | |
| 1424 | style={ | |
| 1425 | splitDirection === "vertical" | |
| 1426 | ? { | |
| 1427 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1428 | } | |
| 1429 | : { | |
| 1430 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1431 | } | |
| 1432 | } | |
| 1433 | > | |
| 1434 | {visibleTerminalIds.map((terminalId) => { | |
| 1435 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); | |
| 1436 | const isActive = terminalId === resolvedActiveTerminalId; | |
| 1437 | return ( | |
| 1438 | <div | |
| 1439 | key={terminalId} | |
| 1440 | className={cn( | |
| 1441 | "min-h-0 min-w-0", | |
| 1442 | splitDirection === "vertical" | |
| 1443 | ? "border-t first:border-t-0" | |
| 1444 | : "border-l first:border-l-0", | |
| 1445 | isActive ? "border-border" : "border-border/70", | |
| 1446 | )} | |
| 1447 | onMouseDown={() => { | |
| 1448 | if (!isActive) onActiveTerminalChange(terminalId); | |
| 1449 | }} | |
| 1450 | > | |
| 1451 | <div className="h-full p-1"> | |
| 1452 | <TerminalViewport | |
| 1453 | advancedTypography={advancedTypography} | |
| 1454 | threadRef={threadRef} | |
| 1455 | threadId={threadId} | |
| 1456 | terminalId={terminalId} | |
| 1457 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 1458 | cwd={terminalLaunchLocation.cwd} | |
| 1459 | {...(terminalLaunchLocation.worktreePath !== undefined | |
| 1460 | ? { worktreePath: terminalLaunchLocation.worktreePath } | |
| 1461 | : {})} | |
| 1462 | {...(terminalLaunchLocation.runtimeEnv | |
| 1463 | ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } | |
| 1464 | : {})} | |
| 1465 | onSessionExited={() => onCloseTerminal(terminalId)} | |
| 1466 | onAddTerminalContext={onAddTerminalContext} | |
| 1467 | focusRequestId={focusRequestId} | |
| 1468 | autoFocus={isActive} | |
| 1469 | resizeEpoch={resizeEpoch} | |
| 1470 | drawerHeight={drawerHeight} | |
| 1471 | keybindings={keybindings} | |
| 1472 | /> | |
| 1411 | 1473 | </div> |
| 1412 | ); | |
| 1413 | })} | |
| 1414 | </div> | |
| 1415 | ) : ( | |
| 1416 | <div className="h-full p-1"> | |
| 1417 | <TerminalViewport | |
| 1418 | advancedTypography={advancedTypography} | |
| 1419 | key={resolvedActiveTerminalId} | |
| 1420 | threadRef={threadRef} | |
| 1421 | threadId={threadId} | |
| 1422 | terminalId={resolvedActiveTerminalId} | |
| 1423 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | |
| 1424 | cwd={activeTerminalLaunchLocation.cwd} | |
| 1425 | {...(activeTerminalLaunchLocation.worktreePath !== undefined | |
| 1426 | ? { worktreePath: activeTerminalLaunchLocation.worktreePath } | |
| 1427 | : {})} | |
| 1428 | {...(activeTerminalLaunchLocation.runtimeEnv | |
| 1429 | ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } | |
| 1430 | : {})} | |
| 1431 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 1432 | onAddTerminalContext={onAddTerminalContext} | |
| 1433 | focusRequestId={focusRequestId} | |
| 1434 | autoFocus | |
| 1435 | resizeEpoch={resizeEpoch} | |
| 1436 | drawerHeight={drawerHeight} | |
| 1437 | keybindings={keybindings} | |
| 1438 | /> | |
| 1439 | </div> | |
| 1440 | )} | |
| 1474 | </div> | |
| 1475 | ); | |
| 1476 | })} | |
| 1477 | </div> | |
| 1478 | ) : ( | |
| 1479 | <div className="h-full p-1"> | |
| 1480 | <TerminalViewport | |
| 1481 | advancedTypography={advancedTypography} | |
| 1482 | key={resolvedActiveTerminalId} | |
| 1483 | threadRef={threadRef} | |
| 1484 | threadId={threadId} | |
| 1485 | terminalId={resolvedActiveTerminalId} | |
| 1486 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | |
| 1487 | cwd={activeTerminalLaunchLocation.cwd} | |
| 1488 | {...(activeTerminalLaunchLocation.worktreePath !== undefined | |
| 1489 | ? { worktreePath: activeTerminalLaunchLocation.worktreePath } | |
| 1490 | : {})} | |
| 1491 | {...(activeTerminalLaunchLocation.runtimeEnv | |
| 1492 | ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } | |
| 1493 | : {})} | |
| 1494 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 1495 | onAddTerminalContext={onAddTerminalContext} | |
| 1496 | focusRequestId={focusRequestId} | |
| 1497 | autoFocus | |
| 1498 | resizeEpoch={resizeEpoch} | |
| 1499 | drawerHeight={drawerHeight} | |
| 1500 | keybindings={keybindings} | |
| 1501 | /> | |
| 1502 | </div> | |
| 1503 | )} | |
| 1504 | </div> | |
| 1505 | ||
| 1506 | <aside | |
| 1507 | className={cn( | |
| 1508 | "relative flex shrink-0 flex-col border-l border-border/70 bg-muted/10", | |
| 1509 | !hasTerminalSidebar && "hidden", | |
| 1510 | )} | |
| 1511 | style={{ width: `${terminalSidebarWidth}px` }} | |
| 1512 | > | |
| 1513 | <RightPanelResizeHandle handlers={terminalSidebarResizeHandlers} /> | |
| 1514 | <div className="flex h-9 shrink-0 items-center justify-center border-b border-border/70 px-2"> | |
| 1515 | {hasTerminalSidebar ? compactTerminalToolbar : null} | |
| 1441 | 1516 | </div> |
| 1442 | 1517 | |
| 1443 | {hasTerminalSidebar && ( | |
| 1444 | <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10"> | |
| 1445 | <div className="flex h-[22px] items-stretch justify-end border-b border-border/70"> | |
| 1446 | <div className="inline-flex h-full items-stretch"> | |
| 1447 | <TerminalActionButton | |
| 1448 | className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${ | |
| 1449 | hasReachedSplitLimit | |
| 1450 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1451 | : "hover:bg-accent/70" | |
| 1452 | }`} | |
| 1453 | onClick={onSplitTerminalAction} | |
| 1454 | label={splitTerminalActionLabel} | |
| 1455 | > | |
| 1456 | <SquareSplitHorizontal className="size-3.25" /> | |
| 1457 | </TerminalActionButton> | |
| 1458 | <TerminalActionButton | |
| 1459 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ | |
| 1460 | hasReachedSplitLimit | |
| 1461 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1462 | : "hover:bg-accent/70" | |
| 1463 | }`} | |
| 1464 | onClick={onSplitTerminalVerticalAction} | |
| 1465 | label={splitTerminalVerticalActionLabel} | |
| 1466 | > | |
| 1467 | <SquareSplitVertical className="size-3.25" /> | |
| 1468 | </TerminalActionButton> | |
| 1469 | <TerminalActionButton | |
| 1470 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 1471 | onClick={onNewTerminalAction} | |
| 1472 | label={newTerminalActionLabel} | |
| 1473 | > | |
| 1474 | <Plus className="size-3.25" /> | |
| 1475 | </TerminalActionButton> | |
| 1476 | <TerminalActionButton | |
| 1477 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 1478 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 1479 | label={closeTerminalActionLabel} | |
| 1480 | > | |
| 1481 | <Trash2 className="size-3.25" /> | |
| 1482 | </TerminalActionButton> | |
| 1483 | </div> | |
| 1484 | </div> | |
| 1485 | ||
| 1486 | <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1"> | |
| 1487 | {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { | |
| 1488 | const isGroupActive = | |
| 1489 | terminalGroup.terminalIds.includes(resolvedActiveTerminalId); | |
| 1490 | const groupActiveTerminalId = isGroupActive | |
| 1491 | ? resolvedActiveTerminalId | |
| 1492 | : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId); | |
| 1493 | ||
| 1494 | return ( | |
| 1495 | <div key={terminalGroup.id} className="pb-0.5"> | |
| 1496 | {showGroupHeaders && ( | |
| 1497 | <button | |
| 1498 | type="button" | |
| 1499 | className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${ | |
| 1500 | isGroupActive | |
| 1501 | ? "bg-accent/70 text-foreground" | |
| 1502 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" | |
| 1503 | }`} | |
| 1504 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} | |
| 1518 | <div className="min-h-0 flex-1 overflow-y-auto p-1"> | |
| 1519 | {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { | |
| 1520 | const isSplitGroup = terminalGroup.terminalIds.length > 1; | |
| 1521 | const splitVertically = terminalGroup.splitDirection === "vertical"; | |
| 1522 | const SplitIcon = splitVertically ? SquareSplitVertical : SquareSplitHorizontal; | |
| 1523 | return ( | |
| 1524 | <div | |
| 1525 | key={terminalGroup.id} | |
| 1526 | className={cn( | |
| 1527 | groupIndex > 0 && "mt-1", | |
| 1528 | isSplitGroup && "rounded-lg border border-border/80 bg-muted/20 p-1", | |
| 1529 | )} | |
| 1530 | > | |
| 1531 | {isSplitGroup ? ( | |
| 1532 | <div className="flex h-5 items-center gap-1 px-1 text-[10px] font-medium text-muted-foreground"> | |
| 1533 | <SplitIcon className="size-3" /> | |
| 1534 | <span>{splitVertically ? "Split down" : "Split right"}</span> | |
| 1535 | </div> | |
| 1536 | ) : null} | |
| 1537 | <div className={cn(isSplitGroup && "space-y-0.5")}> | |
| 1538 | {terminalGroup.terminalIds.map((terminalId) => { | |
| 1539 | const isActive = terminalId === resolvedActiveTerminalId; | |
| 1540 | const automaticLabel = | |
| 1541 | automaticTerminalLabelById.get(terminalId) ?? "Terminal"; | |
| 1542 | const customLabel = terminalCustomLabels[terminalId]?.trim() ?? ""; | |
| 1543 | const displayLabel = terminalLabelById.get(terminalId) ?? automaticLabel; | |
| 1544 | const isRenaming = renamingTerminalId === terminalId; | |
| 1545 | return ( | |
| 1546 | <div | |
| 1547 | key={terminalId} | |
| 1548 | className={cn( | |
| 1549 | "group/tab flex h-6 items-center gap-0.5 rounded-md pr-2 text-xs", | |
| 1550 | isSplitGroup ? "pl-0.5" : "pl-1.5", | |
| 1551 | isActive | |
| 1552 | ? "bg-accent text-foreground" | |
| 1553 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", | |
| 1554 | )} | |
| 1505 | 1555 | > |
| 1506 | Group {groupIndex + 1} | |
| 1507 | </button> | |
| 1508 | )} | |
| 1509 | ||
| 1510 | <div | |
| 1511 | className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""} | |
| 1512 | > | |
| 1513 | {terminalGroup.terminalIds.map((terminalId) => { | |
| 1514 | const isActive = terminalId === resolvedActiveTerminalId; | |
| 1515 | const closeTerminalLabel = `Close ${ | |
| 1516 | terminalLabelById.get(terminalId) ?? "terminal" | |
| 1517 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | |
| 1518 | return ( | |
| 1519 | <div | |
| 1520 | key={terminalId} | |
| 1521 | className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${ | |
| 1522 | isActive | |
| 1523 | ? "bg-accent text-foreground" | |
| 1524 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" | |
| 1525 | }`} | |
| 1556 | <button | |
| 1557 | type="button" | |
| 1558 | className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" | |
| 1559 | aria-label={`Close ${displayLabel}`} | |
| 1560 | onClick={() => onCloseTerminal(terminalId)} | |
| 1561 | > | |
| 1562 | <TerminalSquare className="size-3 shrink-0 group-hover/tab:hidden group-focus-visible/close:hidden pointer-coarse:hidden" /> | |
| 1563 | <XIcon className="hidden size-3 group-hover/tab:block group-focus-visible/close:block pointer-coarse:block" /> | |
| 1564 | </button> | |
| 1565 | {isRenaming ? ( | |
| 1566 | <input | |
| 1567 | autoFocus | |
| 1568 | value={terminalRenameDraft} | |
| 1569 | aria-label="Rename terminal" | |
| 1570 | className="h-6 min-w-0 flex-1 rounded border border-input bg-background px-1.5 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring" | |
| 1571 | onFocus={(event) => event.currentTarget.select()} | |
| 1572 | onChange={(event) => | |
| 1573 | setTerminalRenameDraft(event.currentTarget.value) | |
| 1574 | } | |
| 1575 | onBlur={() => { | |
| 1576 | if (cancelTerminalRenameRef.current) { | |
| 1577 | cancelTerminalRenameRef.current = false; | |
| 1578 | return; | |
| 1579 | } | |
| 1580 | finishTerminalRename(); | |
| 1581 | }} | |
| 1582 | onKeyDown={(event) => { | |
| 1583 | event.stopPropagation(); | |
| 1584 | if (event.nativeEvent.isComposing) return; | |
| 1585 | if (event.key === "Enter") { | |
| 1586 | event.preventDefault(); | |
| 1587 | finishTerminalRename(); | |
| 1588 | } else if (event.key === "Escape") { | |
| 1589 | event.preventDefault(); | |
| 1590 | cancelTerminalRename(); | |
| 1591 | } | |
| 1592 | }} | |
| 1593 | /> | |
| 1594 | ) : ( | |
| 1595 | <button | |
| 1596 | type="button" | |
| 1597 | className="flex min-w-0 flex-1 items-center text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 1598 | title={`${displayLabel}${customLabel && customLabel !== automaticLabel ? ` · ${automaticLabel}` : ""} — ${resolveTerminalLaunchLocation(terminalId).cwd}`} | |
| 1599 | onClick={() => onActiveTerminalChange(terminalId)} | |
| 1600 | onDoubleClick={(event) => { | |
| 1601 | event.preventDefault(); | |
| 1602 | onActiveTerminalChange(terminalId); | |
| 1603 | startTerminalRename(terminalId); | |
| 1604 | }} | |
| 1526 | 1605 | > |
| 1527 | {showGroupHeaders && ( | |
| 1528 | <span className="text-[10px] text-muted-foreground/80">└</span> | |
| 1529 | )} | |
| 1530 | <button | |
| 1531 | type="button" | |
| 1532 | className="flex min-w-0 flex-1 items-center gap-1 text-left" | |
| 1533 | onClick={() => onActiveTerminalChange(terminalId)} | |
| 1534 | > | |
| 1535 | <TerminalSquare className="size-3 shrink-0" /> | |
| 1536 | <span className="truncate"> | |
| 1537 | {terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 1538 | </span> | |
| 1539 | </button> | |
| 1540 | {normalizedTerminalIds.length > 1 && ( | |
| 1541 | <Popover> | |
| 1542 | <PopoverTrigger | |
| 1543 | openOnHover | |
| 1544 | render={ | |
| 1545 | <button | |
| 1546 | type="button" | |
| 1547 | className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | |
| 1548 | onClick={() => onCloseTerminal(terminalId)} | |
| 1549 | aria-label={closeTerminalLabel} | |
| 1550 | /> | |
| 1551 | } | |
| 1552 | > | |
| 1553 | <XIcon className="size-2.5" /> | |
| 1554 | </PopoverTrigger> | |
| 1555 | <PopoverPopup | |
| 1556 | tooltipStyle | |
| 1557 | side="bottom" | |
| 1558 | sideOffset={6} | |
| 1559 | align="center" | |
| 1560 | className="pointer-events-none select-none" | |
| 1561 | > | |
| 1562 | {closeTerminalLabel} | |
| 1563 | </PopoverPopup> | |
| 1564 | </Popover> | |
| 1565 | )} | |
| 1566 | </div> | |
| 1567 | ); | |
| 1568 | })} | |
| 1569 | </div> | |
| 1570 | </div> | |
| 1571 | ); | |
| 1572 | })} | |
| 1573 | </div> | |
| 1574 | </aside> | |
| 1575 | )} | |
| 1576 | </div> | |
| 1606 | <span className="min-w-0 flex-1 truncate"> | |
| 1607 | {displayLabel} | |
| 1608 | {customLabel && customLabel !== automaticLabel ? ( | |
| 1609 | <span className="font-normal text-muted-foreground/80"> | |
| 1610 | {` · ${automaticLabel}`} | |
| 1611 | </span> | |
| 1612 | ) : null} | |
| 1613 | </span> | |
| 1614 | </button> | |
| 1615 | )} | |
| 1616 | </div> | |
| 1617 | ); | |
| 1618 | })} | |
| 1619 | </div> | |
| 1620 | </div> | |
| 1621 | ); | |
| 1622 | })} | |
| 1623 | </div> | |
| 1624 | </aside> | |
| 1577 | 1625 | </div> |
| 1578 | 1626 | </aside> |
| 1579 | 1627 | ); |
apps/web/src/components/WorkspacePageContainer.tsx created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import type { ComponentPropsWithoutRef } from "react"; | |
| 2 | ||
| 3 | import { cn } from "../lib/utils"; | |
| 4 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; | |
| 5 | ||
| 6 | export type WorkspacePageWidth = "readable" | "wide" | "expanded"; | |
| 7 | ||
| 8 | const 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. */ | |
| 15 | export 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. */ | |
| 33 | export 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. */ | |
| 57 | export function WorkspacePageHeaderEdgeControl({ | |
| 58 | className, | |
| 59 | ...props | |
| 60 | }: ComponentPropsWithoutRef<"div">) { | |
| 61 | return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />; | |
| 62 | } |
apps/web/src/components/chat/ChangedFilesTree.test.tsx+11-12| ... | ... | @@ -23,13 +23,9 @@ describe("ChangedFilesCard", () => { |
| 23 | 23 | expect(markup).toContain('data-changed-files-state="expanded"'); |
| 24 | 24 | expect(markup).toContain('aria-expanded="true"'); |
| 25 | 25 | expect(markup).toContain("whitespace-nowrap"); |
| 26 | expect(markup).toContain( | |
| 27 | 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', | |
| 28 | ); | |
| 26 | expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); | |
| 29 | 27 | expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); |
| 30 | expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); | |
| 31 | expect(markup).toContain("@[24rem]/changed-files:inline"); | |
| 32 | expect(markup).not.toContain("sm:inline"); | |
| 28 | expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>'); | |
| 33 | 29 | expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); |
| 34 | 30 | expect(markup).toContain("!size-[22px]"); |
| 35 | 31 | expect(markup).toContain("size-3"); |
| ... | ... | @@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => { |
| 38 | 34 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); |
| 39 | 35 | expect(markup).toContain("1 changed file"); |
| 40 | 36 | expect(markup).not.toContain("1 changed files"); |
| 37 | expect(markup).not.toContain("Hide files"); | |
| 38 | expect(markup).not.toContain("ml-auto"); | |
| 41 | 39 | }); |
| 42 | 40 | |
| 43 | it("renders a scope and representative-file preview for a large latest change", () => { | |
| 41 | it("renders a clean representative-file preview for a large latest change", () => { | |
| 44 | 42 | const markup = renderToStaticMarkup( |
| 45 | 43 | <ChangedFilesCard |
| 46 | 44 | turnId={TurnId.make("turn-1")} |
| ... | ... | @@ -67,14 +65,15 @@ describe("ChangedFilesCard", () => { |
| 67 | 65 | |
| 68 | 66 | expect(markup).toContain('data-changed-files-state="preview"'); |
| 69 | 67 | expect(markup).toContain('aria-expanded="false"'); |
| 70 | expect(markup).toContain("apps"); | |
| 71 | expect(markup).toContain("2 files"); | |
| 72 | expect(markup).toContain("packages"); | |
| 73 | expect(markup).toContain("root"); | |
| 68 | expect(markup).toContain("apps/web/src/"); | |
| 69 | expect(markup).toContain("packages/shared/src/"); | |
| 74 | 70 | expect(markup).toContain("App.tsx"); |
| 75 | 71 | expect(markup).toContain("git.ts"); |
| 76 | 72 | expect(markup).toContain("README.md"); |
| 77 | expect(markup).toContain("Show all 4 files"); | |
| 73 | expect(markup).not.toContain("basis-0"); | |
| 74 | expect(markup).not.toContain("+1 more"); | |
| 75 | expect(markup).not.toContain("Show files"); | |
| 76 | expect(markup).toContain('aria-label="120 additions, 20 deletions"'); | |
| 78 | 77 | expect(markup).not.toContain("App.test.tsx"); |
| 79 | 78 | }); |
| 80 | 79 |
apps/web/src/components/chat/ChangedFilesTree.tsx+23-39| ... | ... | @@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 19 | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 20 | 20 | import { Button } from "../ui/button"; |
| 21 | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | import { | |
| 23 | changedFileName, | |
| 24 | selectChangedFilePreview, | |
| 25 | summarizeChangedFileScopes, | |
| 26 | } from "./changedFilesPresentation"; | |
| 22 | import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; | |
| 27 | 23 | |
| 28 | 24 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 29 | 25 | |
| ... | ... | @@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 50 | 46 | onOpenTurnDiff, |
| 51 | 47 | } = props; |
| 52 | 48 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 53 | const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); | |
| 54 | 49 | const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); |
| 55 | 50 | const compactPreviewVisible = showCompactPreview && !expanded; |
| 56 | 51 | |
| 57 | 52 | return ( |
| 58 | 53 | <div |
| 59 | className="@container/changed-files mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32" | |
| 54 | className="@container/changed-files mt-4 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32" | |
| 60 | 55 | data-changed-files-state={ |
| 61 | 56 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" |
| 62 | 57 | } |
| ... | ... | @@ -64,7 +59,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 64 | 59 | <div |
| 65 | 60 | data-changed-files-header="" |
| 66 | 61 | className={cn( |
| 67 | "flex items-center justify-between gap-2 rounded-xl px-1", | |
| 62 | "flex items-center justify-between gap-2 rounded-lg px-1", | |
| 68 | 63 | expanded && |
| 69 | 64 | "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", |
| 70 | 65 | )} |
| ... | ... | @@ -73,7 +68,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 73 | 68 | type="button" |
| 74 | 69 | aria-expanded={expanded} |
| 75 | 70 | data-scroll-anchor-ignore |
| 76 | className="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 71 | className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 77 | 72 | onClick={() => onExpandedChange(!expanded)} |
| 78 | 73 | > |
| 79 | 74 | <ChevronRightIcon |
| ... | ... | @@ -96,9 +91,6 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 96 | 91 | /> |
| 97 | 92 | )} |
| 98 | 93 | </span> |
| 99 | <span className="ml-1 hidden min-w-0 flex-1 truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 @[24rem]/changed-files:inline"> | |
| 100 | {expanded ? "Hide files" : "Show files"} | |
| 101 | </span> | |
| 102 | 94 | </button> |
| 103 | 95 | <div className="flex shrink-0 items-center gap-1.5"> |
| 104 | 96 | {expanded ? ( |
| ... | ... | @@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 158 | 150 | onOpenTurnDiff={onOpenTurnDiff} |
| 159 | 151 | /> |
| 160 | 152 | ) : compactPreviewVisible ? ( |
| 161 | <div className="px-2 pb-1.5 pt-1"> | |
| 162 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground"> | |
| 163 | {scopeSummary.map((scope, index) => ( | |
| 164 | <span key={scope.label} className="inline-flex items-center gap-1"> | |
| 165 | {index > 0 ? <span aria-hidden="true">·</span> : null} | |
| 166 | <span className="font-mono text-foreground/75">{scope.label}</span> | |
| 167 | <span> | |
| 168 | {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} | |
| 169 | </span> | |
| 170 | </span> | |
| 171 | ))} | |
| 172 | </p> | |
| 173 | <div className="mt-2 flex flex-wrap items-center gap-1.5"> | |
| 153 | <div className="px-1.5 pb-1 pt-0.5"> | |
| 154 | <div className="flex min-w-0 items-center gap-x-3 overflow-hidden"> | |
| 174 | 155 | {previewFiles.map((file) => ( |
| 175 | 156 | <button |
| 176 | 157 | key={file.path} |
| 177 | 158 | type="button" |
| 178 | 159 | title={file.path} |
| 179 | className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 160 | className="group flex min-h-6 min-w-0 max-w-72 shrink items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 180 | 161 | onClick={() => onOpenTurnDiff(turnId, file.path)} |
| 181 | 162 | > |
| 182 | 163 | <PierreEntryIcon |
| 183 | 164 | pathValue={file.path} |
| 184 | 165 | kind="file" |
| 185 | 166 | theme={resolvedTheme} |
| 186 | className="size-3 shrink-0 text-muted-foreground/70" | |
| 167 | className="size-3.5 shrink-0 text-muted-foreground/70" | |
| 187 | 168 | /> |
| 188 | <span className="truncate">{changedFileName(file.path)}</span> | |
| 169 | <span className="min-w-0 truncate font-mono text-xs text-foreground/75 group-hover:text-foreground"> | |
| 170 | {changedFileName(file.path)} | |
| 171 | </span> | |
| 172 | {hasNonZeroStat(file) ? ( | |
| 173 | <DiffStatLabel | |
| 174 | additions={file.additions} | |
| 175 | deletions={file.deletions} | |
| 176 | layout="inline" | |
| 177 | className="shrink-0 text-[10px]" | |
| 178 | /> | |
| 179 | ) : null} | |
| 189 | 180 | </button> |
| 190 | 181 | ))} |
| 191 | <button | |
| 192 | type="button" | |
| 193 | className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 194 | onClick={() => onExpandedChange(true)} | |
| 195 | > | |
| 196 | Show all {files.length} files | |
| 197 | </button> | |
| 198 | 182 | </div> |
| 199 | 183 | </div> |
| 200 | 184 | ) : null} |
| ... | ... | @@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 270 | 254 | ) : ( |
| 271 | 255 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| 272 | 256 | )} |
| 273 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | |
| 257 | <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | |
| 274 | 258 | {node.name} |
| 275 | 259 | </span> |
| 276 | 260 | {hasNonZeroStat(node.stat) && ( |
| 277 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 261 | <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums"> | |
| 278 | 262 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 279 | 263 | </span> |
| 280 | 264 | )} |
| ... | ... | @@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 305 | 289 | theme={resolvedTheme} |
| 306 | 290 | className="size-3.5 text-muted-foreground/70" |
| 307 | 291 | /> |
| 308 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | |
| 292 | <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | |
| 309 | 293 | {node.name} |
| 310 | 294 | </span> |
| 311 | 295 | {node.stat && ( |
| 312 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 296 | <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums"> | |
| 313 | 297 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 314 | 298 | </span> |
| 315 | 299 | )} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+209-7| ... | ... | @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { |
| 531 | 531 | |
| 532 | 532 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 533 | 533 | "user-entry", |
| 534 | "turn-fold:turn-1", | |
| 535 | 534 | "assistant-thought-entry", |
| 536 | "work-entry-1", | |
| 535 | "work-toggle:work-entry-1", | |
| 536 | "turn-fold:turn-1", | |
| 537 | 537 | "assistant-final-entry", |
| 538 | 538 | ]); |
| 539 | 539 | expect( |
| ... | ... | @@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => { |
| 638 | 638 | expect(foldRow?.label).toBe("Worked for 12s"); |
| 639 | 639 | }); |
| 640 | 640 | |
| 641 | it("keeps a superseded turn fold beside the final response after a steer", () => { | |
| 642 | const rows = deriveMessagesTimelineRows({ | |
| 643 | timelineEntries: [ | |
| 644 | { | |
| 645 | id: "initial-user-entry", | |
| 646 | kind: "message", | |
| 647 | createdAt: "2026-01-01T00:00:00Z", | |
| 648 | message: { | |
| 649 | id: "initial-user" as never, | |
| 650 | role: "user", | |
| 651 | text: "Start the work", | |
| 652 | turnId: null, | |
| 653 | createdAt: "2026-01-01T00:00:00Z", | |
| 654 | updatedAt: "2026-01-01T00:00:00Z", | |
| 655 | streaming: false, | |
| 656 | }, | |
| 657 | }, | |
| 658 | { | |
| 659 | id: "superseded-work-entry", | |
| 660 | kind: "work", | |
| 661 | createdAt: "2026-01-01T00:00:10Z", | |
| 662 | entry: { | |
| 663 | id: "superseded-work", | |
| 664 | createdAt: "2026-01-01T00:00:10Z", | |
| 665 | turnId: "turn-1" as never, | |
| 666 | label: "Ran command", | |
| 667 | tone: "tool", | |
| 668 | }, | |
| 669 | }, | |
| 670 | { | |
| 671 | id: "steer-user-entry", | |
| 672 | kind: "message", | |
| 673 | createdAt: "2026-01-01T00:00:12Z", | |
| 674 | message: { | |
| 675 | id: "steer-user" as never, | |
| 676 | role: "user", | |
| 677 | text: "Change the approach", | |
| 678 | turnId: null, | |
| 679 | createdAt: "2026-01-01T00:00:12Z", | |
| 680 | updatedAt: "2026-01-01T00:00:12Z", | |
| 681 | streaming: false, | |
| 682 | }, | |
| 683 | }, | |
| 684 | { | |
| 685 | id: "assistant-final-entry", | |
| 686 | kind: "message", | |
| 687 | createdAt: "2026-01-01T00:00:20Z", | |
| 688 | message: { | |
| 689 | id: "assistant-final" as never, | |
| 690 | role: "assistant", | |
| 691 | text: "Implemented locally, uncommitted.", | |
| 692 | turnId: "turn-2" as never, | |
| 693 | createdAt: "2026-01-01T00:00:20Z", | |
| 694 | updatedAt: "2026-01-01T00:00:21Z", | |
| 695 | streaming: false, | |
| 696 | }, | |
| 697 | }, | |
| 698 | ], | |
| 699 | latestTurn: { | |
| 700 | turnId: "turn-2" as never, | |
| 701 | state: "completed", | |
| 702 | startedAt: "2026-01-01T00:00:12Z", | |
| 703 | completedAt: "2026-01-01T00:00:21Z", | |
| 704 | }, | |
| 705 | isWorking: false, | |
| 706 | activeTurnStartedAt: null, | |
| 707 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 708 | revertTurnCountByUserMessageId: new Map(), | |
| 709 | }); | |
| 710 | ||
| 711 | expect(rows.map((row) => row.id)).toEqual([ | |
| 712 | "initial-user-entry", | |
| 713 | "steer-user-entry", | |
| 714 | "turn-fold:turn-1", | |
| 715 | "assistant-final-entry", | |
| 716 | ]); | |
| 717 | }); | |
| 718 | ||
| 641 | 719 | it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { |
| 642 | 720 | const rows = deriveMessagesTimelineRows({ |
| 643 | 721 | timelineEntries: [ |
| ... | ... | @@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 771 | 849 | turnId: "turn-1" as never, |
| 772 | 850 | label: "Ran command", |
| 773 | 851 | tone: "tool" as const, |
| 852 | toolLifecycleStatus: "inProgress" as const, | |
| 774 | 853 | }, |
| 775 | 854 | }, |
| 776 | 855 | ], |
| ... | ... | @@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => { |
| 788 | 867 | |
| 789 | 868 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| 790 | 869 | expect(rows.map((row) => row.id)).toEqual([ |
| 870 | "working-indicator-row", | |
| 791 | 871 | "assistant-thought-entry", |
| 792 | "work-entry-1", | |
| 872 | "work-live:work-entry-1", | |
| 873 | ]); | |
| 874 | }); | |
| 875 | ||
| 876 | it("keeps the current tool batch expandable while live entries append", () => { | |
| 877 | const timelineEntries = [ | |
| 878 | { | |
| 879 | id: "work-entry-1", | |
| 880 | kind: "work" as const, | |
| 881 | createdAt: "2026-01-01T00:00:01Z", | |
| 882 | entry: { | |
| 883 | id: "work-1", | |
| 884 | createdAt: "2026-01-01T00:00:01Z", | |
| 885 | turnId: "turn-1" as never, | |
| 886 | toolCallId: "call-1", | |
| 887 | label: "Read file", | |
| 888 | tone: "tool" as const, | |
| 889 | }, | |
| 890 | }, | |
| 891 | { | |
| 892 | id: "work-entry-2", | |
| 893 | kind: "work" as const, | |
| 894 | createdAt: "2026-01-01T00:00:02Z", | |
| 895 | entry: { | |
| 896 | id: "work-2", | |
| 897 | createdAt: "2026-01-01T00:00:02Z", | |
| 898 | turnId: "turn-1" as never, | |
| 899 | toolCallId: "call-2", | |
| 900 | label: "Run command", | |
| 901 | command: "vp test run", | |
| 902 | tone: "tool" as const, | |
| 903 | }, | |
| 904 | }, | |
| 905 | ]; | |
| 906 | const baseInput = { | |
| 907 | timelineEntries, | |
| 908 | latestTurn: { | |
| 909 | turnId: "turn-1" as never, | |
| 910 | state: "running" as const, | |
| 911 | startedAt: "2026-01-01T00:00:00Z", | |
| 912 | completedAt: null, | |
| 913 | }, | |
| 914 | isWorking: true, | |
| 915 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 916 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 917 | revertTurnCountByUserMessageId: new Map(), | |
| 918 | }; | |
| 919 | ||
| 920 | const collapsedRows = deriveMessagesTimelineRows(baseInput); | |
| 921 | const expandedRows = deriveMessagesTimelineRows({ | |
| 922 | ...baseInput, | |
| 923 | expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), | |
| 924 | }); | |
| 925 | ||
| 926 | expect(collapsedRows.map((row) => row.id)).toEqual([ | |
| 793 | 927 | "working-indicator-row", |
| 928 | "work-live:tool:call-1", | |
| 794 | 929 | ]); |
| 930 | expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ | |
| 931 | groupId: "work-group:tool:call-1", | |
| 932 | expanded: false, | |
| 933 | groupedEntries: [{ id: "work-1" }, { id: "work-2" }], | |
| 934 | }); | |
| 935 | expect(expandedRows.map((row) => row.id)).toEqual([ | |
| 936 | "working-indicator-row", | |
| 937 | "work-live:tool:call-1", | |
| 938 | "work-1", | |
| 939 | "work-2", | |
| 940 | ]); | |
| 941 | expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ | |
| 942 | groupId: "work-group:tool:call-1", | |
| 943 | expanded: true, | |
| 944 | }); | |
| 945 | ||
| 946 | const appendedRows = deriveMessagesTimelineRows({ | |
| 947 | ...baseInput, | |
| 948 | timelineEntries: [ | |
| 949 | ...timelineEntries, | |
| 950 | { | |
| 951 | id: "work-entry-3", | |
| 952 | kind: "work" as const, | |
| 953 | createdAt: "2026-01-01T00:00:03Z", | |
| 954 | entry: { | |
| 955 | id: "work-3", | |
| 956 | createdAt: "2026-01-01T00:00:03Z", | |
| 957 | turnId: "turn-1" as never, | |
| 958 | toolCallId: "call-3", | |
| 959 | label: "Changed file", | |
| 960 | tone: "tool" as const, | |
| 961 | }, | |
| 962 | }, | |
| 963 | ], | |
| 964 | expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), | |
| 965 | }); | |
| 966 | ||
| 967 | expect(appendedRows.map((row) => row.id)).toEqual([ | |
| 968 | "working-indicator-row", | |
| 969 | "work-live:tool:call-1", | |
| 970 | "work-1", | |
| 971 | "work-2", | |
| 972 | "work-3", | |
| 973 | ]); | |
| 974 | ||
| 975 | const rowsWithLaterPlan = deriveMessagesTimelineRows({ | |
| 976 | ...baseInput, | |
| 977 | timelineEntries: [ | |
| 978 | ...timelineEntries, | |
| 979 | { | |
| 980 | id: "plan:thread-1:turn:turn-1", | |
| 981 | kind: "proposed-plan" as const, | |
| 982 | createdAt: "2026-01-01T00:00:03Z", | |
| 983 | proposedPlan: { | |
| 984 | id: "plan:thread-1:turn:turn-1", | |
| 985 | turnId: "turn-1" as never, | |
| 986 | planMarkdown: "# Next steps", | |
| 987 | implementedAt: null, | |
| 988 | implementationThreadId: null, | |
| 989 | createdAt: "2026-01-01T00:00:03Z", | |
| 990 | updatedAt: "2026-01-01T00:00:03Z", | |
| 991 | }, | |
| 992 | }, | |
| 993 | ], | |
| 994 | }); | |
| 995 | expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); | |
| 996 | expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); | |
| 795 | 997 | }); |
| 796 | 998 | |
| 797 | 999 | it("does not fold the session's running turn when latestTurn regresses", () => { |
| ... | ... | @@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 852 | 1054 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ |
| 853 | 1055 | "turn-1", |
| 854 | 1056 | ]); |
| 855 | expect(rows.map((row) => row.id)).toContain("running-work-entry"); | |
| 1057 | expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); | |
| 856 | 1058 | }); |
| 857 | 1059 | |
| 858 | 1060 | it("only shows assistant metadata on the terminal assistant message", () => { |
| ... | ... | @@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 994 | 1196 | expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), |
| 995 | 1197 | }); |
| 996 | 1198 | |
| 997 | expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); | |
| 1199 | expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); | |
| 998 | 1200 | expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| 999 | 1201 | groupId: "work-group:work-entry-1", |
| 1000 | hiddenCount: 2, | |
| 1202 | hiddenCount: 3, | |
| 1001 | 1203 | expanded: false, |
| 1002 | 1204 | onlyToolEntries: true, |
| 1003 | 1205 | }); |
| 1004 | 1206 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 1207 | "work-toggle:work-entry-1", | |
| 1005 | 1208 | "work-1", |
| 1006 | 1209 | "work-2", |
| 1007 | 1210 | "work-3", |
| 1008 | "work-toggle:work-entry-1", | |
| 1009 | 1211 | ]); |
| 1010 | 1212 | expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| 1011 | 1213 | expanded: true, |
apps/web/src/components/chat/MessagesTimeline.logic.ts+382-30| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { |
| 3 | 3 | formatDuration, |
| 4 | workEntryIndicatesToolFailure, | |
| 4 | 5 | workEntryIndicatesToolNeutralStatus, |
| 5 | 6 | workLogEntryIsToolLike, |
| 6 | 7 | type TimelineEntry, |
| ... | ... | @@ -166,6 +167,17 @@ export type MessagesTimelineRow = |
| 166 | 167 | id: string; |
| 167 | 168 | createdAt: string; |
| 168 | 169 | groupedEntries: WorkLogEntry[]; |
| 170 | isExpandedToolGroupEntry: boolean; | |
| 171 | isLastExpandedToolGroupEntry: boolean; | |
| 172 | } | |
| 173 | | { | |
| 174 | kind: "work-live"; | |
| 175 | id: string; | |
| 176 | createdAt: string; | |
| 177 | entry: WorkLogEntry; | |
| 178 | groupedEntries: WorkLogEntry[]; | |
| 179 | groupId: string; | |
| 180 | expanded: boolean; | |
| 169 | 181 | } |
| 170 | 182 | | { |
| 171 | 183 | kind: "work-toggle"; |
| ... | ... | @@ -175,6 +187,9 @@ export type MessagesTimelineRow = |
| 175 | 187 | hiddenCount: number; |
| 176 | 188 | expanded: boolean; |
| 177 | 189 | onlyToolEntries: boolean; |
| 190 | summary: string | null; | |
| 191 | summaryKind: ToolGroupAction | "mixed" | null; | |
| 192 | hasFailure: boolean; | |
| 178 | 193 | } |
| 179 | 194 | | { |
| 180 | 195 | kind: "turn-fold"; |
| ... | ... | @@ -208,7 +223,12 @@ export type MessagesTimelineRow = |
| 208 | 223 | createdAt: string; |
| 209 | 224 | turnPlan: TurnPlanEntry; |
| 210 | 225 | } |
| 211 | | { kind: "working"; id: string; createdAt: string | null }; | |
| 226 | | { | |
| 227 | kind: "working"; | |
| 228 | id: string; | |
| 229 | createdAt: string | null; | |
| 230 | showThinking: boolean; | |
| 231 | }; | |
| 212 | 232 | |
| 213 | 233 | export interface StableMessagesTimelineRowsState { |
| 214 | 234 | byId: Map<string, MessagesTimelineRow>; |
| ... | ... | @@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string { |
| 238 | 258 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 239 | 259 | } |
| 240 | 260 | |
| 261 | type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; | |
| 262 | ||
| 263 | function 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 | ||
| 279 | function 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 | ||
| 297 | function 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. */ | |
| 313 | export 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 | ||
| 332 | function 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 | ||
| 337 | function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { | |
| 338 | return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; | |
| 339 | } | |
| 340 | ||
| 341 | function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { | |
| 342 | return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; | |
| 343 | } | |
| 344 | ||
| 241 | 345 | export function resolveAssistantMessageCopyState({ |
| 242 | 346 | text, |
| 243 | 347 | showCopyButton, |
| ... | ... | @@ -310,17 +414,34 @@ function deriveUnsettledTurnId( |
| 310 | 414 | return isSettled ? null : latestTurn.turnId; |
| 311 | 415 | } |
| 312 | 416 | |
| 417 | function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number { | |
| 418 | return timelineEntries.findLastIndex( | |
| 419 | (entry) => entry.kind === "message" && entry.message.role === "user", | |
| 420 | ); | |
| 421 | } | |
| 422 | ||
| 423 | function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { | |
| 424 | if (entry.kind === "message") { | |
| 425 | return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; | |
| 426 | } | |
| 427 | if (entry.kind === "turn-plan") { | |
| 428 | return entry.turnPlan.turnId; | |
| 429 | } | |
| 430 | return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; | |
| 431 | } | |
| 432 | ||
| 313 | 433 | /** |
| 314 | 434 | * Settled turns fold their commentary and tool activity behind a |
| 315 | * "Worked for ..." row anchored at the turn's first foldable entry; the | |
| 316 | * terminal assistant message stays visible below the fold. | |
| 435 | * "Worked for ..." row placed immediately before the next terminal assistant | |
| 436 | * response. A steer can split one visible response across turn ids, so tying | |
| 437 | * the disclosure to the first hidden entry would strand it above the steer. | |
| 317 | 438 | */ |
| 318 | 439 | function deriveTurnFolds(input: { |
| 319 | 440 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 320 | 441 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 321 | 442 | latestTurn: TimelineLatestTurn | null; |
| 322 | 443 | unsettledTurnId: TurnId | null; |
| 323 | }): ReadonlyMap<string, TurnFold> { | |
| 444 | }): ReadonlyMap<string, ReadonlyArray<TurnFold>> { | |
| 324 | 445 | interface TurnGroup { |
| 325 | 446 | entries: Array<TimelineEntry>; |
| 326 | 447 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; |
| ... | ... | @@ -375,7 +496,7 @@ function deriveTurnFolds(input: { |
| 375 | 496 | } |
| 376 | 497 | } |
| 377 | 498 | |
| 378 | const foldsByAnchorEntryId = new Map<string, TurnFold>(); | |
| 499 | const foldsByAnchorEntryId = new Map<string, TurnFold[]>(); | |
| 379 | 500 | for (const [turnId, group] of groupsByTurnId) { |
| 380 | 501 | if (turnId === input.unsettledTurnId) { |
| 381 | 502 | continue; |
| ... | ... | @@ -405,6 +526,24 @@ function deriveTurnFolds(input: { |
| 405 | 526 | if (!firstEntry || !lastEntry) { |
| 406 | 527 | continue; |
| 407 | 528 | } |
| 529 | const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => | |
| 530 | hiddenEntryIds.has(entry.id), | |
| 531 | ); | |
| 532 | if (lastHiddenEntryIndex < 0) { | |
| 533 | continue; | |
| 534 | } | |
| 535 | const nextTerminalAssistantEntry = input.timelineEntries | |
| 536 | .slice(lastHiddenEntryIndex + 1) | |
| 537 | .find( | |
| 538 | (entry) => | |
| 539 | entry.kind === "message" && | |
| 540 | entry.message.role === "assistant" && | |
| 541 | input.terminalAssistantMessageIds.has(entry.message.id), | |
| 542 | ); | |
| 543 | const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; | |
| 544 | if (!anchorEntry) { | |
| 545 | continue; | |
| 546 | } | |
| 408 | 547 | |
| 409 | 548 | const isLatestInterruptedTurn = |
| 410 | 549 | input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; |
| ... | ... | @@ -431,13 +570,16 @@ function deriveTurnFolds(input: { |
| 431 | 570 | ? `Worked for ${duration}` |
| 432 | 571 | : "Worked"; |
| 433 | 572 | |
| 434 | foldsByAnchorEntryId.set(firstEntry.id, { | |
| 573 | const fold = { | |
| 435 | 574 | turnId, |
| 436 | anchorEntryId: firstEntry.id, | |
| 437 | createdAt: firstEntry.createdAt, | |
| 575 | anchorEntryId: anchorEntry.id, | |
| 576 | createdAt: anchorEntry.createdAt, | |
| 438 | 577 | hiddenEntryIds, |
| 439 | 578 | label, |
| 440 | }); | |
| 579 | }; | |
| 580 | const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); | |
| 581 | if (anchoredFolds) anchoredFolds.push(fold); | |
| 582 | else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); | |
| 441 | 583 | } |
| 442 | 584 | return foldsByAnchorEntryId; |
| 443 | 585 | } |
| ... | ... | @@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: { |
| 469 | 611 | unsettledTurnId, |
| 470 | 612 | }); |
| 471 | 613 | const collapsedEntryIds = new Set<string>(); |
| 472 | for (const fold of foldsByAnchorEntryId.values()) { | |
| 473 | if (!input.expandedTurnIds?.has(fold.turnId)) { | |
| 474 | for (const entryId of fold.hiddenEntryIds) { | |
| 475 | collapsedEntryIds.add(entryId); | |
| 614 | for (const folds of foldsByAnchorEntryId.values()) { | |
| 615 | for (const fold of folds) { | |
| 616 | if (!input.expandedTurnIds?.has(fold.turnId)) { | |
| 617 | for (const entryId of fold.hiddenEntryIds) { | |
| 618 | collapsedEntryIds.add(entryId); | |
| 619 | } | |
| 476 | 620 | } |
| 477 | 621 | } |
| 478 | 622 | } |
| 479 | 623 | |
| 624 | let activeTurnHeaderIndex = input.timelineEntries.length; | |
| 625 | if (input.isWorking) { | |
| 626 | const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); | |
| 627 | const firstOwnedAfterUser = | |
| 628 | unsettledTurnId === null | |
| 629 | ? -1 | |
| 630 | : input.timelineEntries.findIndex( | |
| 631 | (entry, index) => | |
| 632 | index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, | |
| 633 | ); | |
| 634 | activeTurnHeaderIndex = | |
| 635 | firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; | |
| 636 | } | |
| 637 | const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => | |
| 638 | input.isWorking && | |
| 639 | index >= activeTurnHeaderIndex && | |
| 640 | (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); | |
| 641 | const isVisibleActiveToolEntry = (entry: WorkLogEntry) => | |
| 642 | workLogEntryIsToolLike(entry) && | |
| 643 | (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); | |
| 644 | const activeEntries = input.isWorking | |
| 645 | ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) | |
| 646 | : []; | |
| 647 | const activeTurnHasVisibleContent = | |
| 648 | activeEntries.some((entry) => { | |
| 649 | if (entry.kind === "message") { | |
| 650 | return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; | |
| 651 | } | |
| 652 | if (entry.kind === "work") { | |
| 653 | return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); | |
| 654 | } | |
| 655 | if (entry.kind === "turn-plan") return true; | |
| 656 | return false; | |
| 657 | }) || | |
| 658 | input.timelineEntries | |
| 659 | .slice(activeTurnHeaderIndex) | |
| 660 | .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); | |
| 661 | ||
| 662 | const activeWorkEntryIds = new Set<string>(); | |
| 663 | const activeWorkRowsByAnchorId = new Map< | |
| 664 | string, | |
| 665 | Extract<MessagesTimelineRow, { kind: "work-live" }> | |
| 666 | >(); | |
| 667 | const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); | |
| 668 | for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { | |
| 669 | const entry = input.timelineEntries[index]; | |
| 670 | if (!entry) continue; | |
| 671 | const isVisibleTurnContent = | |
| 672 | (entry.kind === "message" && entry.message.role === "user") || | |
| 673 | entry.kind === "proposed-plan" || | |
| 674 | (entryBelongsToActiveTurn(entry, index) && | |
| 675 | ((entry.kind === "message" && entry.message.role === "assistant") || | |
| 676 | entry.kind === "turn-plan" || | |
| 677 | (entry.kind === "work" && | |
| 678 | entry.entry.agentSpawn === undefined && | |
| 679 | isVisibleActiveToolEntry(entry.entry)))); | |
| 680 | hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; | |
| 681 | } | |
| 682 | ||
| 683 | for (let index = 0; index < input.timelineEntries.length; index += 1) { | |
| 684 | const entry = input.timelineEntries[index]; | |
| 685 | if ( | |
| 686 | !entry || | |
| 687 | entry.kind !== "work" || | |
| 688 | entry.entry.agentSpawn !== undefined || | |
| 689 | !entryBelongsToActiveTurn(entry, index) | |
| 690 | ) { | |
| 691 | continue; | |
| 692 | } | |
| 693 | if (!isVisibleActiveToolEntry(entry.entry)) { | |
| 694 | continue; | |
| 695 | } | |
| 696 | ||
| 697 | const anchorEntry = entry; | |
| 698 | let latestToolEntry = entry; | |
| 699 | const batchEntryIds = [entry.id]; | |
| 700 | const visibleBatchEntries = [entry.entry]; | |
| 701 | let cursor = index + 1; | |
| 702 | while (cursor < input.timelineEntries.length) { | |
| 703 | const nextEntry = input.timelineEntries[cursor]; | |
| 704 | if ( | |
| 705 | !nextEntry || | |
| 706 | nextEntry.kind !== "work" || | |
| 707 | nextEntry.entry.agentSpawn !== undefined || | |
| 708 | !entryBelongsToActiveTurn(nextEntry, cursor) | |
| 709 | ) { | |
| 710 | break; | |
| 711 | } | |
| 712 | batchEntryIds.push(nextEntry.id); | |
| 713 | if (isVisibleActiveToolEntry(nextEntry.entry)) { | |
| 714 | latestToolEntry = nextEntry; | |
| 715 | visibleBatchEntries.push(nextEntry.entry); | |
| 716 | } | |
| 717 | cursor += 1; | |
| 718 | } | |
| 719 | ||
| 720 | // Once newer commentary, a plan, or another tool batch exists, this batch | |
| 721 | // is history. Let the regular work-group path turn it into an expandable | |
| 722 | // summary so none of its calls disappear behind the live one-line view. | |
| 723 | if (hasLaterTurnContent[cursor] !== true) { | |
| 724 | for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); | |
| 725 | const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); | |
| 726 | activeWorkRowsByAnchorId.set(anchorEntry.id, { | |
| 727 | kind: "work-live", | |
| 728 | id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, | |
| 729 | createdAt: anchorEntry.createdAt, | |
| 730 | entry: latestToolEntry.entry, | |
| 731 | groupedEntries: visibleBatchEntries, | |
| 732 | groupId, | |
| 733 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, | |
| 734 | }); | |
| 735 | } | |
| 736 | index = cursor - 1; | |
| 737 | } | |
| 738 | ||
| 480 | 739 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 481 | 740 | const timelineEntry = input.timelineEntries[index]; |
| 482 | 741 | if (!timelineEntry) { |
| 483 | 742 | continue; |
| 484 | 743 | } |
| 485 | 744 | |
| 486 | const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); | |
| 487 | if (turnFold) { | |
| 745 | if (input.isWorking && index === activeTurnHeaderIndex) { | |
| 488 | 746 | nextRows.push({ |
| 489 | kind: "turn-fold", | |
| 490 | id: `turn-fold:${turnFold.turnId}`, | |
| 491 | createdAt: turnFold.createdAt, | |
| 492 | turnId: turnFold.turnId, | |
| 493 | label: turnFold.label, | |
| 494 | expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, | |
| 747 | kind: "working", | |
| 748 | id: "working-indicator-row", | |
| 749 | createdAt: input.activeTurnStartedAt, | |
| 750 | showThinking: !activeTurnHasVisibleContent, | |
| 495 | 751 | }); |
| 496 | 752 | } |
| 497 | 753 | |
| 754 | const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); | |
| 755 | if (anchoredTurnFolds) { | |
| 756 | for (const turnFold of anchoredTurnFolds) { | |
| 757 | nextRows.push({ | |
| 758 | kind: "turn-fold", | |
| 759 | id: `turn-fold:${turnFold.turnId}`, | |
| 760 | createdAt: turnFold.createdAt, | |
| 761 | turnId: turnFold.turnId, | |
| 762 | label: turnFold.label, | |
| 763 | expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, | |
| 764 | }); | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 498 | 768 | if (collapsedEntryIds.has(timelineEntry.id)) { |
| 499 | 769 | continue; |
| 500 | 770 | } |
| 501 | 771 | |
| 772 | if (activeWorkEntryIds.has(timelineEntry.id)) { | |
| 773 | const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); | |
| 774 | if (activeWorkRow) { | |
| 775 | nextRows.push(activeWorkRow); | |
| 776 | if (activeWorkRow.expanded) { | |
| 777 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | |
| 778 | nextRows.push({ | |
| 779 | kind: "work", | |
| 780 | id: workEntry.id, | |
| 781 | createdAt: workEntry.createdAt, | |
| 782 | groupedEntries: [workEntry], | |
| 783 | isExpandedToolGroupEntry: true, | |
| 784 | isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, | |
| 785 | }); | |
| 786 | } | |
| 787 | } | |
| 788 | } | |
| 789 | continue; | |
| 790 | } | |
| 791 | ||
| 502 | 792 | if (timelineEntry.kind === "work") { |
| 503 | 793 | const groupedEntries = [timelineEntry.entry]; |
| 504 | 794 | let cursor = index + 1; |
| ... | ... | @@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: { |
| 507 | 797 | if ( |
| 508 | 798 | !nextEntry || |
| 509 | 799 | nextEntry.kind !== "work" || |
| 800 | activeWorkEntryIds.has(nextEntry.id) || | |
| 510 | 801 | collapsedEntryIds.has(nextEntry.id) || |
| 511 | 802 | foldsByAnchorEntryId.has(nextEntry.id) |
| 512 | 803 | ) { |
| ... | ... | @@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: { |
| 519 | 810 | (entry) => !workEntryIndicatesToolNeutralStatus(entry), |
| 520 | 811 | ); |
| 521 | 812 | if (visibleGroupedEntries.length > 0) { |
| 522 | if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { | |
| 813 | const onlyToolEntries = visibleGroupedEntries.every( | |
| 814 | (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, | |
| 815 | ); | |
| 816 | if (onlyToolEntries) { | |
| 817 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | |
| 818 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | |
| 819 | const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); | |
| 820 | nextRows.push({ | |
| 821 | kind: "work-toggle", | |
| 822 | id: `work-toggle:${timelineEntry.id}`, | |
| 823 | createdAt: timelineEntry.createdAt, | |
| 824 | groupId, | |
| 825 | hiddenCount: visibleGroupedEntries.length, | |
| 826 | expanded, | |
| 827 | onlyToolEntries: true, | |
| 828 | summary: summarizeToolGroup(visibleGroupedEntries), | |
| 829 | summaryKind, | |
| 830 | hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), | |
| 831 | }); | |
| 832 | if (expanded) { | |
| 833 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | |
| 834 | nextRows.push({ | |
| 835 | kind: "work", | |
| 836 | id: workEntry.id, | |
| 837 | createdAt: workEntry.createdAt, | |
| 838 | groupedEntries: [workEntry], | |
| 839 | isExpandedToolGroupEntry: true, | |
| 840 | isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | |
| 841 | }); | |
| 842 | } | |
| 843 | } | |
| 844 | } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { | |
| 523 | 845 | nextRows.push({ |
| 524 | 846 | kind: "work", |
| 525 | 847 | id: timelineEntry.id, |
| 526 | 848 | createdAt: timelineEntry.createdAt, |
| 527 | 849 | groupedEntries: visibleGroupedEntries, |
| 850 | isExpandedToolGroupEntry: false, | |
| 851 | isLastExpandedToolGroupEntry: false, | |
| 528 | 852 | }); |
| 529 | 853 | } else { |
| 530 | const groupId = `work-group:${timelineEntry.id}`; | |
| 854 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | |
| 531 | 855 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 532 | 856 | // Agent-spawn CTA rows are always visible: a running fleet must |
| 533 | 857 | // never hide behind a "+N tool calls" toggle. Selection is by |
| ... | ... | @@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: { |
| 551 | 875 | id: workEntry.id, |
| 552 | 876 | createdAt: workEntry.createdAt, |
| 553 | 877 | groupedEntries: [workEntry], |
| 878 | isExpandedToolGroupEntry: false, | |
| 879 | isLastExpandedToolGroupEntry: false, | |
| 554 | 880 | }); |
| 555 | 881 | } |
| 556 | 882 | |
| ... | ... | @@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: { |
| 562 | 888 | groupId, |
| 563 | 889 | hiddenCount: hiddenEntries.length, |
| 564 | 890 | expanded, |
| 565 | onlyToolEntries: visibleGroupedEntries.every((entry) => | |
| 566 | workLogEntryIsToolLike(entry), | |
| 891 | onlyToolEntries, | |
| 892 | summary: null, | |
| 893 | summaryKind: null, | |
| 894 | hasFailure: visibleGroupedEntries.some((entry) => | |
| 895 | workEntryIndicatesToolFailure(entry), | |
| 567 | 896 | ), |
| 568 | 897 | }); |
| 569 | 898 | } |
| ... | ... | @@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: { |
| 629 | 958 | }); |
| 630 | 959 | } |
| 631 | 960 | |
| 632 | if (input.isWorking) { | |
| 961 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { | |
| 633 | 962 | nextRows.push({ |
| 634 | 963 | kind: "working", |
| 635 | 964 | id: "working-indicator-row", |
| 636 | 965 | createdAt: input.activeTurnStartedAt, |
| 966 | showThinking: !activeTurnHasVisibleContent, | |
| 637 | 967 | }); |
| 638 | 968 | } |
| 639 | 969 | |
| ... | ... | @@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 666 | 996 | |
| 667 | 997 | switch (a.kind) { |
| 668 | 998 | case "working": |
| 669 | return a.createdAt === (b as typeof a).createdAt; | |
| 999 | return ( | |
| 1000 | a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking | |
| 1001 | ); | |
| 670 | 1002 | |
| 671 | 1003 | case "turn-fold": { |
| 672 | 1004 | const bf = b as typeof a; |
| ... | ... | @@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 683 | 1015 | return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; |
| 684 | 1016 | } |
| 685 | 1017 | |
| 686 | case "work": | |
| 687 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); | |
| 1018 | case "work": { | |
| 1019 | const bw = b as typeof a; | |
| 1020 | return ( | |
| 1021 | a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && | |
| 1022 | a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && | |
| 1023 | Equal.equals(a.groupedEntries, bw.groupedEntries) | |
| 1024 | ); | |
| 1025 | } | |
| 1026 | ||
| 1027 | case "work-live": { | |
| 1028 | const bw = b as typeof a; | |
| 1029 | return ( | |
| 1030 | a.createdAt === bw.createdAt && | |
| 1031 | a.groupId === bw.groupId && | |
| 1032 | a.expanded === bw.expanded && | |
| 1033 | Equal.equals(a.entry, bw.entry) && | |
| 1034 | Equal.equals(a.groupedEntries, bw.groupedEntries) | |
| 1035 | ); | |
| 1036 | } | |
| 688 | 1037 | |
| 689 | 1038 | case "work-toggle": { |
| 690 | 1039 | const bw = b as typeof a; |
| ... | ... | @@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 693 | 1042 | a.groupId === bw.groupId && |
| 694 | 1043 | a.hiddenCount === bw.hiddenCount && |
| 695 | 1044 | a.expanded === bw.expanded && |
| 696 | a.onlyToolEntries === bw.onlyToolEntries | |
| 1045 | a.onlyToolEntries === bw.onlyToolEntries && | |
| 1046 | a.summary === bw.summary && | |
| 1047 | a.summaryKind === bw.summaryKind && | |
| 1048 | a.hasFailure === bw.hasFailure | |
| 697 | 1049 | ); |
| 698 | 1050 | } |
| 699 | 1051 |
apps/web/src/components/chat/MessagesTimeline.test.tsx+44-2| ... | ... | @@ -554,7 +554,49 @@ describe("MessagesTimeline", () => { |
| 554 | 554 | expect(markup).toContain("Work Log"); |
| 555 | 555 | }); |
| 556 | 556 | |
| 557 | it("formats changed file paths from the workspace root", () => { | |
| 557 | it("makes the whole live tool row expandable without adding a chevron", () => { | |
| 558 | const turnId = TurnId.make("turn-live-tools"); | |
| 559 | const markup = renderToStaticMarkup( | |
| 560 | <MessagesTimeline | |
| 561 | {...buildProps()} | |
| 562 | isWorking | |
| 563 | activeTurnInProgress | |
| 564 | activeTurnStartedAt="2026-03-17T19:12:28.000Z" | |
| 565 | latestTurn={{ | |
| 566 | turnId, | |
| 567 | state: "running", | |
| 568 | startedAt: "2026-03-17T19:12:28.000Z", | |
| 569 | completedAt: null, | |
| 570 | }} | |
| 571 | runningTurnId={turnId} | |
| 572 | timelineEntries={[ | |
| 573 | { | |
| 574 | id: "entry-live-tool", | |
| 575 | kind: "work", | |
| 576 | createdAt: "2026-03-17T19:12:29.000Z", | |
| 577 | entry: { | |
| 578 | id: "work-live-tool", | |
| 579 | createdAt: "2026-03-17T19:12:29.000Z", | |
| 580 | turnId, | |
| 581 | toolCallId: "call-live-tool", | |
| 582 | label: "Running tests", | |
| 583 | command: "env -C /tmp sudo -iu postgres psql", | |
| 584 | tone: "tool", | |
| 585 | toolLifecycleStatus: "inProgress", | |
| 586 | }, | |
| 587 | }, | |
| 588 | ]} | |
| 589 | />, | |
| 590 | ); | |
| 591 | ||
| 592 | expect(markup).not.toContain('aria-label="Expand current tool calls"'); | |
| 593 | expect(markup).toContain('aria-expanded="false"'); | |
| 594 | expect(markup).toContain("Running psql"); | |
| 595 | expect(markup).not.toContain("lucide-chevron-right"); | |
| 596 | expect(markup).not.toContain("hover:bg-accent/20"); | |
| 597 | }); | |
| 598 | ||
| 599 | it("summarizes completed changed-file activity", () => { | |
| 558 | 600 | const markup = renderToStaticMarkup( |
| 559 | 601 | <MessagesTimeline |
| 560 | 602 | {...buildProps()} |
| ... | ... | @@ -576,7 +618,7 @@ describe("MessagesTimeline", () => { |
| 576 | 618 | />, |
| 577 | 619 | ); |
| 578 | 620 | |
| 579 | expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); | |
| 621 | expect(markup).toContain("Changed 1 file"); | |
| 580 | 622 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); |
| 581 | 623 | }); |
| 582 | 624 |
apps/web/src/components/chat/MessagesTimeline.tsx+336-155| ... | ... | @@ -35,7 +35,6 @@ import { |
| 35 | 35 | deriveTimelineEntries, |
| 36 | 36 | workEntryIndicatesToolFailure, |
| 37 | 37 | workEntryIndicatesToolNeutralStatus, |
| 38 | workEntryIndicatesToolSuccess, | |
| 39 | 38 | workLogEntryIsToolLike, |
| 40 | 39 | } from "../../session-logic"; |
| 41 | 40 | import { type TurnDiffSummary } from "../../types"; |
| ... | ... | @@ -57,7 +56,6 @@ import { |
| 57 | 56 | MessageCircleIcon, |
| 58 | 57 | MousePointerClickIcon, |
| 59 | 58 | PaintbrushIcon, |
| 60 | MinusIcon, | |
| 61 | 59 | SquarePenIcon, |
| 62 | 60 | TerminalIcon, |
| 63 | 61 | Undo2Icon, |
| ... | ... | @@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped |
| 920 | 918 | type TimelineRow = MessagesTimelineRow; |
| 921 | 919 | |
| 922 | 920 | const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { |
| 921 | const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; | |
| 922 | const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; | |
| 923 | const isExpandedToolGroupHeader = | |
| 924 | (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || | |
| 925 | (row.kind === "work-live" && row.expanded); | |
| 926 | ||
| 923 | 927 | return ( |
| 924 | 928 | <div |
| 925 | 929 | className={cn( |
| 926 | 930 | // Commentary (non-terminal assistant) rows carry no metadata row, so |
| 927 | 931 | // they sit closer to the work that follows them. |
| 928 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || | |
| 929 | row.kind === "work" || | |
| 930 | row.kind === "work-toggle" || | |
| 931 | row.kind === "turn-plan" | |
| 932 | ? "pb-2" | |
| 933 | : "pb-4", | |
| 932 | isExpandedToolGroupEntry | |
| 933 | ? isLastExpandedToolGroupEntry | |
| 934 | ? "pb-1" | |
| 935 | : "pb-0" | |
| 936 | : isExpandedToolGroupHeader | |
| 937 | ? "pb-0" | |
| 938 | : row.kind === "turn-fold" | |
| 939 | ? "pb-1.5" | |
| 940 | : (row.kind === "message" && | |
| 941 | row.message.role === "assistant" && | |
| 942 | !row.showAssistantMeta) || | |
| 943 | row.kind === "work" || | |
| 944 | row.kind === "work-live" || | |
| 945 | row.kind === "work-toggle" || | |
| 946 | row.kind === "turn-plan" | |
| 947 | ? "pb-2" | |
| 948 | : "pb-4", | |
| 934 | 949 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| 935 | 950 | )} |
| 936 | 951 | data-timeline-row-id={row.id} |
| ... | ... | @@ -938,7 +953,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 938 | 953 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 939 | 954 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 940 | 955 | > |
| 941 | {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} | |
| 956 | {row.kind === "work" ? ( | |
| 957 | <WorkGroupSection | |
| 958 | groupedEntries={row.groupedEntries} | |
| 959 | isExpandedToolGroupEntry={row.isExpandedToolGroupEntry} | |
| 960 | /> | |
| 961 | ) : null} | |
| 962 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} | |
| 942 | 963 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} |
| 943 | 964 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| 944 | 965 | {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} |
| ... | ... | @@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { |
| 1083 | 1104 | |
| 1084 | 1105 | function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) { |
| 1085 | 1106 | const ctx = use(TimelineRowCtx); |
| 1086 | const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; | |
| 1087 | 1107 | |
| 1088 | 1108 | return ( |
| 1089 | 1109 | <div className="border-b border-border/60 pb-2 pt-1"> |
| ... | ... | @@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- |
| 1092 | 1112 | aria-expanded={row.expanded} |
| 1093 | 1113 | data-scroll-anchor-ignore |
| 1094 | 1114 | onClick={() => ctx.onToggleTurnFold(row.turnId)} |
| 1095 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | |
| 1115 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | |
| 1096 | 1116 | > |
| 1097 | 1117 | <span>{row.label}</span> |
| 1098 | <Icon className="size-3.5" /> | |
| 1118 | <ChevronRightIcon | |
| 1119 | className={cn("size-3.5 transition-transform", row.expanded && "-rotate-90")} | |
| 1120 | /> | |
| 1099 | 1121 | </button> |
| 1100 | 1122 | </div> |
| 1101 | 1123 | ); |
| ... | ... | @@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ |
| 1278 | 1300 | }); |
| 1279 | 1301 | |
| 1280 | 1302 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1281 | const { workingStepLabel } = use(TimelineRowActivityCtx); | |
| 1282 | 1303 | return ( |
| 1283 | <div className="py-0.5 pl-1.5"> | |
| 1284 | <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums"> | |
| 1285 | <span className="inline-flex items-center gap-[3px]"> | |
| 1286 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> | |
| 1287 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> | |
| 1288 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" /> | |
| 1289 | </span> | |
| 1290 | <span className="shrink-0"> | |
| 1304 | <div> | |
| 1305 | <div className="border-b border-border/60 pb-2 pt-1"> | |
| 1306 | <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | |
| 1291 | 1307 | {row.createdAt ? ( |
| 1292 | 1308 | <> |
| 1293 | 1309 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| ... | ... | @@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1295 | 1311 | ) : ( |
| 1296 | 1312 | "Working..." |
| 1297 | 1313 | )} |
| 1298 | </span> | |
| 1299 | {workingStepLabel ? ( | |
| 1300 | <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span> | |
| 1301 | ) : null} | |
| 1314 | </div> | |
| 1302 | 1315 | </div> |
| 1316 | {row.showThinking ? ( | |
| 1317 | <div className="mt-2"> | |
| 1318 | <ThinkingActivityRow /> | |
| 1319 | </div> | |
| 1320 | ) : null} | |
| 1303 | 1321 | </div> |
| 1304 | 1322 | ); |
| 1305 | 1323 | } |
| ... | ... | @@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 1340 | 1358 | /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ |
| 1341 | 1359 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 1342 | 1360 | groupedEntries, |
| 1361 | isExpandedToolGroupEntry, | |
| 1343 | 1362 | }: { |
| 1344 | 1363 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 1364 | isExpandedToolGroupEntry: boolean; | |
| 1345 | 1365 | }) { |
| 1346 | 1366 | const { workspaceRoot } = use(TimelineRowCtx); |
| 1347 | 1367 | const nonEmptyEntries = useMemo( |
| ... | ... | @@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1358 | 1378 | if (nonEmptyEntries.length === 0) return null; |
| 1359 | 1379 | |
| 1360 | 1380 | return ( |
| 1361 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}> | |
| 1381 | <section | |
| 1382 | className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")} | |
| 1383 | aria-label={groupLabel} | |
| 1384 | > | |
| 1362 | 1385 | {!onlyToolEntries && ( |
| 1363 | 1386 | <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p> |
| 1364 | 1387 | )} |
| ... | ... | @@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1368 | 1391 | key={workEntry.id} |
| 1369 | 1392 | workEntry={workEntry} |
| 1370 | 1393 | workspaceRoot={workspaceRoot} |
| 1394 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | |
| 1371 | 1395 | /> |
| 1372 | 1396 | ))} |
| 1373 | 1397 | </div> |
| ... | ... | @@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1375 | 1399 | ); |
| 1376 | 1400 | }); |
| 1377 | 1401 | |
| 1402 | function 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 | ||
| 1417 | function ThinkingActivityRow() { | |
| 1418 | return <LiveActivityRow label="Thinking" />; | |
| 1419 | } | |
| 1420 | ||
| 1421 | function 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 | ||
| 1452 | function 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 | ||
| 1470 | function 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 | ||
| 1378 | 1490 | function WorkGroupToggleTimelineRow({ |
| 1379 | 1491 | row, |
| 1380 | 1492 | }: { |
| 1381 | 1493 | row: Extract<TimelineRow, { kind: "work-toggle" }>; |
| 1382 | 1494 | }) { |
| 1383 | 1495 | const ctx = use(TimelineRowCtx); |
| 1496 | if (row.onlyToolEntries && row.summary) { | |
| 1497 | return ( | |
| 1498 | <button | |
| 1499 | type="button" | |
| 1500 | className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | |
| 1501 | aria-expanded={row.expanded} | |
| 1502 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | |
| 1503 | > | |
| 1504 | <span | |
| 1505 | className={cn( | |
| 1506 | "flex size-6 shrink-0 items-center justify-center", | |
| 1507 | row.hasFailure ? "text-destructive" : "text-icon-muted", | |
| 1508 | )} | |
| 1509 | aria-label={row.hasFailure ? "Tool call failed" : undefined} | |
| 1510 | > | |
| 1511 | {row.hasFailure ? ( | |
| 1512 | <XIcon className="size-4 shrink-0 stroke-[1.8]" aria-hidden /> | |
| 1513 | ) : ( | |
| 1514 | <WorkEntryIconSvg | |
| 1515 | name={toolGroupSummaryIconName(row.summaryKind)} | |
| 1516 | className="size-4 shrink-0 stroke-[1.8] opacity-70" | |
| 1517 | /> | |
| 1518 | )} | |
| 1519 | </span> | |
| 1520 | <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span> | |
| 1521 | </button> | |
| 1522 | ); | |
| 1523 | } | |
| 1384 | 1524 | const labelNoun = row.onlyToolEntries |
| 1385 | 1525 | ? row.hiddenCount === 1 |
| 1386 | 1526 | ? "tool call" |
| ... | ... | @@ -2019,32 +2159,101 @@ function workEntryPreview( |
| 2019 | 2159 | : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; |
| 2020 | 2160 | } |
| 2021 | 2161 | |
| 2022 | function workEntryRawCommand( | |
| 2023 | workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">, | |
| 2024 | ): string | null { | |
| 2025 | const rawCommand = workEntry.rawCommand?.trim(); | |
| 2026 | if (!rawCommand || !workEntry.command) { | |
| 2027 | return null; | |
| 2162 | type CommandWrapper = "env" | "sudo"; | |
| 2163 | ||
| 2164 | const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = { | |
| 2165 | env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), | |
| 2166 | sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), | |
| 2167 | }; | |
| 2168 | ||
| 2169 | const 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 | ||
| 2174 | function commandProgramName(command: string): string | null { | |
| 2175 | const tokens = command.trim().split(/\s+/); | |
| 2176 | let index = 0; | |
| 2177 | let wrapper: CommandWrapper | null = null; | |
| 2178 | ||
| 2179 | while (index < tokens.length) { | |
| 2180 | const token = tokens[index]?.replace(/^["']|["']$/g, ""); | |
| 2181 | if (!token) return null; | |
| 2182 | if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { | |
| 2183 | index += 1; | |
| 2184 | continue; | |
| 2185 | } | |
| 2186 | if (token === "env" || token === "sudo") { | |
| 2187 | wrapper = token; | |
| 2188 | index += 1; | |
| 2189 | continue; | |
| 2190 | } | |
| 2191 | if (wrapper !== null && token === "--") { | |
| 2192 | wrapper = null; | |
| 2193 | index += 1; | |
| 2194 | continue; | |
| 2195 | } | |
| 2196 | if (wrapper !== null && token.startsWith("-")) { | |
| 2197 | if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { | |
| 2198 | if (tokens[index + 1] === undefined) return null; | |
| 2199 | index += 2; | |
| 2200 | continue; | |
| 2201 | } | |
| 2202 | if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { | |
| 2203 | index += 1; | |
| 2204 | continue; | |
| 2205 | } | |
| 2206 | if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { | |
| 2207 | let consumesNextToken = false; | |
| 2208 | for (const [optionIndex, option] of token.slice(1).split("").entries()) { | |
| 2209 | const shortOption = `-${option}`; | |
| 2210 | if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { | |
| 2211 | consumesNextToken = optionIndex === token.length - 2; | |
| 2212 | break; | |
| 2213 | } | |
| 2214 | if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; | |
| 2215 | } | |
| 2216 | if (consumesNextToken && tokens[index + 1] === undefined) return null; | |
| 2217 | index += consumesNextToken ? 2 : 1; | |
| 2218 | continue; | |
| 2219 | } | |
| 2220 | return null; | |
| 2221 | } | |
| 2222 | return token.split(/[\\/]/).at(-1) || null; | |
| 2028 | 2223 | } |
| 2029 | return rawCommand === workEntry.command.trim() ? null : rawCommand; | |
| 2224 | ||
| 2225 | return null; | |
| 2226 | } | |
| 2227 | ||
| 2228 | function liveWorkEntryLabel( | |
| 2229 | workEntry: TimelineWorkEntry, | |
| 2230 | workspaceRoot: string | undefined, | |
| 2231 | ): string { | |
| 2232 | const command = workEntry.command?.trim(); | |
| 2233 | if (command) { | |
| 2234 | const program = commandProgramName(command); | |
| 2235 | if (program) return `Running ${program}`; | |
| 2236 | return "Running command"; | |
| 2237 | } | |
| 2238 | ||
| 2239 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | |
| 2030 | 2240 | } |
| 2031 | 2241 | |
| 2032 | 2242 | function buildToolCallExpandedBody( |
| 2033 | 2243 | workEntry: TimelineWorkEntry, |
| 2034 | 2244 | workspaceRoot: string | undefined, |
| 2035 | 2245 | ): string | null { |
| 2246 | const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); | |
| 2036 | 2247 | const blocks: string[] = []; |
| 2248 | if (command) { | |
| 2249 | blocks.push(command); | |
| 2250 | } | |
| 2037 | 2251 | if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { |
| 2038 | 2252 | blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); |
| 2039 | 2253 | } |
| 2040 | const raw = workEntryRawCommand(workEntry); | |
| 2041 | if (raw?.trim()) { | |
| 2042 | blocks.push(raw.trim()); | |
| 2043 | } else if (workEntry.command?.trim()) { | |
| 2044 | blocks.push(workEntry.command.trim()); | |
| 2045 | } | |
| 2046 | if (workEntry.detail?.trim()) { | |
| 2047 | blocks.push(workEntry.detail.trim()); | |
| 2254 | const detail = workEntry.detail?.trim(); | |
| 2255 | if (detail && detail !== command) { | |
| 2256 | blocks.push(detail); | |
| 2048 | 2257 | } |
| 2049 | 2258 | const changedFiles = workEntry.changedFiles ?? []; |
| 2050 | 2259 | if (changedFiles.length > 0) { |
| ... | ... | @@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time |
| 2180 | 2389 | : "working" |
| 2181 | 2390 | : failed > 0 |
| 2182 | 2391 | ? `${failed} failed` |
| 2183 | : "✓ completed"; | |
| 2392 | : "Completed"; | |
| 2184 | 2393 | |
| 2185 | 2394 | return ( |
| 2186 | <button | |
| 2187 | type="button" | |
| 2188 | onClick={onOpenAgents} | |
| 2189 | className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50" | |
| 2190 | > | |
| 2191 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> | |
| 2192 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 2193 | <span className="min-w-0 truncate"> | |
| 2194 | <span className="font-medium">{lead}</span> | |
| 2195 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} | |
| 2196 | </span> | |
| 2197 | <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground"> | |
| 2198 | <span>{status}</span> | |
| 2199 | {totalTokens > 0 ? ( | |
| 2200 | <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span> | |
| 2201 | ) : null} | |
| 2202 | <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span> | |
| 2203 | </span> | |
| 2204 | </button> | |
| 2395 | <div className="-mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"> | |
| 2396 | <div className="flex items-center justify-between gap-2 rounded-xl px-1"> | |
| 2397 | <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left text-xs leading-4"> | |
| 2398 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> | |
| 2399 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 2400 | <span className="min-w-0 truncate font-medium text-foreground">{lead}</span> | |
| 2401 | {workflowName ? ( | |
| 2402 | <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground md:inline"> | |
| 2403 | {workflowName} | |
| 2404 | </span> | |
| 2405 | ) : null} | |
| 2406 | <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground sm:flex"> | |
| 2407 | <span>{status}</span> | |
| 2408 | {totalTokens > 0 ? ( | |
| 2409 | <span className="font-mono tabular-nums"> | |
| 2410 | Σ {formatSubagentTokenCount(totalTokens)} | |
| 2411 | </span> | |
| 2412 | ) : null} | |
| 2413 | </span> | |
| 2414 | </div> | |
| 2415 | <Button | |
| 2416 | type="button" | |
| 2417 | size="xs" | |
| 2418 | variant="outline" | |
| 2419 | aria-label={live ? "Open agents" : "View agents"} | |
| 2420 | onClick={onOpenAgents} | |
| 2421 | > | |
| 2422 | <EyeIcon className="size-3" /> | |
| 2423 | <span className="hidden sm:inline">{live ? "Open agents" : "View agents"}</span> | |
| 2424 | </Button> | |
| 2425 | </div> | |
| 2426 | </div> | |
| 2205 | 2427 | ); |
| 2206 | 2428 | }); |
| 2207 | 2429 | |
| 2208 | 2430 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2209 | 2431 | workEntry: TimelineWorkEntry; |
| 2210 | 2432 | workspaceRoot: string | undefined; |
| 2433 | isExpandedToolGroupEntry: boolean; | |
| 2211 | 2434 | }) { |
| 2212 | const { workEntry, workspaceRoot } = props; | |
| 2435 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | |
| 2213 | 2436 | // Before any hooks: spawn CTA rows render their own component. |
| 2214 | 2437 | if (workEntry.agentSpawn) { |
| 2215 | 2438 | return <AgentSpawnCtaRow workEntry={workEntry} />; |
| 2216 | 2439 | } |
| 2217 | return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />; | |
| 2440 | return ( | |
| 2441 | <PlainWorkEntryRow | |
| 2442 | workEntry={workEntry} | |
| 2443 | workspaceRoot={workspaceRoot} | |
| 2444 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | |
| 2445 | /> | |
| 2446 | ); | |
| 2218 | 2447 | }); |
| 2219 | 2448 | |
| 2220 | 2449 | const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2221 | 2450 | workEntry: TimelineWorkEntry; |
| 2222 | 2451 | workspaceRoot: string | undefined; |
| 2452 | isExpandedToolGroupEntry: boolean; | |
| 2223 | 2453 | }) { |
| 2224 | const { workEntry, workspaceRoot } = props; | |
| 2225 | const activity = use(TimelineRowActivityCtx); | |
| 2454 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | |
| 2226 | 2455 | const [expanded, setExpanded] = useState(false); |
| 2227 | 2456 | const iconConfig = workToneIcon(workEntry.tone); |
| 2228 | 2457 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; |
| 2229 | const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); | |
| 2230 | const heading = toolWorkEntryHeading(workEntry); | |
| 2231 | const rawPreview = workEntryPreview(workEntry, workspaceRoot); | |
| 2232 | const preview = | |
| 2233 | rawPreview && | |
| 2234 | normalizeCompactToolLabel(rawPreview).toLowerCase() === | |
| 2235 | normalizeCompactToolLabel(heading).toLowerCase() | |
| 2236 | ? null | |
| 2237 | : rawPreview; | |
| 2238 | const displayText = preview ? `${heading} - ${preview}` : heading; | |
| 2458 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); | |
| 2459 | const entryIconName = | |
| 2460 | showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); | |
| 2461 | const isCommandEntry = | |
| 2462 | workEntry.requestKind === "command" || | |
| 2463 | workEntry.itemType === "command_execution" || | |
| 2464 | Boolean(workEntry.command); | |
| 2465 | const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | |
| 2239 | 2466 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); |
| 2240 | 2467 | const canExpand = expandedBody !== null; |
| 2241 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); | |
| 2242 | 2468 | const showDestructiveRowStyle = |
| 2243 | 2469 | showFailedIndicator && |
| 2244 | 2470 | (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); |
| 2245 | 2471 | const iconWrapperClass = cn( |
| 2246 | "flex size-5 shrink-0 items-center justify-center", | |
| 2247 | showWarningIndicator | |
| 2472 | "flex size-6 shrink-0 items-center justify-center", | |
| 2473 | showWarningIndicator || showFailedIndicator | |
| 2248 | 2474 | ? "text-destructive" |
| 2249 | 2475 | : showDestructiveRowStyle |
| 2250 | 2476 | ? "text-destructive" |
| ... | ... | @@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2256 | 2482 | ? "font-medium text-warning" |
| 2257 | 2483 | : showDestructiveRowStyle |
| 2258 | 2484 | ? "font-medium text-destructive" |
| 2259 | : "font-medium text-foreground"; | |
| 2260 | const turnSettled = !activity.activeTurnInProgress; | |
| 2261 | const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); | |
| 2262 | const showSuccessIndicator = | |
| 2263 | workEntryIndicatesToolSuccess(workEntry) || | |
| 2264 | (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); | |
| 2485 | : workLogEntryIsToolLike(workEntry) | |
| 2486 | ? "text-secondary-label" | |
| 2487 | : "text-foreground/80"; | |
| 2488 | const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; | |
| 2265 | 2489 | const rowToggleProps = canExpand |
| 2266 | 2490 | ? { |
| 2267 | 2491 | role: "button" as const, |
| 2268 | 2492 | tabIndex: 0 as const, |
| 2269 | 2493 | "aria-label": displayText, |
| 2494 | "aria-expanded": expanded, | |
| 2270 | 2495 | onClick: () => setExpanded((v) => !v), |
| 2271 | 2496 | onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => { |
| 2272 | 2497 | if (e.key === "Enter" || e.key === " ") { |
| ... | ... | @@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2280 | 2505 | return ( |
| 2281 | 2506 | <div |
| 2282 | 2507 | className={cn( |
| 2283 | "flex flex-col rounded-md px-0.5 py-0.5 transition-colors", | |
| 2508 | "flex flex-col rounded-md px-0.5 transition-colors", | |
| 2509 | isExpandedToolGroupEntry ? "py-0" : "py-0.5", | |
| 2284 | 2510 | canExpand && |
| 2285 | "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 2511 | "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 2286 | 2512 | )} |
| 2287 | 2513 | {...rowToggleProps} |
| 2288 | 2514 | > |
| 2289 | 2515 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> |
| 2290 | <span className={iconWrapperClass}> | |
| 2291 | <WorkEntryIconSvg | |
| 2292 | name={entryIconName} | |
| 2293 | className="block size-3.5 shrink-0 stroke-[1.8] opacity-80" | |
| 2294 | /> | |
| 2295 | </span> | |
| 2516 | {showEntryIcon ? ( | |
| 2517 | <span | |
| 2518 | className={iconWrapperClass} | |
| 2519 | aria-label={showFailedIndicator ? "Tool call failed" : undefined} | |
| 2520 | > | |
| 2521 | <WorkEntryIconSvg | |
| 2522 | name={entryIconName} | |
| 2523 | className="block size-4 shrink-0 stroke-[1.8] opacity-70" | |
| 2524 | /> | |
| 2525 | </span> | |
| 2526 | ) : null} | |
| 2296 | 2527 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 2297 | 2528 | <div className="min-w-0 flex-1 overflow-hidden"> |
| 2298 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> | |
| 2299 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> | |
| 2300 | {preview && ( | |
| 2301 | <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span> | |
| 2529 | <p | |
| 2530 | className={cn( | |
| 2531 | "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed", | |
| 2532 | isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm", | |
| 2302 | 2533 | )} |
| 2303 | </p> | |
| 2304 | </div> | |
| 2305 | <div className="flex shrink-0 items-center gap-px text-icon-muted"> | |
| 2306 | <span | |
| 2307 | className="flex size-4 shrink-0 items-center justify-center" | |
| 2308 | aria-hidden={!canExpand} | |
| 2309 | 2534 | > |
| 2310 | {canExpand ? ( | |
| 2311 | <ChevronDownIcon | |
| 2312 | className={cn( | |
| 2313 | "size-3 shrink-0 opacity-70 transition-transform duration-200", | |
| 2314 | expanded && "rotate-180", | |
| 2315 | )} | |
| 2316 | aria-hidden | |
| 2317 | /> | |
| 2318 | ) : null} | |
| 2319 | </span> | |
| 2320 | <span className="flex size-4 shrink-0 items-center justify-center"> | |
| 2321 | {showFailedIndicator ? ( | |
| 2322 | <Tooltip> | |
| 2323 | <TooltipTrigger | |
| 2324 | render={ | |
| 2325 | <span | |
| 2326 | className="flex size-4 items-center justify-center" | |
| 2327 | aria-label="Tool call failed" | |
| 2328 | /> | |
| 2329 | } | |
| 2330 | > | |
| 2331 | <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden /> | |
| 2332 | </TooltipTrigger> | |
| 2333 | <TooltipPopup>Failed</TooltipPopup> | |
| 2334 | </Tooltip> | |
| 2335 | ) : showSuccessIndicator ? ( | |
| 2336 | <Tooltip> | |
| 2337 | <TooltipTrigger | |
| 2338 | render={<span className="flex size-4 items-center justify-center" />} | |
| 2339 | > | |
| 2340 | <span className="inline-flex size-4 items-center justify-center"> | |
| 2341 | <CheckIcon | |
| 2342 | className="block size-3 shrink-0 stroke-current" | |
| 2343 | stroke="currentColor" | |
| 2344 | aria-hidden | |
| 2345 | /> | |
| 2346 | </span> | |
| 2347 | </TooltipTrigger> | |
| 2348 | <TooltipPopup>Completed</TooltipPopup> | |
| 2349 | </Tooltip> | |
| 2350 | ) : showNeutralIndicator ? ( | |
| 2351 | <Tooltip> | |
| 2352 | <TooltipTrigger | |
| 2353 | render={<span className="flex size-4 items-center justify-center" />} | |
| 2354 | > | |
| 2355 | <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden /> | |
| 2356 | </TooltipTrigger> | |
| 2357 | <TooltipPopup>Empty</TooltipPopup> | |
| 2358 | </Tooltip> | |
| 2359 | ) : null} | |
| 2360 | </span> | |
| 2535 | <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span> | |
| 2536 | </p> | |
| 2361 | 2537 | </div> |
| 2362 | 2538 | </div> |
| 2363 | 2539 | </div> |
| 2364 | 2540 | {expanded && canExpand && expandedBody ? ( |
| 2365 | 2541 | <div |
| 2366 | className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5" | |
| 2542 | className="mt-1 ms-[1.875rem] cursor-default pt-0.5" | |
| 2367 | 2543 | onClick={stopRowToggle} |
| 2368 | 2544 | onPointerDown={stopRowToggle} |
| 2369 | 2545 | > |
| 2370 | <pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed select-text"> | |
| 2546 | <pre | |
| 2547 | className={cn( | |
| 2548 | "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed", | |
| 2549 | isCommandEntry ? "select-all" : "select-text", | |
| 2550 | )} | |
| 2551 | > | |
| 2371 | 2552 | {expandedBody} |
| 2372 | 2553 | </pre> |
| 2373 | 2554 | </div> |
apps/web/src/components/chat/PanelLayoutControls.tsx+13-5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; |
| 2 | 2 | import { memo } from "react"; |
| 3 | 3 | |
| 4 | import { cn } from "../../lib/utils"; | |
| 4 | 5 | import { Toggle } from "../ui/toggle"; |
| 5 | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 6 | 7 | |
| ... | ... | @@ -12,6 +13,7 @@ interface PanelLayoutControlsProps { |
| 12 | 13 | rightPanelAvailable: boolean; |
| 13 | 14 | rightPanelOpen: boolean; |
| 14 | 15 | rightPanelShortcutLabel: string | null; |
| 16 | rightPanelUnavailableLabel?: string; | |
| 15 | 17 | /** Running + waiting subagents in this thread; badges the right panel toggle. */ |
| 16 | 18 | liveAgentCount: number; |
| 17 | 19 | onToggleTerminal: () => void; |
| ... | ... | @@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 26 | 28 | rightPanelAvailable, |
| 27 | 29 | rightPanelOpen, |
| 28 | 30 | rightPanelShortcutLabel, |
| 31 | rightPanelUnavailableLabel = "Right panel is unavailable", | |
| 29 | 32 | liveAgentCount, |
| 30 | 33 | onToggleTerminal, |
| 31 | 34 | onToggleRightPanel, |
| ... | ... | @@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 40 | 43 | <TooltipTrigger |
| 41 | 44 | render={ |
| 42 | 45 | <Toggle |
| 43 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 46 | className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]" | |
| 44 | 47 | pressed={terminalOpen} |
| 45 | 48 | onPressedChange={onToggleTerminal} |
| 46 | 49 | aria-label="Toggle terminal drawer" |
| ... | ... | @@ -63,7 +66,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 63 | 66 | <TooltipTrigger |
| 64 | 67 | render={ |
| 65 | 68 | <Toggle |
| 66 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 69 | className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag] disabled:opacity-100" | |
| 67 | 70 | pressed={rightPanelOpen} |
| 68 | 71 | onPressedChange={onToggleRightPanel} |
| 69 | 72 | aria-label={ |
| ... | ... | @@ -75,7 +78,12 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 75 | 78 | size="sm" |
| 76 | 79 | disabled={!rightPanelAvailable} |
| 77 | 80 | > |
| 78 | <PanelRightIcon className="size-4" /> | |
| 81 | <PanelRightIcon | |
| 82 | className={cn( | |
| 83 | "size-4", | |
| 84 | !rightPanelAvailable && "text-muted-foreground opacity-100", | |
| 85 | )} | |
| 86 | /> | |
| 79 | 87 | {liveAgentCount > 0 ? ( |
| 80 | 88 | <span |
| 81 | 89 | aria-hidden |
| ... | ... | @@ -94,7 +102,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 94 | 102 | ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` |
| 95 | 103 | : "" |
| 96 | 104 | }` |
| 97 | : "Right panel is unavailable"} | |
| 105 | : rightPanelUnavailableLabel} | |
| 98 | 106 | </TooltipPopup> |
| 99 | 107 | </Tooltip> |
| 100 | 108 | </div> |
| ... | ... | @@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl |
| 114 | 122 | <TooltipTrigger |
| 115 | 123 | render={ |
| 116 | 124 | <Toggle |
| 117 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 125 | className="shrink-0 text-foreground [--control-icon-color:currentColor] [-webkit-app-region:no-drag]" | |
| 118 | 126 | pressed={maximized} |
| 119 | 127 | onPressedChange={onToggle} |
| 120 | 128 | aria-label={label} |
apps/web/src/components/composerInlineChip.ts+7-7| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | // composer honors the prompt font-size preference). The chat variant pins the |
| 3 | 3 | // original 12px, where every em value resolves to the same pixels as before. |
| 4 | 4 | const INLINE_CHIP_CLASS_NAME = |
| 5 | "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle"; | |
| 5 | "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] font-medium leading-none text-foreground align-middle"; | |
| 6 | 6 | |
| 7 | 7 | export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; |
| 8 | 8 | |
| ... | ... | @@ -11,18 +11,18 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[ |
| 11 | 11 | export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = |
| 12 | 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-['']"; |
| 13 | 13 | |
| 14 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; | |
| 14 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = | |
| 15 | "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block"; | |
| 15 | 16 | |
| 16 | 17 | export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; |
| 17 | 18 | |
| 18 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; | |
| 19 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = | |
| 20 | "block self-center truncate leading-none select-none"; | |
| 19 | 21 | |
| 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. | |
| 22 | export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; | |
| 22 | export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; | |
| 23 | 23 | |
| 24 | 24 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = |
| 25 | "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; | |
| 25 | "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300"; | |
| 26 | 26 | |
| 27 | 27 | export 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>`; |
| 28 | 28 |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+285-332| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | GitPullRequestDraftIcon, |
| 26 | 26 | GitPullRequestIcon, |
| 27 | 27 | HammerIcon, |
| 28 | LayersIcon, | |
| 28 | 29 | MessageCircleQuestionIcon, |
| 29 | 30 | MessageSquareIcon, |
| 30 | 31 | LinkIcon, |
| ... | ... | @@ -50,6 +51,7 @@ import { |
| 50 | 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 51 | 52 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 52 | 53 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 54 | import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; | |
| 53 | 55 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 54 | 56 | import { cn } from "~/lib/utils"; |
| 55 | 57 | import { readLocalApi } from "~/localApi"; |
| ... | ... | @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; |
| 60 | 62 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 61 | 63 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 62 | 64 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 65 | import { vcsEnvironment } from "~/state/vcs"; | |
| 63 | 66 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 64 | 67 | |
| 65 | 68 | import { |
| ... | ... | @@ -74,6 +77,7 @@ import { |
| 74 | 77 | import { Badge } from "../ui/badge"; |
| 75 | 78 | import { Button } from "../ui/button"; |
| 76 | 79 | import { Input } from "../ui/input"; |
| 80 | import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; | |
| 77 | 81 | import { |
| 78 | 82 | Menu, |
| 79 | 83 | MenuItem, |
| ... | ... | @@ -116,6 +120,7 @@ import { |
| 116 | 120 | } from "./pullRequestProjectAssignment.logic"; |
| 117 | 121 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 118 | 122 | import { |
| 123 | PullRequestActorAvatar, | |
| 119 | 124 | PullRequestActorLabel, |
| 120 | 125 | PullRequestDiffStat, |
| 121 | 126 | PullRequestMetaLine, |
| ... | ... | @@ -349,7 +354,6 @@ export function PullRequestDetailPanel({ |
| 349 | 354 | onClose, |
| 350 | 355 | onStateChange, |
| 351 | 356 | context = "page", |
| 352 | chromeVariant = "full", | |
| 353 | 357 | composerDraftTarget, |
| 354 | 358 | }: { |
| 355 | 359 | environmentId: EnvironmentId; |
| ... | ... | @@ -381,12 +385,6 @@ export function PullRequestDetailPanel({ |
| 381 | 385 | * again is at best a no-op and at worst git refusing a branch two checkouts. |
| 382 | 386 | */ |
| 383 | 387 | context?: "page" | "thread"; |
| 384 | /** | |
| 385 | * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` | |
| 386 | * folds the whole of it into the top row once the active tab scrolls, and unfolds at the | |
| 387 | * top — the chrome spends its height on what is being read. | |
| 388 | */ | |
| 389 | chromeVariant?: "full" | "collapse"; | |
| 390 | 388 | /** |
| 391 | 389 | * The open thread's composer. Beside the thread whose own pull request this is, hand-offs |
| 392 | 390 | * land here instead of opening a new thread — the branch is already under the reader's feet. |
| ... | ... | @@ -423,26 +421,13 @@ export function PullRequestDetailPanel({ |
| 423 | 421 | ); |
| 424 | 422 | }, [tab]); |
| 425 | 423 | const [chromeCondensed, setChromeCondensed] = useState(false); |
| 426 | // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll | |
| 427 | // events, so the capture handler always writes the active tab's entry — and a tab switch | |
| 428 | // reads the destination's memory instead of inheriting the tab being left. A tab too short | |
| 429 | // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome | |
| 430 | // it has no scrollbar to reopen. | |
| 431 | 424 | const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); |
| 432 | 425 | useEffect(() => { |
| 433 | 426 | setChromeCondensed(chromeStateByTab.current[tab] ?? false); |
| 434 | 427 | }, [tab]); |
| 435 | const condensed = chromeVariant === "collapse" && chromeCondensed; | |
| 436 | // Collapsing removes the fold's height from the chrome, which would otherwise hand that | |
| 437 | // height to the scrollport and leap the content up by it mid-scroll. The cure is exact | |
| 438 | // compensation: collapse only once the reader has scrolled at least the fold's height, | |
| 439 | // then give that height back to `scrollTop` before the next paint — the content under | |
| 440 | // their eyes does not move, and the collapse itself is the only thing that changes. | |
| 428 | const condensed = chromeCondensed; | |
| 441 | 429 | const scrollerRef = useRef<HTMLElement | null>(null); |
| 442 | 430 | const foldRef = useRef<HTMLDivElement | null>(null); |
| 443 | // The condensed chrome's second row opens as the fold closes, so the height the scrollport | |
| 444 | // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` | |
| 445 | // through a zero track reads its natural height in either state. | |
| 446 | 431 | const condensedRowRef = useRef<HTMLDivElement | null>(null); |
| 447 | 432 | const compensationRef = useRef<number | null>(null); |
| 448 | 433 | useLayoutEffect(() => { |
| ... | ... | @@ -463,7 +448,6 @@ export function PullRequestDetailPanel({ |
| 463 | 448 | target: "branch name", |
| 464 | 449 | timeout: 1600, |
| 465 | 450 | }); |
| 466 | ||
| 467 | 451 | // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be |
| 468 | 452 | // clicked, so a reader who does click it lands on a chunk already in the module cache. |
| 469 | 453 | useEffect(() => { |
| ... | ... | @@ -502,6 +486,30 @@ export function PullRequestDetailPanel({ |
| 502 | 486 | }, |
| 503 | 487 | [activity, coreDetail], |
| 504 | 488 | ); |
| 489 | const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); | |
| 490 | const baseBranchRefQuery = useEnvironmentQuery( | |
| 491 | detail === null | |
| 492 | ? null | |
| 493 | : vcsEnvironment.listRefs({ | |
| 494 | environmentId, | |
| 495 | input: { | |
| 496 | cwd: detail.workspaceRoot, | |
| 497 | query: detail.baseBranch, | |
| 498 | includeMatchingRemoteRefs: true, | |
| 499 | limit: 20, | |
| 500 | }, | |
| 501 | }), | |
| 502 | ); | |
| 503 | const matchingBaseBranchRefs = | |
| 504 | detail === null | |
| 505 | ? [] | |
| 506 | : (baseBranchRefQuery.data?.refs.filter( | |
| 507 | (refName) => | |
| 508 | refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), | |
| 509 | ) ?? []); | |
| 510 | const isStackedPullRequest = | |
| 511 | matchingBaseBranchRefs.length > 0 && | |
| 512 | !matchingBaseBranchRefs.some((refName) => refName.isDefault); | |
| 505 | 513 | const activityPending = activityQuery.isPending && activity === null; |
| 506 | 514 | const activityError = activity === null ? activityQuery.error : null; |
| 507 | 515 | const refreshDetail = useCallback(() => { |
| ... | ... | @@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({ |
| 1019 | 1027 | const can = (action: PullRequestAction) => |
| 1020 | 1028 | detail?.capabilities.actions.includes(action) === true && |
| 1021 | 1029 | detail.viewerPermissions.actions.includes(action); |
| 1022 | // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes | |
| 1023 | // to the thing that would help instead of a Merge button that only ever says no. | |
| 1030 | // One live action holds the slot. Conflicts take priority because every other completion action | |
| 1031 | // depends on resolving them first, even for a reader who cannot merge on the host themselves. | |
| 1024 | 1032 | const primaryAction = |
| 1025 | 1033 | detail === null || detail.state !== "open" |
| 1026 | 1034 | ? null |
| 1027 | : detail.isDraft && can("ready") | |
| 1028 | ? "ready" | |
| 1029 | : !can("merge") | |
| 1030 | ? null | |
| 1031 | : conflicting | |
| 1032 | ? "resolve" | |
| 1035 | : conflicting | |
| 1036 | ? "resolve" | |
| 1037 | : detail.isDraft && can("ready") | |
| 1038 | ? "ready" | |
| 1039 | : !can("merge") | |
| 1040 | ? null | |
| 1033 | 1041 | : allowedMergeMethods.length > 0 |
| 1034 | 1042 | ? "merge" |
| 1035 | 1043 | : null; |
| 1036 | 1044 | // The pull request number carries this state in the overview and the right-panel tab mirrors |
| 1037 | // it. Conflicts keep their own row below: an open pull request remains green there. | |
| 1045 | // it. The conflict action is separate from this state: an open pull request remains green. | |
| 1038 | 1046 | const statePresentation = detail |
| 1039 | 1047 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) |
| 1040 | 1048 | : null; |
| 1041 | 1049 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; |
| 1042 | 1050 | const checksState = detail ? pullRequestChecksState(detail.checks) : null; |
| 1043 | 1051 | |
| 1052 | if (detailQuery.isPending && !detail) { | |
| 1053 | return <PullRequestDetailGhost />; | |
| 1054 | } | |
| 1055 | ||
| 1044 | 1056 | return ( |
| 1045 | 1057 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> |
| 1046 | {/* The top row's geometry never changes: both of its states occupy the same stacked | |
| 1047 | cell and crossfade, so the actions on the right have one home whatever the chrome | |
| 1048 | is doing below. The fold and this fade share one 200ms clock. */} | |
| 1049 | 1058 | <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> |
| 1050 | {/* The fixed height lives on the two top-row cells — not the grid, whose later rows | |
| 1051 | are the fold — so the actions have one immovable home in both states. */} | |
| 1052 | <div className="ml-4 grid h-11 min-w-0 items-center"> | |
| 1059 | <div className="ml-4 grid h-7 min-w-0 items-center"> | |
| 1053 | 1060 | <div |
| 1054 | 1061 | aria-hidden={condensed} |
| 1055 | 1062 | inert={condensed} |
| 1056 | 1063 | className={cn( |
| 1057 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none", | |
| 1058 | // Sequenced, not simultaneous: the leaving layer clears quickly before the | |
| 1059 | // arriving one lands, so no frame shows both texts superimposed at half opacity. | |
| 1064 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none", | |
| 1060 | 1065 | condensed |
| 1061 | ? "pointer-events-none opacity-0 duration-100" | |
| 1062 | : "opacity-100 delay-75 duration-150", | |
| 1066 | ? "pointer-events-none -translate-y-1 opacity-0 duration-100" | |
| 1067 | : "translate-y-0 opacity-100 delay-50 duration-150", | |
| 1063 | 1068 | )} |
| 1064 | 1069 | > |
| 1065 | 1070 | {detail && statePresentation ? ( |
| 1066 | 1071 | <> |
| 1067 | <span className="min-w-0 truncate" title={detail.repository}> | |
| 1068 | {detail.repository} | |
| 1069 | </span> | |
| 1072 | {repositoryUrl ? ( | |
| 1073 | <button | |
| 1074 | type="button" | |
| 1075 | onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)} | |
| 1076 | className="min-w-0 truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" | |
| 1077 | title={`Open ${detail.repository} repository`} | |
| 1078 | > | |
| 1079 | {detail.repository} | |
| 1080 | </button> | |
| 1081 | ) : ( | |
| 1082 | <span className="min-w-0 truncate font-medium text-muted-foreground"> | |
| 1083 | {detail.repository} | |
| 1084 | </span> | |
| 1085 | )} | |
| 1070 | 1086 | <button |
| 1071 | 1087 | type="button" |
| 1072 | 1088 | onClick={() => void readLocalApi()?.shell.openExternal(detail.url)} |
| ... | ... | @@ -1087,10 +1103,10 @@ export function PullRequestDetailPanel({ |
| 1087 | 1103 | aria-hidden={!condensed} |
| 1088 | 1104 | inert={!condensed} |
| 1089 | 1105 | className={cn( |
| 1090 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none", | |
| 1106 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none", | |
| 1091 | 1107 | condensed |
| 1092 | ? "opacity-100 delay-75 duration-150" | |
| 1093 | : "pointer-events-none opacity-0 duration-100", | |
| 1108 | ? "translate-y-0 opacity-100 delay-50 duration-150" | |
| 1109 | : "pointer-events-none translate-y-1 opacity-0 duration-100", | |
| 1094 | 1110 | )} |
| 1095 | 1111 | > |
| 1096 | 1112 | {detail && statePresentation ? ( |
| ... | ... | @@ -1109,30 +1125,14 @@ export function PullRequestDetailPanel({ |
| 1109 | 1125 | > |
| 1110 | 1126 | #{detail.number} |
| 1111 | 1127 | </button> |
| 1112 | <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}> | |
| 1128 | <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}> | |
| 1113 | 1129 | {detail.title} |
| 1114 | </span> | |
| 1115 | {conflicting ? ( | |
| 1116 | <Badge | |
| 1117 | variant="error" | |
| 1118 | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive" | |
| 1119 | > | |
| 1120 | <TriangleAlertIcon className="size-3" /> | |
| 1121 | Conflicts | |
| 1122 | </Badge> | |
| 1123 | ) : checksSummary ? ( | |
| 1124 | <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground"> | |
| 1125 | {detail && checksState !== null ? ( | |
| 1126 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | |
| 1127 | ) : null} | |
| 1128 | {checksSummary} | |
| 1129 | </span> | |
| 1130 | ) : null} | |
| 1130 | </h1> | |
| 1131 | 1131 | </> |
| 1132 | 1132 | ) : null} |
| 1133 | 1133 | </div> |
| 1134 | 1134 | </div> |
| 1135 | <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1"> | |
| 1135 | <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1"> | |
| 1136 | 1136 | {detail ? ( |
| 1137 | 1137 | <> |
| 1138 | 1138 | <Menu> |
| ... | ... | @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ |
| 1140 | 1140 | render={ |
| 1141 | 1141 | <Button |
| 1142 | 1142 | aria-label="More pull request actions" |
| 1143 | className="size-6" | |
| 1143 | className="order-last size-6" | |
| 1144 | 1144 | size="icon-xs" |
| 1145 | 1145 | variant="ghost-muted" |
| 1146 | 1146 | /> |
| ... | ... | @@ -1312,16 +1312,15 @@ export function PullRequestDetailPanel({ |
| 1312 | 1312 | <MenuTrigger |
| 1313 | 1313 | disabled={handoff !== null} |
| 1314 | 1314 | render={ |
| 1315 | <Button size="xs" variant="outline"> | |
| 1316 | {handoff?.startsWith("checkout") ? ( | |
| 1317 | "Checking out..." | |
| 1318 | ) : ( | |
| 1319 | <> | |
| 1320 | <GitBranchIcon className="size-3" /> | |
| 1321 | Check out | |
| 1322 | <ChevronDownIcon className="size-3 text-muted-foreground" /> | |
| 1323 | </> | |
| 1324 | )} | |
| 1315 | <Button size="xs" variant="outline" className="gap-0 p-0"> | |
| 1316 | <span className="inline-flex h-full items-center gap-1.5 ps-[8.5px] pe-2"> | |
| 1317 | <GitBranchIcon aria-hidden className="size-3.5" /> | |
| 1318 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} | |
| 1319 | </span> | |
| 1320 | <span aria-hidden className="h-4 w-px shrink-0 bg-input" /> | |
| 1321 | <span className="inline-flex h-full w-6 shrink-0 items-center justify-center"> | |
| 1322 | <ChevronDownIcon aria-hidden className="size-4 text-muted-foreground" /> | |
| 1323 | </span> | |
| 1325 | 1324 | </Button> |
| 1326 | 1325 | } |
| 1327 | 1326 | /> |
| ... | ... | @@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({ |
| 1367 | 1366 | Auto-merge |
| 1368 | 1367 | </Badge> |
| 1369 | 1368 | ) : null} |
| 1370 | {primaryAction === "ready" ? ( | |
| 1369 | {primaryAction === "resolve" ? ( | |
| 1370 | <Badge | |
| 1371 | variant="error" | |
| 1372 | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive hover:bg-destructive/14" | |
| 1373 | render={ | |
| 1374 | <button | |
| 1375 | type="button" | |
| 1376 | disabled={handoff !== null} | |
| 1377 | onClick={startResolveConflicts} | |
| 1378 | /> | |
| 1379 | } | |
| 1380 | > | |
| 1381 | <TriangleAlertIcon className="size-3" /> | |
| 1382 | {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} | |
| 1383 | </Badge> | |
| 1384 | ) : primaryAction === "ready" ? ( | |
| 1371 | 1385 | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> |
| 1372 | 1386 | Ready for review |
| 1373 | 1387 | </Button> |
| ... | ... | @@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({ |
| 1394 | 1408 | ) : null} |
| 1395 | 1409 | </div> |
| 1396 | 1410 | |
| 1397 | {/* The condensed chrome's second row: the tabs that the closing fold takes with it, | |
| 1398 | and compact copies of the branch pair and diff stat so they stay in sight while | |
| 1399 | the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} | |
| 1400 | <div | |
| 1401 | className={cn( | |
| 1402 | "col-span-2 grid", | |
| 1403 | // Inverse of the fold's track: closing eases while the fold above eases open, | |
| 1404 | // so the chrome trades rows in one motion instead of two jumps. | |
| 1405 | condensed | |
| 1406 | ? "grid-rows-[1fr]" | |
| 1407 | : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", | |
| 1408 | )} | |
| 1409 | > | |
| 1411 | <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}> | |
| 1410 | 1412 | <div |
| 1411 | 1413 | ref={condensedRowRef} |
| 1412 | 1414 | className={cn( |
| 1413 | "min-h-0 overflow-hidden", | |
| 1415 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", | |
| 1414 | 1416 | condensed |
| 1415 | ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none" | |
| 1416 | : "opacity-0", | |
| 1417 | ? "translate-y-0 opacity-100 delay-50" | |
| 1418 | : "translate-y-1 opacity-0 duration-100", | |
| 1417 | 1419 | )} |
| 1418 | 1420 | inert={!condensed} |
| 1419 | 1421 | > |
| 1420 | 1422 | {detail ? ( |
| 1421 | <div className="flex min-w-0 items-center gap-1 px-4 pb-2"> | |
| 1422 | <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5"> | |
| 1423 | {visibleTabs.map((item) => ( | |
| 1424 | <button | |
| 1425 | key={item.value} | |
| 1426 | type="button" | |
| 1427 | tabIndex={condensed ? 0 : -1} | |
| 1428 | aria-pressed={tab === item.value} | |
| 1429 | onClick={() => setTab(item.value)} | |
| 1430 | className={cn( | |
| 1431 | "rounded-md px-2 py-1 text-[11px] transition-colors", | |
| 1432 | tab === item.value | |
| 1433 | ? "bg-accent text-foreground" | |
| 1434 | : "text-muted-foreground hover:text-foreground", | |
| 1435 | )} | |
| 1423 | <div className="col-span-2 min-w-0 px-4 pb-2 pt-1"> | |
| 1424 | <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> | |
| 1425 | <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground"> | |
| 1426 | <PullRequestActorAvatar actor={detail.author} className="shrink-0" /> | |
| 1427 | <span className="sr-only">{detail.author?.login ?? "ghost"}</span> | |
| 1428 | <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span> | |
| 1429 | </span> | |
| 1430 | <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" /> | |
| 1431 | <span | |
| 1432 | className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65" | |
| 1433 | title={`${detail.baseBranch} ← ${detail.headBranch}`} | |
| 1434 | > | |
| 1435 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> | |
| 1436 | {isStackedPullRequest ? ( | |
| 1437 | <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" /> | |
| 1438 | ) : null} | |
| 1439 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 1440 | </span> | |
| 1441 | {freshness ? ( | |
| 1442 | <PullRequestBaseFreshnessWarning | |
| 1443 | baseBranch={detail.baseBranch} | |
| 1444 | freshness={freshness} | |
| 1445 | pending={actionPending} | |
| 1446 | onUpdate={(method) => void perform("update-branch", undefined, method)} | |
| 1447 | iconClassName="size-3" | |
| 1448 | /> | |
| 1449 | ) : null} | |
| 1450 | <ArrowLeftIcon | |
| 1451 | aria-label="receives changes from" | |
| 1452 | className="size-3 shrink-0 opacity-60" | |
| 1453 | /> | |
| 1454 | <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code> | |
| 1455 | </span> | |
| 1456 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]"> | |
| 1457 | <span | |
| 1458 | className="inline-flex items-center gap-1 tabular-nums" | |
| 1459 | aria-label={`${detail.changedFiles.toLocaleString()} changed ${ | |
| 1460 | detail.changedFiles === 1 ? "file" : "files" | |
| 1461 | }`} | |
| 1436 | 1462 | > |
| 1437 | {item.label} | |
| 1438 | </button> | |
| 1439 | ))} | |
| 1440 | </nav> | |
| 1441 | <span | |
| 1442 | className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground" | |
| 1443 | title={`${detail.baseBranch} ← ${detail.headBranch}`} | |
| 1444 | > | |
| 1445 | <span className="truncate">{detail.baseBranch}</span> | |
| 1446 | {freshness ? ( | |
| 1447 | <PullRequestBaseFreshnessWarning | |
| 1448 | baseBranch={detail.baseBranch} | |
| 1449 | freshness={freshness} | |
| 1450 | pending={actionPending} | |
| 1451 | onUpdate={(method) => void perform("update-branch", undefined, method)} | |
| 1452 | iconClassName="size-3" | |
| 1463 | <FileDiffIcon aria-hidden className="size-3" /> | |
| 1464 | {detail.changedFiles.toLocaleString()} | |
| 1465 | </span> | |
| 1466 | <PullRequestDiffStat | |
| 1467 | additions={detail.additions} | |
| 1468 | deletions={detail.deletions} | |
| 1469 | className="shrink-0 font-mono text-[11px]" | |
| 1453 | 1470 | /> |
| 1454 | ) : null} | |
| 1455 | <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" /> | |
| 1456 | <span className="truncate">{detail.headBranch}</span> | |
| 1457 | </span> | |
| 1458 | <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground"> | |
| 1459 | <span className="inline-flex items-center gap-1 tabular-nums"> | |
| 1460 | <FileDiffIcon className="size-3" /> | |
| 1461 | {detail.changedFiles.toLocaleString()} | |
| 1462 | 1471 | </span> |
| 1463 | <PullRequestDiffStat | |
| 1464 | additions={detail.additions} | |
| 1465 | deletions={detail.deletions} | |
| 1466 | className="shrink-0 font-mono text-[11px]" | |
| 1467 | /> | |
| 1468 | </span> | |
| 1472 | </div> | |
| 1469 | 1473 | </div> |
| 1470 | 1474 | ) : null} |
| 1471 | 1475 | </div> |
| 1472 | 1476 | </div> |
| 1473 | 1477 | |
| 1474 | {/* Folding is a grid track going to zero: the rows below stay mounted, the track | |
| 1475 | animates closed over them, and `inert` takes the hidden controls out of the tab | |
| 1476 | order for as long as the chrome is condensed. */} | |
| 1477 | <div | |
| 1478 | className={cn( | |
| 1479 | "col-span-2 grid", | |
| 1480 | // Collapse is instant: it happens mid-scroll, and the compensation that keeps the | |
| 1481 | // content pinned would fight an animated track frame by frame. Reopening happens | |
| 1482 | // at the top with no compensation, so the fold can ease back in instead of | |
| 1483 | // snapping the content down. | |
| 1484 | condensed | |
| 1485 | ? "grid-rows-[0fr]" | |
| 1486 | : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", | |
| 1487 | )} | |
| 1488 | > | |
| 1478 | <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}> | |
| 1489 | 1479 | <div |
| 1490 | 1480 | ref={foldRef} |
| 1491 | // One-way on purpose: appearing content eases in over ground the instant track | |
| 1492 | // already reserved; departing content cuts, because its ground is gone in the | |
| 1493 | // same frame and the scroll compensation reads it as scrolled past. | |
| 1494 | 1481 | className={cn( |
| 1495 | "min-h-0 overflow-hidden", | |
| 1482 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", | |
| 1496 | 1483 | condensed |
| 1497 | ? "opacity-0" | |
| 1498 | : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none", | |
| 1484 | ? "-translate-y-1 opacity-0 duration-100" | |
| 1485 | : "translate-y-0 opacity-100 delay-50", | |
| 1499 | 1486 | )} |
| 1500 | 1487 | inert={condensed} |
| 1501 | 1488 | > |
| 1502 | 1489 | {detail ? ( |
| 1503 | <div className="col-span-2 mt-3 min-w-0 px-4 pb-4"> | |
| 1490 | <div className="col-span-2 mt-1 min-w-0 px-4 pb-4"> | |
| 1504 | 1491 | {titleDraft === null ? ( |
| 1505 | 1492 | <div className="group flex min-w-0 items-start gap-1"> |
| 1506 | 1493 | <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug"> |
| ... | ... | @@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({ |
| 1567 | 1554 | </PullRequestMetaLine> |
| 1568 | 1555 | |
| 1569 | 1556 | <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1570 | <code | |
| 1571 | className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground" | |
| 1572 | title={detail.baseBranch} | |
| 1557 | <span | |
| 1558 | className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70" | |
| 1559 | title={`${detail.baseBranch} ← ${detail.headBranch}`} | |
| 1573 | 1560 | > |
| 1574 | {detail.baseBranch} | |
| 1575 | </code> | |
| 1576 | {freshness ? ( | |
| 1577 | <PullRequestBaseFreshnessWarning | |
| 1578 | baseBranch={detail.baseBranch} | |
| 1579 | freshness={freshness} | |
| 1580 | pending={actionPending} | |
| 1581 | onUpdate={(method) => void perform("update-branch", undefined, method)} | |
| 1561 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> | |
| 1562 | {isStackedPullRequest ? ( | |
| 1563 | <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" /> | |
| 1564 | ) : null} | |
| 1565 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 1566 | </span> | |
| 1567 | {freshness ? ( | |
| 1568 | <PullRequestBaseFreshnessWarning | |
| 1569 | baseBranch={detail.baseBranch} | |
| 1570 | freshness={freshness} | |
| 1571 | pending={actionPending} | |
| 1572 | onUpdate={(method) => void perform("update-branch", undefined, method)} | |
| 1573 | /> | |
| 1574 | ) : null} | |
| 1575 | <ArrowLeftIcon | |
| 1576 | aria-label="receives changes from" | |
| 1577 | className="size-3.5 shrink-0 opacity-60" | |
| 1582 | 1578 | /> |
| 1583 | ) : null} | |
| 1584 | <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" /> | |
| 1585 | <button | |
| 1586 | type="button" | |
| 1587 | className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | |
| 1588 | aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"} | |
| 1589 | title={isBranchCopied ? "Copied" : "Copy pull request branch"} | |
| 1590 | onClick={() => copyBranchToClipboard(detail.headBranch)} | |
| 1591 | > | |
| 1592 | <code | |
| 1593 | className={cn( | |
| 1594 | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", | |
| 1595 | isBranchCopied ? "opacity-0" : "opacity-100", | |
| 1596 | )} | |
| 1597 | title={detail.headBranch} | |
| 1598 | > | |
| 1599 | {detail.headBranch} | |
| 1600 | </code> | |
| 1601 | <span | |
| 1602 | aria-hidden="true" | |
| 1603 | className={cn( | |
| 1604 | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", | |
| 1605 | isBranchCopied ? "opacity-100" : "opacity-0", | |
| 1606 | )} | |
| 1579 | <button | |
| 1580 | type="button" | |
| 1581 | className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 1582 | aria-label={ | |
| 1583 | isBranchCopied ? "Branch name copied" : "Copy pull request branch" | |
| 1584 | } | |
| 1585 | title={isBranchCopied ? "Copied" : "Copy pull request branch"} | |
| 1586 | onClick={() => copyBranchToClipboard(detail.headBranch)} | |
| 1607 | 1587 | > |
| 1608 | Copied | |
| 1609 | </span> | |
| 1610 | </button> | |
| 1588 | <code | |
| 1589 | className={cn( | |
| 1590 | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", | |
| 1591 | isBranchCopied ? "opacity-0" : "opacity-100", | |
| 1592 | )} | |
| 1593 | > | |
| 1594 | {detail.headBranch} | |
| 1595 | </code> | |
| 1596 | <span | |
| 1597 | aria-hidden="true" | |
| 1598 | className={cn( | |
| 1599 | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", | |
| 1600 | isBranchCopied ? "opacity-100" : "opacity-0", | |
| 1601 | )} | |
| 1602 | > | |
| 1603 | Copied | |
| 1604 | </span> | |
| 1605 | </button> | |
| 1606 | </span> | |
| 1611 | 1607 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2"> |
| 1612 | 1608 | <span className="inline-flex items-center gap-1.5 tabular-nums"> |
| 1613 | 1609 | <FileDiffIcon className="size-3.5" /> |
| ... | ... | @@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({ |
| 1623 | 1619 | </div> |
| 1624 | 1620 | </div> |
| 1625 | 1621 | ) : null} |
| 1622 | </div> | |
| 1623 | </div> | |
| 1626 | 1624 | |
| 1627 | {detail && conflicting ? ( | |
| 1628 | <div className="col-span-2 flex items-center gap-1 px-4 pb-3"> | |
| 1629 | <Badge | |
| 1630 | variant="error" | |
| 1631 | className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive" | |
| 1625 | {detail ? ( | |
| 1626 | <nav | |
| 1627 | className={cn( | |
| 1628 | "col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", | |
| 1629 | condensed ? "py-1.5" : "py-2", | |
| 1630 | )} | |
| 1631 | aria-label="Pull request tabs" | |
| 1632 | > | |
| 1633 | <SegmentedTabList> | |
| 1634 | {visibleTabs.map((item) => ( | |
| 1635 | <SegmentedTab | |
| 1636 | key={item.value} | |
| 1637 | selected={tab === item.value} | |
| 1638 | onClick={() => setTab(item.value)} | |
| 1632 | 1639 | > |
| 1633 | <TriangleAlertIcon className="size-3.5" /> | |
| 1634 | Merge conflicts | |
| 1635 | </Badge> | |
| 1640 | {item.label} | |
| 1641 | </SegmentedTab> | |
| 1642 | ))} | |
| 1643 | </SegmentedTabList> | |
| 1644 | {tab === "summary" ? ( | |
| 1645 | <span | |
| 1646 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" | |
| 1647 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} | |
| 1648 | > | |
| 1649 | {checksState !== null ? ( | |
| 1650 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | |
| 1651 | ) : ( | |
| 1652 | <CircleDotIcon aria-hidden className="size-3.5" /> | |
| 1653 | )} | |
| 1654 | {checksSummary} | |
| 1655 | </span> | |
| 1656 | ) : tab === "timeline" ? ( | |
| 1657 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> | |
| 1658 | <PullRequestMetaLine | |
| 1659 | className={cn( | |
| 1660 | "whitespace-nowrap text-[11px] transition-opacity", | |
| 1661 | (activityPending || activityError) && "opacity-35", | |
| 1662 | )} | |
| 1663 | > | |
| 1664 | <span | |
| 1665 | className="inline-flex items-center gap-1" | |
| 1666 | aria-label={ | |
| 1667 | activityError | |
| 1668 | ? "Comments unavailable" | |
| 1669 | : `${detail.commentCount.toLocaleString()} ${ | |
| 1670 | detail.commentCount === 1 ? "comment" : "comments" | |
| 1671 | }` | |
| 1672 | } | |
| 1673 | > | |
| 1674 | <MessageSquareIcon aria-hidden className="size-3" /> | |
| 1675 | {activityError | |
| 1676 | ? "—" | |
| 1677 | : activityPending | |
| 1678 | ? "…" | |
| 1679 | : detail.commentCount.toLocaleString()} | |
| 1680 | </span> | |
| 1681 | <span | |
| 1682 | className="inline-flex items-center gap-1" | |
| 1683 | aria-label={ | |
| 1684 | activityError | |
| 1685 | ? "Commits unavailable" | |
| 1686 | : `${detail.commits.length.toLocaleString()} ${ | |
| 1687 | detail.commits.length === 1 ? "commit" : "commits" | |
| 1688 | }` | |
| 1689 | } | |
| 1690 | > | |
| 1691 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> | |
| 1692 | {activityError | |
| 1693 | ? "—" | |
| 1694 | : activityPending | |
| 1695 | ? "…" | |
| 1696 | : detail.commits.length.toLocaleString()} | |
| 1697 | </span> | |
| 1698 | </PullRequestMetaLine> | |
| 1636 | 1699 | <Button |
| 1637 | 1700 | size="xs" |
| 1638 | 1701 | variant="ghost" |
| 1639 | className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive" | |
| 1640 | disabled={handoff !== null} | |
| 1641 | onClick={startResolveConflicts} | |
| 1702 | className="h-7 px-2 text-[10px] text-muted-foreground" | |
| 1703 | aria-label={ | |
| 1704 | timelineOrder === "newest" | |
| 1705 | ? "Show oldest activity first" | |
| 1706 | : "Show newest activity first" | |
| 1707 | } | |
| 1708 | onClick={() => | |
| 1709 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) | |
| 1710 | } | |
| 1642 | 1711 | > |
| 1643 | {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve} | |
| 1644 | <ArrowUpRightIcon className="size-3.5 text-destructive" /> | |
| 1712 | <ArrowDownUpIcon aria-hidden className="size-3" /> | |
| 1713 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} | |
| 1645 | 1714 | </Button> |
| 1646 | 1715 | </div> |
| 1647 | 1716 | ) : null} |
| 1648 | ||
| 1649 | {detail ? ( | |
| 1650 | <nav | |
| 1651 | className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 1652 | aria-label="Pull request tabs" | |
| 1653 | > | |
| 1654 | {visibleTabs.map((item) => ( | |
| 1655 | <button | |
| 1656 | key={item.value} | |
| 1657 | type="button" | |
| 1658 | aria-pressed={tab === item.value} | |
| 1659 | onClick={() => setTab(item.value)} | |
| 1660 | className={cn( | |
| 1661 | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors", | |
| 1662 | tab === item.value | |
| 1663 | ? "bg-accent text-foreground" | |
| 1664 | : "text-muted-foreground hover:text-foreground", | |
| 1665 | )} | |
| 1666 | > | |
| 1667 | {item.label} | |
| 1668 | </button> | |
| 1669 | ))} | |
| 1670 | {tab === "summary" ? ( | |
| 1671 | <span | |
| 1672 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" | |
| 1673 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} | |
| 1674 | > | |
| 1675 | {/* The rollup icon opens the checks behind the summary; with none reported | |
| 1676 | there is nothing to open, so the plain glyph stays. */} | |
| 1677 | {detail && checksState !== null ? ( | |
| 1678 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | |
| 1679 | ) : ( | |
| 1680 | <CircleDotIcon aria-hidden className="size-3.5" /> | |
| 1681 | )} | |
| 1682 | {checksSummary} | |
| 1683 | </span> | |
| 1684 | ) : tab === "timeline" ? ( | |
| 1685 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> | |
| 1686 | <PullRequestMetaLine | |
| 1687 | className={cn( | |
| 1688 | "whitespace-nowrap text-[11px] transition-opacity", | |
| 1689 | (activityPending || activityError) && "opacity-35", | |
| 1690 | )} | |
| 1691 | > | |
| 1692 | <span | |
| 1693 | className="inline-flex items-center gap-1" | |
| 1694 | aria-label={ | |
| 1695 | activityError | |
| 1696 | ? "Comments unavailable" | |
| 1697 | : `${detail.commentCount.toLocaleString()} ${ | |
| 1698 | detail.commentCount === 1 ? "comment" : "comments" | |
| 1699 | }` | |
| 1700 | } | |
| 1701 | > | |
| 1702 | <MessageSquareIcon aria-hidden className="size-3" /> | |
| 1703 | {activityError | |
| 1704 | ? "—" | |
| 1705 | : activityPending | |
| 1706 | ? "…" | |
| 1707 | : detail.commentCount.toLocaleString()} | |
| 1708 | </span> | |
| 1709 | <span | |
| 1710 | className="inline-flex items-center gap-1" | |
| 1711 | aria-label={ | |
| 1712 | activityError | |
| 1713 | ? "Commits unavailable" | |
| 1714 | : `${detail.commits.length.toLocaleString()} ${ | |
| 1715 | detail.commits.length === 1 ? "commit" : "commits" | |
| 1716 | }` | |
| 1717 | } | |
| 1718 | > | |
| 1719 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> | |
| 1720 | {activityError | |
| 1721 | ? "—" | |
| 1722 | : activityPending | |
| 1723 | ? "…" | |
| 1724 | : detail.commits.length.toLocaleString()} | |
| 1725 | </span> | |
| 1726 | </PullRequestMetaLine> | |
| 1727 | <Button | |
| 1728 | size="xs" | |
| 1729 | variant="ghost" | |
| 1730 | className="h-7 px-2 text-[10px] text-muted-foreground" | |
| 1731 | aria-label={ | |
| 1732 | timelineOrder === "newest" | |
| 1733 | ? "Show oldest activity first" | |
| 1734 | : "Show newest activity first" | |
| 1735 | } | |
| 1736 | onClick={() => | |
| 1737 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) | |
| 1738 | } | |
| 1739 | > | |
| 1740 | <ArrowDownUpIcon aria-hidden className="size-3" /> | |
| 1741 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} | |
| 1742 | </Button> | |
| 1743 | </div> | |
| 1744 | ) : null} | |
| 1745 | </nav> | |
| 1746 | ) : null} | |
| 1747 | </div> | |
| 1748 | </div> | |
| 1717 | </nav> | |
| 1718 | ) : null} | |
| 1749 | 1719 | </div> |
| 1750 | 1720 | |
| 1751 | 1721 | <div |
| 1752 | 1722 | className="relative min-h-0 flex-1 overflow-hidden" |
| 1753 | // Scroll does not bubble, but it captures: one listener hears every tab's own scroll | |
| 1754 | // container. Collapse past two line-heights, expand only back at the very top, so the | |
| 1755 | // boundary row cannot flap the chrome open and shut. | |
| 1756 | 1723 | onScrollCapture={(event) => { |
| 1757 | if (chromeVariant !== "collapse") return; | |
| 1758 | 1724 | const scroller = event.target as HTMLElement; |
| 1759 | 1725 | scrollerRef.current = scroller; |
| 1760 | 1726 | const top = scroller.scrollTop; |
| 1761 | 1727 | setChromeCondensed((previous) => { |
| 1762 | 1728 | let next = previous; |
| 1763 | // `scrollHeight` reads the fold's natural height whichever state the track is in. | |
| 1764 | 1729 | const foldHeight = foldRef.current?.scrollHeight ?? 0; |
| 1765 | // The chrome trades the fold for the condensed second row, so the height the | |
| 1766 | // scrollport actually gains is the difference between the two. | |
| 1767 | 1730 | const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); |
| 1768 | 1731 | if (previous) { |
| 1769 | 1732 | // The hard top reopens the chrome with no refund: the reader asked for the top, |
| ... | ... | @@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({ |
| 1781 | 1744 | }); |
| 1782 | 1745 | }} |
| 1783 | 1746 | > |
| 1784 | {detailQuery.isPending && !detail ? ( | |
| 1785 | // The ghost wears the shape of the tab being waited on, so switching tabs mid-load | |
| 1786 | // does not flash a summary outline under a timeline heading. | |
| 1787 | tab === "timeline" ? ( | |
| 1788 | <PullRequestTimelineGhost /> | |
| 1789 | ) : tab === "code" ? ( | |
| 1790 | <DiffPanelLoadingState label="Loading pull request diff..." /> | |
| 1791 | ) : ( | |
| 1792 | <PullRequestDetailGhost /> | |
| 1793 | ) | |
| 1794 | ) : detailQuery.error && !detail ? ( | |
| 1747 | {detailQuery.error && !detail ? ( | |
| 1795 | 1748 | <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} /> |
| 1796 | 1749 | ) : detail ? ( |
| 1797 | 1750 | <> |
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+91-24| ... | ... | @@ -45,13 +45,11 @@ export function PullRequestListGhost({ |
| 45 | 45 | <GhostBar className="size-4 rounded-full" /> |
| 46 | 46 | <div className="min-w-0 space-y-1.5"> |
| 47 | 47 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> |
| 48 | <GhostBar | |
| 49 | className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])} | |
| 50 | /> | |
| 48 | <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} /> | |
| 51 | 49 | </div> |
| 52 | 50 | <div className="flex flex-col items-end gap-1.5"> |
| 53 | 51 | <GhostBar className="w-12" /> |
| 54 | <GhostBar className="w-16 bg-muted-foreground/10" /> | |
| 52 | <GhostBar className="w-16" /> | |
| 55 | 53 | </div> |
| 56 | 54 | </div> |
| 57 | 55 | ))} |
| ... | ... | @@ -59,32 +57,101 @@ export function PullRequestListGhost({ |
| 59 | 57 | ); |
| 60 | 58 | } |
| 61 | 59 | |
| 62 | /** The summary's own shape: a title, a byline, the facts rows, the description. */ | |
| 60 | /** | |
| 61 | * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description | |
| 62 | * boundaries in the ghost prevents the loaded pull request from replacing one layout with | |
| 63 | * another a moment later. | |
| 64 | */ | |
| 63 | 65 | export function PullRequestDetailGhost() { |
| 64 | 66 | return ( |
| 65 | 67 | <div |
| 66 | 68 | role="status" |
| 67 | 69 | aria-label="Loading pull request" |
| 68 | className="animate-ghost-pulse space-y-6 px-4 py-5" | |
| 70 | className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background" | |
| 69 | 71 | > |
| 70 | <div className="space-y-2"> | |
| 71 | <GhostBar className="h-5 w-4/5" /> | |
| 72 | <GhostBar className="w-2/5 bg-muted-foreground/10" /> | |
| 72 | <div className="shrink-0 border-b border-border/60"> | |
| 73 | <div className="flex h-7 items-center justify-between gap-3 px-4"> | |
| 74 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | |
| 75 | <GhostBar className="w-24" /> | |
| 76 | <GhostBar className="w-9" /> | |
| 77 | </div> | |
| 78 | <div className="flex shrink-0 items-center gap-1"> | |
| 79 | <GhostBar className="h-5 w-16 rounded-md" /> | |
| 80 | <GhostBar className="size-5 rounded-md" /> | |
| 81 | </div> | |
| 82 | </div> | |
| 83 | ||
| 84 | <div className="px-4 pb-4 pt-1"> | |
| 85 | <GhostBar className="h-5 w-4/5 max-w-md" /> | |
| 86 | <div className="mt-2 flex items-center gap-1.5"> | |
| 87 | <GhostBar className="size-4 rounded-full" /> | |
| 88 | <GhostBar className="w-24" /> | |
| 89 | </div> | |
| 90 | <div className="mt-4 flex min-w-0 items-center gap-2"> | |
| 91 | <GhostBar className="h-6 w-24 rounded-md" /> | |
| 92 | <GhostBar className="size-3 rounded-full" /> | |
| 93 | <GhostBar className="h-6 w-32 rounded-md" /> | |
| 94 | <div className="ml-auto flex shrink-0 items-center gap-2"> | |
| 95 | <GhostBar className="w-10" /> | |
| 96 | <GhostBar className="w-20" /> | |
| 97 | </div> | |
| 98 | </div> | |
| 99 | </div> | |
| 100 | ||
| 101 | <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2"> | |
| 102 | <div className="flex items-center gap-1 p-0.5"> | |
| 103 | <GhostBar className="h-6 w-16 rounded-md" /> | |
| 104 | <GhostBar className="h-6 w-16 rounded-md" /> | |
| 105 | <GhostBar className="h-6 w-12 rounded-md" /> | |
| 106 | </div> | |
| 107 | <GhostBar className="w-20" /> | |
| 108 | </div> | |
| 73 | 109 | </div> |
| 74 | <div className="space-y-3"> | |
| 75 | {Array.from({ length: 4 }, (_, index) => ( | |
| 76 | <div key={index} className="flex items-center gap-3"> | |
| 110 | ||
| 111 | <div className="min-h-0 flex-1 overflow-hidden"> | |
| 112 | <section className="px-4 py-3"> | |
| 113 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | |
| 114 | <div className="flex items-center gap-1.5"> | |
| 115 | <GhostBar className="size-3.5 rounded-full" /> | |
| 116 | <GhostBar className="w-14" /> | |
| 117 | </div> | |
| 118 | <div className="flex items-center gap-1"> | |
| 119 | <GhostBar className="size-4 rounded-full" /> | |
| 120 | <GhostBar className="size-4 rounded-full" /> | |
| 121 | <GhostBar className="ml-1 size-5 rounded-md" /> | |
| 122 | </div> | |
| 123 | </div> | |
| 124 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | |
| 125 | <div className="flex items-center gap-1.5"> | |
| 126 | <GhostBar className="size-3.5 rounded-full" /> | |
| 127 | <GhostBar className="w-10" /> | |
| 128 | </div> | |
| 129 | <div className="flex items-center gap-1"> | |
| 130 | <GhostBar className="h-5 w-24 rounded-full" /> | |
| 131 | <GhostBar className="h-5 w-20 rounded-full" /> | |
| 132 | </div> | |
| 133 | </div> | |
| 134 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | |
| 135 | <div className="flex items-center gap-1.5"> | |
| 136 | <GhostBar className="size-3.5 rounded-full" /> | |
| 137 | <GhostBar className="w-14" /> | |
| 138 | </div> | |
| 139 | <GhostBar className="w-20" /> | |
| 140 | </div> | |
| 141 | </section> | |
| 142 | ||
| 143 | <section className="border-t border-border/60"> | |
| 144 | <div className="flex min-h-11 items-center gap-1.5 px-4 py-3"> | |
| 145 | <GhostBar className="h-4 w-24" /> | |
| 77 | 146 | <GhostBar className="size-3.5 rounded-full" /> |
| 78 | <GhostBar className="w-20 bg-muted-foreground/10" /> | |
| 79 | <GhostBar className={TITLE_WIDTHS[(index + 1) % TITLE_WIDTHS.length]} /> | |
| 80 | 147 | </div> |
| 81 | ))} | |
| 82 | </div> | |
| 83 | <div className="space-y-2 pt-1"> | |
| 84 | <GhostBar className="w-full bg-muted-foreground/10" /> | |
| 85 | <GhostBar className="w-11/12 bg-muted-foreground/10" /> | |
| 86 | <GhostBar className="w-4/5 bg-muted-foreground/10" /> | |
| 87 | <GhostBar className="w-2/3 bg-muted-foreground/10" /> | |
| 148 | <div className="space-y-2 px-4 pb-4"> | |
| 149 | <GhostBar className="w-full" /> | |
| 150 | <GhostBar className="w-11/12" /> | |
| 151 | <GhostBar className="w-4/5" /> | |
| 152 | <GhostBar className="w-2/3" /> | |
| 153 | </div> | |
| 154 | </section> | |
| 88 | 155 | </div> |
| 89 | 156 | </div> |
| 90 | 157 | ); |
| ... | ... | @@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { |
| 113 | 180 | <div key={index} className="relative pb-5"> |
| 114 | 181 | <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" /> |
| 115 | 182 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> |
| 116 | <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" /> | |
| 183 | <GhostBar className="mt-1.5 w-16" /> | |
| 117 | 184 | </div> |
| 118 | 185 | ))} |
| 119 | 186 | </div> |
| ... | ... | @@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) { |
| 134 | 201 | <GhostBar className="size-5 shrink-0 rounded-full" /> |
| 135 | 202 | <div className="flex-1 space-y-1.5"> |
| 136 | 203 | <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} /> |
| 137 | <GhostBar className="w-full bg-muted-foreground/10" /> | |
| 138 | <GhostBar className="w-3/4 bg-muted-foreground/10" /> | |
| 204 | <GhostBar className="w-full" /> | |
| 205 | <GhostBar className="w-3/4" /> | |
| 139 | 206 | </div> |
| 140 | 207 | </div> |
| 141 | 208 | ))} |
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+9-6| ... | ... | @@ -25,6 +25,7 @@ import { cn } from "~/lib/utils"; |
| 25 | 25 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 26 | 26 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | 27 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; |
| 28 | import { Button } from "../ui/button"; | |
| 28 | 29 | |
| 29 | 30 | import { |
| 30 | 31 | Menu, |
| ... | ... | @@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({ |
| 261 | 262 | return ( |
| 262 | 263 | <Menu> |
| 263 | 264 | <MenuTrigger |
| 264 | className={cn( | |
| 265 | // The icon-button size that pairs with a full-height input, so the two read as one strip. | |
| 266 | "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8", | |
| 267 | filtered && "text-foreground", | |
| 268 | )} | |
| 269 | aria-label="Filter pull requests" | |
| 265 | render={ | |
| 266 | <Button | |
| 267 | className={cn("relative", filtered && "[--control-icon-color:currentColor]")} | |
| 268 | size="icon" | |
| 269 | variant="outline" | |
| 270 | aria-label="Filter pull requests" | |
| 271 | /> | |
| 272 | } | |
| 270 | 273 | > |
| 271 | 274 | <ListFilterIcon className="size-4" /> |
| 272 | 275 | {filtered ? ( |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3| ... | ... | @@ -196,12 +196,12 @@ function MetaRow({ |
| 196 | 196 | children: ReactNode; |
| 197 | 197 | }) { |
| 198 | 198 | return ( |
| 199 | <div className="flex items-center gap-2 py-1.5 text-xs"> | |
| 200 | <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground"> | |
| 199 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs"> | |
| 200 | <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground"> | |
| 201 | 201 | {icon} |
| 202 | 202 | {label} |
| 203 | 203 | </span> |
| 204 | <span className="min-w-0 flex-1 text-foreground">{children}</span> | |
| 204 | <span className="min-w-0 text-foreground">{children}</span> | |
| 205 | 205 | </div> |
| 206 | 206 | ); |
| 207 | 207 | } |
apps/web/src/components/settings/DiagnosticsSettings.tsx+1-1| ... | ... | @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { |
| 992 | 992 | : false; |
| 993 | 993 | |
| 994 | 994 | return ( |
| 995 | <SettingsPageContainer className="max-w-6xl gap-10"> | |
| 995 | <SettingsPageContainer width="expanded" className="gap-10"> | |
| 996 | 996 | <ResourceTelemetryDiagnostics /> |
| 997 | 997 | |
| 998 | 998 | <SettingsSection |
apps/web/src/components/settings/KeybindingsSettings.tsx+1-1| ... | ... | @@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() { |
| 1195 | 1195 | ); |
| 1196 | 1196 | |
| 1197 | 1197 | return ( |
| 1198 | <SettingsPageContainer className="max-w-5xl"> | |
| 1198 | <SettingsPageContainer width="wide"> | |
| 1199 | 1199 | <SettingsSection |
| 1200 | 1200 | {...searchableSetting("keybindings")} |
| 1201 | 1201 | headerAction={ |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+4-22| ... | ... | @@ -81,8 +81,6 @@ import { |
| 81 | 81 | type NewProjectScriptInput, |
| 82 | 82 | type ProjectScriptEditorRequest, |
| 83 | 83 | } from "../projectScriptEditor"; |
| 84 | import { cn } from "../../lib/utils"; | |
| 85 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | |
| 86 | 84 | import { Button } from "../ui/button"; |
| 87 | 85 | import { Input } from "../ui/input"; |
| 88 | 86 | import { |
| ... | ... | @@ -102,6 +100,7 @@ import { |
| 102 | 100 | WorkspaceBreadcrumbItem, |
| 103 | 101 | WorkspaceBreadcrumbSeparator, |
| 104 | 102 | } from "../WorkspaceBreadcrumb"; |
| 103 | import { WorkspacePageHeader } from "../WorkspacePageContainer"; | |
| 105 | 104 | import { |
| 106 | 105 | SettingResetButton, |
| 107 | 106 | SettingsPageContainer, |
| ... | ... | @@ -174,26 +173,9 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { |
| 174 | 173 | return ( |
| 175 | 174 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 176 | 175 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 177 | {!isElectron && ( | |
| 178 | <header | |
| 179 | className={cn( | |
| 180 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | |
| 181 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 182 | )} | |
| 183 | > | |
| 184 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | |
| 185 | </header> | |
| 186 | )} | |
| 187 | {isElectron && ( | |
| 188 | <div | |
| 189 | className={cn( | |
| 190 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | |
| 191 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 192 | )} | |
| 193 | > | |
| 194 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | |
| 195 | </div> | |
| 196 | )} | |
| 176 | <WorkspacePageHeader electron={isElectron}> | |
| 177 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | |
| 178 | </WorkspacePageHeader> | |
| 197 | 179 | <ProjectSettingsPanel projectKey={projectKey} /> |
| 198 | 180 | </div> |
| 199 | 181 | </SidebarInset> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-22| ... | ... | @@ -9,7 +9,6 @@ import { |
| 9 | 9 | } from "react"; |
| 10 | 10 | import { |
| 11 | 11 | ArchiveIcon, |
| 12 | ArrowLeftIcon, | |
| 13 | 12 | BotIcon, |
| 14 | 13 | GitBranchIcon, |
| 15 | 14 | KeyboardIcon, |
| ... | ... | @@ -19,7 +18,7 @@ import { |
| 19 | 18 | Settings2Icon, |
| 20 | 19 | XIcon, |
| 21 | 20 | } from "lucide-react"; |
| 22 | import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; | |
| 21 | import { useLocation, useNavigate } from "@tanstack/react-router"; | |
| 23 | 22 | |
| 24 | 23 | import { Button } from "../ui/button"; |
| 25 | 24 | import { Input } from "../ui/input"; |
| ... | ... | @@ -34,6 +33,7 @@ import { |
| 34 | 33 | useSidebar, |
| 35 | 34 | } from "../ui/sidebar"; |
| 36 | 35 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; |
| 36 | import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; | |
| 37 | 37 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 38 | 38 | import { |
| 39 | 39 | searchSettings, |
| ... | ... | @@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 72 | 72 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 73 | 73 | const navigate = useNavigate(); |
| 74 | 74 | const currentHash = useLocation({ select: (location) => location.hash }); |
| 75 | const canGoBack = useCanGoBack(); | |
| 76 | 75 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); |
| 77 | 76 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 78 | 77 | const [query, setQuery] = useState(""); |
| ... | ... | @@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 176 | 175 | }, |
| 177 | 176 | [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], |
| 178 | 177 | ); |
| 179 | const handleBackClick = useCallback(() => { | |
| 180 | if (isMobile) { | |
| 181 | setOpenMobile(false); | |
| 182 | } | |
| 183 | if (canGoBack) { | |
| 184 | window.history.back(); | |
| 185 | return; | |
| 186 | } | |
| 187 | void navigate({ to: "/" }); | |
| 188 | }, [canGoBack, isMobile, navigate, setOpenMobile]); | |
| 189 | ||
| 190 | 178 | return ( |
| 191 | 179 | <> |
| 192 | 180 | <SidebarContent className="overflow-x-hidden"> |
| ... | ... | @@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 296 | 284 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> |
| 297 | 285 | <T3ConnectSidebarSignIn /> |
| 298 | 286 | <div className="flex items-center gap-1"> |
| 299 | <SidebarMenu className="min-w-0 flex-1"> | |
| 300 | <SidebarMenuItem> | |
| 301 | <SidebarMenuButton onClick={handleBackClick}> | |
| 302 | <ArrowLeftIcon /> | |
| 303 | <span>Back</span> | |
| 304 | </SidebarMenuButton> | |
| 305 | </SidebarMenuItem> | |
| 306 | </SidebarMenu> | |
| 287 | <SidebarUtilityMenu /> | |
| 307 | 288 | <T3ConnectSidebarAvatar /> |
| 308 | 289 | </div> |
| 309 | 290 | </SidebarFooter> |
apps/web/src/components/settings/ThemeSettings.tsx+1-1| ... | ... | @@ -768,7 +768,7 @@ export function ThemeLibrary({ |
| 768 | 768 | <TooltipProvider> |
| 769 | 769 | <div |
| 770 | 770 | className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4" |
| 771 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 1fr))" }} | |
| 771 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }} | |
| 772 | 772 | > |
| 773 | 773 | {STANDARD_THEME_CARDS.map((standardTheme) => ( |
| 774 | 774 | <ThemeLibraryCard |
apps/web/src/components/settings/settingsLayout.tsx+6-3| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | } from "react"; |
| 13 | 13 | |
| 14 | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; | |
| 15 | 16 | import { Button } from "../ui/button"; |
| 16 | 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 17 | 18 | |
| ... | ... | @@ -233,9 +234,11 @@ export function SettingResetButton({ |
| 233 | 234 | export function SettingsPageContainer({ |
| 234 | 235 | children, |
| 235 | 236 | className, |
| 237 | width = "readable", | |
| 236 | 238 | }: { |
| 237 | 239 | children: ReactNode; |
| 238 | 240 | className?: string; |
| 241 | width?: WorkspacePageWidth; | |
| 239 | 242 | }) { |
| 240 | 243 | const navigate = useNavigate(); |
| 241 | 244 | const hash = useLocation({ select: (location) => location.hash }); |
| ... | ... | @@ -247,12 +250,12 @@ export function SettingsPageContainer({ |
| 247 | 250 | return ( |
| 248 | 251 | <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}> |
| 249 | 252 | <div |
| 250 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto px-4 pt-10 pb-7 sm:px-8 sm:pt-12 sm:pb-10" | |
| 253 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]" | |
| 251 | 254 | data-settings-page-scroll |
| 252 | 255 | > |
| 253 | <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}> | |
| 256 | <WorkspacePageContainer width={width} className={cn("gap-12", className)}> | |
| 254 | 257 | {children} |
| 255 | </div> | |
| 258 | </WorkspacePageContainer> | |
| 256 | 259 | </div> |
| 257 | 260 | </SettingsSearchTargetProvider> |
| 258 | 261 | ); |
apps/web/src/components/sidebar/SidebarChrome.tsx+78-68| ... | ... | @@ -4,8 +4,9 @@ import { |
| 4 | 4 | GitPullRequestIcon, |
| 5 | 5 | SettingsIcon, |
| 6 | 6 | } from "lucide-react"; |
| 7 | import type { ReactNode } from "react"; | |
| 7 | 8 | import { memo, useCallback } from "react"; |
| 8 | import { Link, useLocation, useNavigate } from "@tanstack/react-router"; | |
| 9 | import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; | |
| 9 | 10 | |
| 10 | 11 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 11 | 12 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -117,16 +118,44 @@ function T3Wordmark() { |
| 117 | 118 | ); |
| 118 | 119 | } |
| 119 | 120 | |
| 120 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { | |
| 121 | function SidebarUtilityItem({ | |
| 122 | icon, | |
| 123 | label, | |
| 124 | onClick, | |
| 125 | }: { | |
| 126 | icon: ReactNode; | |
| 127 | label: string; | |
| 128 | onClick: () => void; | |
| 129 | }) { | |
| 130 | return ( | |
| 131 | <SidebarMenuItem className="shrink-0"> | |
| 132 | <Tooltip> | |
| 133 | <TooltipTrigger | |
| 134 | render={ | |
| 135 | <SidebarMenuButton aria-label={label} onClick={onClick} size="icon"> | |
| 136 | {icon} | |
| 137 | </SidebarMenuButton> | |
| 138 | } | |
| 139 | /> | |
| 140 | <TooltipPopup side="top">{label}</TooltipPopup> | |
| 141 | </Tooltip> | |
| 142 | </SidebarMenuItem> | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { | |
| 121 | 147 | const navigate = useNavigate(); |
| 148 | const canGoBack = useCanGoBack(); | |
| 122 | 149 | const { isMobile, setOpenMobile } = useSidebar(); |
| 123 | 150 | const currentFooterPage = useLocation({ |
| 124 | 151 | select: (location) => |
| 125 | location.pathname === "/usage" | |
| 126 | ? "usage" | |
| 127 | : location.pathname === "/pull-requests" | |
| 128 | ? "pull-requests" | |
| 129 | : null, | |
| 152 | /^\/settings(?:\/|$)/.test(location.pathname) | |
| 153 | ? "settings" | |
| 154 | : location.pathname === "/usage" | |
| 155 | ? "usage" | |
| 156 | : location.pathname === "/pull-requests" | |
| 157 | ? "pull-requests" | |
| 158 | : null, | |
| 130 | 159 | }); |
| 131 | 160 | const { environments } = useEnvironments(); |
| 132 | 161 | // The page reads every connected server, so one of them offering pull requests is enough for |
| ... | ... | @@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 157 | 186 | |
| 158 | 187 | const handleBackClick = useCallback(() => { |
| 159 | 188 | closeMobileSidebar(); |
| 189 | if (canGoBack) { | |
| 190 | window.history.back(); | |
| 191 | return; | |
| 192 | } | |
| 160 | 193 | void navigate({ to: "/" }); |
| 161 | }, [closeMobileSidebar, navigate]); | |
| 194 | }, [canGoBack, closeMobileSidebar, navigate]); | |
| 162 | 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 | }); | |
| 230 | ||
| 231 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { | |
| 163 | 232 | return ( |
| 164 | 233 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> |
| 165 | 234 | <SidebarProviderUpdatePill /> |
| 166 | 235 | <SidebarUpdateArchitectureWarning /> |
| 167 | <SidebarMenu className="flex-row items-center"> | |
| 168 | {currentFooterPage ? ( | |
| 169 | <SidebarMenuItem className="min-w-0 flex-1"> | |
| 170 | <SidebarMenuButton onClick={handleBackClick}> | |
| 171 | <ArrowLeftIcon /> | |
| 172 | <span>Back</span> | |
| 173 | </SidebarMenuButton> | |
| 174 | </SidebarMenuItem> | |
| 175 | ) : ( | |
| 176 | <> | |
| 177 | <SidebarMenuItem className="shrink-0"> | |
| 178 | <Tooltip> | |
| 179 | <TooltipTrigger | |
| 180 | render={ | |
| 181 | <SidebarMenuButton | |
| 182 | aria-label="Settings" | |
| 183 | onClick={handleSettingsClick} | |
| 184 | size="icon" | |
| 185 | > | |
| 186 | <SettingsIcon /> | |
| 187 | </SidebarMenuButton> | |
| 188 | } | |
| 189 | /> | |
| 190 | <TooltipPopup side="top">Settings</TooltipPopup> | |
| 191 | </Tooltip> | |
| 192 | </SidebarMenuItem> | |
| 193 | {pullRequestsSupported ? ( | |
| 194 | <SidebarMenuItem className="shrink-0"> | |
| 195 | <Tooltip> | |
| 196 | <TooltipTrigger | |
| 197 | render={ | |
| 198 | <SidebarMenuButton | |
| 199 | aria-label="Pull Requests" | |
| 200 | onClick={handlePullRequestsClick} | |
| 201 | size="icon" | |
| 202 | > | |
| 203 | <GitPullRequestIcon /> | |
| 204 | </SidebarMenuButton> | |
| 205 | } | |
| 206 | /> | |
| 207 | <TooltipPopup side="top">Pull Requests</TooltipPopup> | |
| 208 | </Tooltip> | |
| 209 | </SidebarMenuItem> | |
| 210 | ) : null} | |
| 211 | <SidebarMenuItem className="shrink-0"> | |
| 212 | <Tooltip> | |
| 213 | <TooltipTrigger | |
| 214 | render={ | |
| 215 | <SidebarMenuButton aria-label="Usage" onClick={handleUsageClick} size="icon"> | |
| 216 | <ChartNoAxesColumnIcon /> | |
| 217 | </SidebarMenuButton> | |
| 218 | } | |
| 219 | /> | |
| 220 | <TooltipPopup side="top">Usage</TooltipPopup> | |
| 221 | </Tooltip> | |
| 222 | </SidebarMenuItem> | |
| 223 | </> | |
| 224 | )} | |
| 225 | <SidebarUpdatePill /> | |
| 226 | </SidebarMenu> | |
| 236 | <SidebarUtilityMenu /> | |
| 227 | 237 | </SidebarFooter> |
| 228 | 238 | ); |
| 229 | 239 | }); |
apps/web/src/components/threadActionMenu.logic.test.ts+10-4| ... | ... | @@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] { |
| 19 | 19 | return buildThreadActionMenuItems(state).map((item) => item.id); |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | function 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 | ||
| 22 | 28 | describe("buildThreadActionMenuItems", () => { |
| 23 | 29 | it("hides lifecycle items when the environment lacks the capabilities", () => { |
| 24 | 30 | expect( |
| ... | ... | @@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => { |
| 26 | 32 | ...baseState, |
| 27 | 33 | supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, |
| 28 | 34 | }), |
| 29 | ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); | |
| 35 | ).toEqual(["rename", "mark-unread", "copy", "delete"]); | |
| 30 | 36 | }); |
| 31 | 37 | |
| 32 | 38 | it("includes branch items only for threads with a branch", () => { |
| 33 | const withBranch = ids({ ...baseState, branch: "feat/menu" }); | |
| 39 | const withBranch = allIds({ ...baseState, branch: "feat/menu" }); | |
| 34 | 40 | expect(withBranch).toContain("new-thread-on-branch"); |
| 35 | 41 | expect(withBranch).toContain("copy-branch"); |
| 36 | expect(ids(baseState)).not.toContain("new-thread-on-branch"); | |
| 37 | expect(ids(baseState)).not.toContain("copy-branch"); | |
| 42 | expect(allIds(baseState)).not.toContain("new-thread-on-branch"); | |
| 43 | expect(allIds(baseState)).not.toContain("copy-branch"); | |
| 38 | 44 | }); |
| 39 | 45 | |
| 40 | 46 | it("flips lifecycle labels with thread state", () => { |
apps/web/src/components/threadActionMenu.logic.ts+31-11| ... | ... | @@ -18,6 +18,7 @@ export type ThreadActionMenuId = |
| 18 | 18 | | "rename" |
| 19 | 19 | | "regenerate-title" |
| 20 | 20 | | "mark-unread" |
| 21 | | "copy" | |
| 21 | 22 | | "copy-path" |
| 22 | 23 | | "copy-branch" |
| 23 | 24 | | "copy-thread-id" |
| ... | ... | @@ -53,14 +54,15 @@ export function buildThreadActionMenuItems( |
| 53 | 54 | { |
| 54 | 55 | id: "new-thread-on-branch" as const, |
| 55 | 56 | label: `New thread on ${state.branch}`, |
| 57 | icon: "message-square-plus", | |
| 56 | 58 | }, |
| 57 | 59 | ] |
| 58 | 60 | : []), |
| 59 | 61 | ...(state.supports.pinning |
| 60 | 62 | ? [ |
| 61 | 63 | state.isPinned |
| 62 | ? { id: "unpin" as const, label: "Unpin thread" } | |
| 63 | : { id: "pin" as const, label: "Pin thread" }, | |
| 64 | ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } | |
| 65 | : { id: "pin" as const, label: "Pin thread", icon: "pin" }, | |
| 64 | 66 | ] |
| 65 | 67 | : []), |
| 66 | 68 | // Both lifecycle actions stay available on pinned threads: settling |
| ... | ... | @@ -69,17 +71,18 @@ export function buildThreadActionMenuItems( |
| 69 | 71 | ...(state.supports.settlement |
| 70 | 72 | ? [ |
| 71 | 73 | state.isSettled |
| 72 | ? { id: "unsettle" as const, label: "Un-settle thread" } | |
| 73 | : { id: "settle" as const, label: "Settle thread" }, | |
| 74 | ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } | |
| 75 | : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, | |
| 74 | 76 | ] |
| 75 | 77 | : []), |
| 76 | 78 | ...(state.supports.snooze |
| 77 | 79 | ? [ |
| 78 | 80 | state.isSnoozed |
| 79 | ? { id: "unsnooze" as const, label: "Wake thread" } | |
| 81 | ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } | |
| 80 | 82 | : { |
| 81 | 83 | id: "snooze" as const, |
| 82 | 84 | label: "Snooze", |
| 85 | icon: "clock", | |
| 83 | 86 | disabled: !state.canSnoozeNow, |
| 84 | 87 | children: state.snoozePresets.map((preset) => ({ |
| 85 | 88 | id: `snooze:${preset.id}` as const, |
| ... | ... | @@ -88,20 +91,37 @@ export function buildThreadActionMenuItems( |
| 88 | 91 | }, |
| 89 | 92 | ] |
| 90 | 93 | : []), |
| 91 | { id: "rename", label: "Rename thread" }, | |
| 94 | { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, | |
| 92 | 95 | ...(state.supports.titleRegeneration |
| 93 | 96 | ? [ |
| 94 | 97 | { |
| 95 | 98 | id: "regenerate-title" as const, |
| 96 | 99 | label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", |
| 100 | icon: "refresh-cw", | |
| 97 | 101 | disabled: state.isRegeneratingTitle, |
| 98 | 102 | }, |
| 99 | 103 | ] |
| 100 | 104 | : []), |
| 101 | { id: "mark-unread", label: "Mark unread" }, | |
| 102 | { id: "copy-path", label: "Copy path", icon: "copy" }, | |
| 103 | ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), | |
| 104 | { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, | |
| 105 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | |
| 105 | { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, | |
| 106 | { | |
| 107 | id: "copy", | |
| 108 | label: "Copy", | |
| 109 | icon: "copy", | |
| 110 | separatorBefore: true, | |
| 111 | children: [ | |
| 112 | { id: "copy-path", label: "Path", icon: "folder" }, | |
| 113 | ...(state.branch | |
| 114 | ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] | |
| 115 | : []), | |
| 116 | { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, | |
| 117 | ], | |
| 118 | }, | |
| 119 | { | |
| 120 | id: "delete", | |
| 121 | label: "Delete", | |
| 122 | destructive: true, | |
| 123 | icon: "trash", | |
| 124 | separatorBefore: true, | |
| 125 | }, | |
| 106 | 126 | ]; |
| 107 | 127 | } |
apps/web/src/components/ui/segmented-tabs.tsx created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import type { ComponentProps, HTMLAttributes } from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | import { Toggle } from "~/components/ui/toggle"; | |
| 5 | ||
| 6 | function 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 | ||
| 19 | function 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 | ||
| 40 | export { SegmentedTab, SegmentedTabList }; |
apps/web/src/components/ui/toggle.tsx+6| ... | ... | @@ -18,6 +18,10 @@ const toggleVariants = cva( |
| 18 | 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 | 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", |
| 20 | 20 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", |
| 21 | segmented: | |
| 22 | "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", | |
| 23 | "segmented-compact": | |
| 24 | "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", | |
| 21 | 25 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", |
| 22 | 26 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", |
| 23 | 27 | }, |
| ... | ... | @@ -27,6 +31,8 @@ const toggleVariants = cva( |
| 27 | 31 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", |
| 28 | 32 | outline: |
| 29 | 33 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", |
| 34 | segmented: | |
| 35 | "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", | |
| 30 | 36 | }, |
| 31 | 37 | }, |
| 32 | 38 | }, |
apps/web/src/components/usage/UsagePage.tsx+150-243| ... | ... | @@ -19,13 +19,22 @@ import { |
| 19 | 19 | formatUsd, |
| 20 | 20 | makeWindow, |
| 21 | 21 | } from "@t3tools/shared/usageFormat"; |
| 22 | import { ScrollArea } from "../ui/scroll-area"; | |
| 23 | 22 | import { Button } from "../ui/button"; |
| 23 | import { ScrollArea } from "../ui/scroll-area"; | |
| 24 | 24 | import { SidebarInset } from "../ui/sidebar"; |
| 25 | import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; | |
| 26 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | |
| 27 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | |
| 28 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | |
| 25 | import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; | |
| 26 | import { | |
| 27 | WorkspaceBreadcrumb, | |
| 28 | WorkspaceBreadcrumbItem, | |
| 29 | WorkspaceBreadcrumbSeparator, | |
| 30 | } from "../WorkspaceBreadcrumb"; | |
| 31 | import { | |
| 32 | WorkspacePageContainer, | |
| 33 | WorkspacePageHeader, | |
| 34 | WorkspacePageHeaderEdgeControl, | |
| 35 | } from "../WorkspacePageContainer"; | |
| 36 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | |
| 37 | import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | |
| 29 | 38 | |
| 30 | 39 | const WINDOW_OPTIONS = [ |
| 31 | 40 | { days: 1, label: "Past 24h" }, |
| ... | ... | @@ -66,21 +75,6 @@ export function UsagePage() { |
| 66 | 75 | [isPast24Hours, merged.daily, merged.hourly], |
| 67 | 76 | ); |
| 68 | 77 | |
| 69 | // Ranked by whatever the toggle is showing, so the bars always descend. | |
| 70 | const orderedProviders = useMemo( | |
| 71 | () => | |
| 72 | merged.providers.toSorted((a, b) => | |
| 73 | metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, | |
| 74 | ), | |
| 75 | [merged.providers, metric], | |
| 76 | ); | |
| 77 | ||
| 78 | const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( | |
| 79 | (period) => period.totalTokens > 0, | |
| 80 | ).length; | |
| 81 | const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; | |
| 82 | const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; | |
| 83 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; | |
| 84 | 78 | const selectWindow = (days: number) => { |
| 85 | 79 | setWindowSelection({ |
| 86 | 80 | days, |
| ... | ... | @@ -100,78 +94,66 @@ export function UsagePage() { |
| 100 | 94 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 101 | 95 | } |
| 102 | 96 | }; |
| 97 | const windowLabel = | |
| 98 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | |
| 99 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | |
| 100 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; | |
| 101 | const topbarContent = ( | |
| 102 | <div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2"> | |
| 103 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0"> | |
| 104 | <WorkspaceBreadcrumbItem current> | |
| 105 | <h1>Usage</h1> | |
| 106 | </WorkspaceBreadcrumbItem> | |
| 107 | <WorkspaceBreadcrumbSeparator /> | |
| 108 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | |
| 109 | <span className="truncate">{windowLabel}</span> | |
| 110 | </WorkspaceBreadcrumbItem> | |
| 111 | </WorkspaceBreadcrumb> | |
| 112 | <div className="ms-auto flex min-w-0 basis-full flex-wrap items-center justify-end gap-2 sm:basis-auto"> | |
| 113 | <SegmentedTabList aria-label="Usage metric"> | |
| 114 | {(["cost", "tokens"] as const).map((option) => ( | |
| 115 | <SegmentedTab | |
| 116 | key={option} | |
| 117 | selected={option === metric} | |
| 118 | onClick={() => setMetric(option)} | |
| 119 | > | |
| 120 | {option === "cost" ? "Cost" : "Tokens"} | |
| 121 | </SegmentedTab> | |
| 122 | ))} | |
| 123 | </SegmentedTabList> | |
| 124 | <SegmentedTabList aria-label="Usage period"> | |
| 125 | {WINDOW_OPTIONS.map((option) => ( | |
| 126 | <SegmentedTab | |
| 127 | key={option.days} | |
| 128 | selected={option.days === windowDays} | |
| 129 | onClick={() => selectWindow(option.days)} | |
| 130 | > | |
| 131 | {option.label} | |
| 132 | </SegmentedTab> | |
| 133 | ))} | |
| 134 | </SegmentedTabList> | |
| 135 | <WorkspacePageHeaderEdgeControl> | |
| 136 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | |
| 137 | <RefreshCwIcon className="size-3.5" /> | |
| 138 | </Button> | |
| 139 | </WorkspacePageHeaderEdgeControl> | |
| 140 | </div> | |
| 141 | </div> | |
| 142 | ); | |
| 103 | 143 | |
| 104 | 144 | return ( |
| 105 | 145 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 106 | 146 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 107 | {!isElectron && ( | |
| 108 | <header | |
| 109 | className={cn( | |
| 110 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | |
| 111 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 112 | )} | |
| 113 | > | |
| 114 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | |
| 115 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | |
| 116 | </WorkspaceBreadcrumb> | |
| 117 | </header> | |
| 118 | )} | |
| 119 | ||
| 120 | {isElectron && ( | |
| 121 | <div | |
| 122 | className={cn( | |
| 123 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | |
| 124 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 125 | )} | |
| 126 | > | |
| 127 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | |
| 128 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | |
| 129 | </WorkspaceBreadcrumb> | |
| 130 | </div> | |
| 131 | )} | |
| 147 | <WorkspacePageHeader electron={isElectron} className="h-auto py-2"> | |
| 148 | {topbarContent} | |
| 149 | </WorkspacePageHeader> | |
| 132 | 150 | |
| 133 | 151 | <ScrollArea className="min-h-0 flex-1"> |
| 134 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> | |
| 135 | <div className="flex flex-wrap items-center justify-between gap-4"> | |
| 136 | <p className="text-sm text-muted-foreground"> | |
| 137 | {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | |
| 138 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | |
| 139 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} | |
| 140 | </p> | |
| 141 | <div className="flex items-center gap-2"> | |
| 142 | <div className="flex rounded-md border border-border"> | |
| 143 | {WINDOW_OPTIONS.map((option) => ( | |
| 144 | <button | |
| 145 | key={option.days} | |
| 146 | type="button" | |
| 147 | aria-pressed={option.days === windowDays} | |
| 148 | onClick={() => selectWindow(option.days)} | |
| 149 | className={cn( | |
| 150 | "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 151 | option.days === windowDays | |
| 152 | ? "bg-muted text-foreground" | |
| 153 | : "text-muted-foreground hover:text-foreground", | |
| 154 | )} | |
| 155 | > | |
| 156 | {option.label} | |
| 157 | </button> | |
| 158 | ))} | |
| 159 | </div> | |
| 160 | <Button | |
| 161 | size="icon" | |
| 162 | variant="outline" | |
| 163 | onClick={refreshWindow} | |
| 164 | aria-label="Refresh usage" | |
| 165 | > | |
| 166 | <RefreshCwIcon className="size-3.5" /> | |
| 167 | </Button> | |
| 168 | </div> | |
| 169 | </div> | |
| 170 | ||
| 152 | <WorkspacePageContainer width="wide"> | |
| 171 | 153 | {settling ? ( |
| 172 | 154 | <> |
| 173 | 155 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 174 | <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} /> | |
| 156 | <UsageSkeleton /> | |
| 175 | 157 | </> |
| 176 | 158 | ) : ( |
| 177 | 159 | <> |
| ... | ... | @@ -181,88 +163,62 @@ export function UsagePage() { |
| 181 | 163 | staleEnvironments={merged.staleEnvironments} |
| 182 | 164 | /> |
| 183 | 165 | |
| 184 | {/* Cost first: the financial answer, then the provider split. */} | |
| 185 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | |
| 186 | {/* The summary follows the chart toggle, so the headline and the | |
| 187 | series are always reading the same units. */} | |
| 188 | <div className="flex flex-col gap-5"> | |
| 166 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> | |
| 167 | <div className="flex min-w-0 flex-col gap-5"> | |
| 189 | 168 | <div className="flex flex-col gap-1"> |
| 190 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 191 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | |
| 192 | </span> | |
| 193 | 169 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 194 | 170 | {metric === "cost" |
| 195 | ? `${formatUsd(merged.costUsd)}*` | |
| 171 | ? formatUsd(merged.costUsd) | |
| 196 | 172 | : formatTokens(merged.totalTokens)} |
| 197 | 173 | </span> |
| 198 | 174 | <span className="text-xs text-muted-foreground"> |
| 199 | 175 | {metric === "cost" |
| 200 | ? "* if billed at full API rate" | |
| 201 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} | |
| 176 | ? `${formatCount(merged.sessions)} sessions · API estimate` | |
| 177 | : `${formatCount(merged.sessions)} sessions`} | |
| 202 | 178 | </span> |
| 203 | 179 | </div> |
| 204 | 180 | |
| 205 | {orderedProviders.map((provider) => { | |
| 206 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; | |
| 181 | {PROVIDER_ORDER.map((provider) => { | |
| 182 | const totals = merged.providers.find((entry) => entry.provider === provider); | |
| 183 | const share = | |
| 184 | metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); | |
| 185 | const providerSessions = totals?.sessions ?? 0; | |
| 186 | const sessionLabel = `${formatCount(providerSessions)} ${ | |
| 187 | providerSessions === 1 ? "session" : "sessions" | |
| 188 | }`; | |
| 207 | 189 | return ( |
| 208 | <div key={provider.provider} className="flex flex-col gap-1.5"> | |
| 209 | <div className="flex items-baseline justify-between"> | |
| 210 | <span className="flex items-center gap-2 text-sm text-foreground"> | |
| 211 | <ProviderMark provider={provider.provider} className="size-4" /> | |
| 212 | {PROVIDER_LABEL[provider.provider]} | |
| 190 | <div key={provider} className="flex flex-col gap-1"> | |
| 191 | <div className="flex items-baseline justify-between gap-4"> | |
| 192 | <span className="flex min-w-0 items-center gap-2 text-sm text-foreground"> | |
| 193 | <ProviderMark provider={provider} className="size-4" /> | |
| 194 | <span className="flex min-w-0 items-baseline gap-1.5"> | |
| 195 | <span className="truncate">{PROVIDER_LABEL[provider]}</span> | |
| 196 | <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground tabular-nums"> | |
| 197 | {sessionLabel} | |
| 198 | </span> | |
| 199 | </span> | |
| 213 | 200 | </span> |
| 214 | <span className="text-sm text-foreground tabular-nums"> | |
| 201 | <span className="shrink-0 text-sm font-medium text-foreground tabular-nums"> | |
| 215 | 202 | {metric === "cost" |
| 216 | ? formatUsd(provider.costUsd) | |
| 217 | : formatTokens(provider.totalTokens)} | |
| 203 | ? formatUsd(totals?.costUsd ?? 0) | |
| 204 | : formatTokens(totals?.totalTokens ?? 0)} | |
| 218 | 205 | </span> |
| 219 | 206 | </div> |
| 220 | <div className="h-1 w-full overflow-hidden rounded-full bg-muted"> | |
| 221 | <div | |
| 222 | className="h-full" | |
| 223 | style={{ | |
| 224 | width: `${(share * 100).toFixed(1)}%`, | |
| 225 | backgroundColor: PROVIDER_COLOR[provider.provider], | |
| 226 | }} | |
| 227 | /> | |
| 228 | </div> | |
| 229 | 207 | <span className="text-xs text-muted-foreground"> |
| 230 | 208 | {metric === "cost" |
| 231 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` | |
| 232 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} | |
| 209 | ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` | |
| 210 | : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} | |
| 233 | 211 | </span> |
| 234 | 212 | </div> |
| 235 | 213 | ); |
| 236 | 214 | })} |
| 237 | 215 | </div> |
| 238 | 216 | |
| 239 | <div className="flex flex-col gap-3"> | |
| 240 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 241 | <h2 className="text-sm font-medium text-foreground"> | |
| 242 | {isPast24Hours ? "Hourly" : "Daily"}{" "} | |
| 243 | {metric === "tokens" ? "processed tokens" : "cost"} | |
| 244 | </h2> | |
| 245 | <div className="flex items-center gap-4"> | |
| 246 | <div className="flex overflow-hidden rounded-md border border-border"> | |
| 247 | {(["cost", "tokens"] as const).map((option) => ( | |
| 248 | <button | |
| 249 | key={option} | |
| 250 | type="button" | |
| 251 | onClick={() => setMetric(option)} | |
| 252 | className={cn( | |
| 253 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | |
| 254 | option === metric | |
| 255 | ? "bg-muted text-foreground" | |
| 256 | : "text-muted-foreground hover:text-foreground", | |
| 257 | )} | |
| 258 | > | |
| 259 | {option} | |
| 260 | </button> | |
| 261 | ))} | |
| 262 | </div> | |
| 263 | <UsageChartLegend /> | |
| 264 | </div> | |
| 265 | </div> | |
| 217 | <div className="flex min-w-0 flex-col gap-3"> | |
| 218 | <h2 className="text-sm font-medium text-foreground"> | |
| 219 | {isPast24Hours ? "Hourly" : "Daily"}{" "} | |
| 220 | {metric === "tokens" ? "processed tokens" : "cost"} | |
| 221 | </h2> | |
| 266 | 222 | <UsageProviderChart |
| 267 | 223 | days={days} |
| 268 | 224 | daily={merged.daily} |
| ... | ... | @@ -276,63 +232,42 @@ export function UsagePage() { |
| 276 | 232 | </div> |
| 277 | 233 | </section> |
| 278 | 234 | |
| 279 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | |
| 280 | <Metric | |
| 281 | label="Processed tokens" | |
| 282 | value={formatTokens(merged.totalTokens)} | |
| 283 | detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`} | |
| 284 | /> | |
| 285 | <Metric | |
| 286 | label="Cached input" | |
| 287 | value={formatTokens(merged.cachedInputTokens)} | |
| 288 | detail={`${formatPercent(cachedShare)} of observed input`} | |
| 289 | /> | |
| 290 | <Metric | |
| 291 | label="Uncached input" | |
| 292 | value={formatTokens(merged.uncachedInputTokens)} | |
| 293 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} | |
| 294 | /> | |
| 295 | <Metric | |
| 296 | label="Output" | |
| 297 | value={formatTokens(merged.outputTokens)} | |
| 298 | detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} | |
| 299 | /> | |
| 300 | <Metric | |
| 301 | label="Cache savings" | |
| 302 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | |
| 303 | detail={ | |
| 304 | merged.costUsd > 0 | |
| 305 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` | |
| 306 | : "vs full input rates" | |
| 307 | } | |
| 308 | /> | |
| 235 | <section className="flex flex-col gap-2"> | |
| 236 | <h2 className="text-sm font-medium text-foreground">Totals</h2> | |
| 237 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | |
| 238 | <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} /> | |
| 239 | <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} /> | |
| 240 | <Metric | |
| 241 | label="Uncached input" | |
| 242 | value={formatTokens(merged.uncachedInputTokens)} | |
| 243 | /> | |
| 244 | <Metric label="Output" value={formatTokens(merged.outputTokens)} /> | |
| 245 | <Metric | |
| 246 | label="Cache savings" | |
| 247 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | |
| 248 | /> | |
| 249 | </div> | |
| 309 | 250 | </section> |
| 310 | 251 | |
| 311 | 252 | <section className="flex flex-col gap-3"> |
| 312 | 253 | <div className="flex items-center justify-between gap-3"> |
| 313 | 254 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 314 | <div className="flex overflow-hidden rounded-md border border-border"> | |
| 255 | <SegmentedTabList aria-label="Usage breakdown"> | |
| 315 | 256 | {( |
| 316 | 257 | [ |
| 317 | { value: "model", label: "model" }, | |
| 318 | { value: "time", label: isPast24Hours ? "hour" : "day" }, | |
| 258 | { value: "model", label: "Model" }, | |
| 259 | { value: "time", label: isPast24Hours ? "Hour" : "Day" }, | |
| 319 | 260 | ] as const |
| 320 | 261 | ).map((option) => ( |
| 321 | <button | |
| 262 | <SegmentedTab | |
| 322 | 263 | key={option.value} |
| 323 | type="button" | |
| 264 | selected={option.value === breakdown} | |
| 324 | 265 | onClick={() => setBreakdown(option.value)} |
| 325 | className={cn( | |
| 326 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | |
| 327 | option.value === breakdown | |
| 328 | ? "bg-muted text-foreground" | |
| 329 | : "text-muted-foreground hover:text-foreground", | |
| 330 | )} | |
| 331 | 266 | > |
| 332 | 267 | {option.label} |
| 333 | </button> | |
| 268 | </SegmentedTab> | |
| 334 | 269 | ))} |
| 335 | </div> | |
| 270 | </SegmentedTabList> | |
| 336 | 271 | </div> |
| 337 | 272 | |
| 338 | 273 | {breakdown === "model" ? ( |
| ... | ... | @@ -356,7 +291,7 @@ export function UsagePage() { |
| 356 | 291 | merged.models.map((model) => ( |
| 357 | 292 | <tr |
| 358 | 293 | key={`${model.provider}:${model.model}`} |
| 359 | className="border-b border-border/50" | |
| 294 | className="hover:bg-muted/20" | |
| 360 | 295 | > |
| 361 | 296 | <td className="py-2 text-foreground"> |
| 362 | 297 | <span className="flex items-center gap-2"> |
| ... | ... | @@ -403,7 +338,7 @@ export function UsagePage() { |
| 403 | 338 | recentPeriods.map((period) => ( |
| 404 | 339 | <tr |
| 405 | 340 | key={"hourStart" in period ? period.hourStart : period.day} |
| 406 | className="border-b border-border/50" | |
| 341 | className="hover:bg-muted/20" | |
| 407 | 342 | > |
| 408 | 343 | <td className="py-2 text-foreground"> |
| 409 | 344 | {"hourStart" in period |
| ... | ... | @@ -433,7 +368,7 @@ export function UsagePage() { |
| 433 | 368 | </section> |
| 434 | 369 | </> |
| 435 | 370 | )} |
| 436 | </div> | |
| 371 | </WorkspacePageContainer> | |
| 437 | 372 | </ScrollArea> |
| 438 | 373 | </div> |
| 439 | 374 | </SidebarInset> |
| ... | ... | @@ -452,20 +387,11 @@ function ProviderMark({ |
| 452 | 387 | return <Mark className={cn("shrink-0", className)} aria-hidden />; |
| 453 | 388 | } |
| 454 | 389 | |
| 455 | function Metric({ | |
| 456 | label, | |
| 457 | value, | |
| 458 | detail, | |
| 459 | }: { | |
| 460 | readonly label: string; | |
| 461 | readonly value: string; | |
| 462 | readonly detail: string; | |
| 463 | }) { | |
| 390 | function Metric({ label, value }: { readonly label: string; readonly value: string }) { | |
| 464 | 391 | return ( |
| 465 | <div className="flex flex-col gap-0.5 bg-background px-4 py-3"> | |
| 392 | <div className="flex min-w-0 flex-col gap-0.5"> | |
| 466 | 393 | <span className="text-xs text-muted-foreground">{label}</span> |
| 467 | <span className="text-lg text-foreground tabular-nums">{value}</span> | |
| 468 | <span className="text-xs text-muted-foreground">{detail}</span> | |
| 394 | <span className="text-base font-medium text-foreground tabular-nums">{value}</span> | |
| 469 | 395 | </div> |
| 470 | 396 | ); |
| 471 | 397 | } |
| ... | ... | @@ -569,70 +495,51 @@ function UsageDeviceStrip({ |
| 569 | 495 | ); |
| 570 | 496 | } |
| 571 | 497 | |
| 572 | /** Deterministic bar heights (each unique: they double as keys). */ | |
| 573 | const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; | |
| 574 | ||
| 575 | 498 | /** |
| 576 | * Static stand-in with the loaded page's shape: headline, provider split, | |
| 577 | * chart and metrics strip. No shimmer; blocks fill in exactly once when the | |
| 578 | * last device answers. | |
| 499 | * Static stand-in with the loaded page's shape. No shimmer; blocks fill in | |
| 500 | * exactly once when the last device answers. | |
| 579 | 501 | */ |
| 580 | function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { | |
| 502 | function UsageSkeleton() { | |
| 581 | 503 | return ( |
| 582 | 504 | <> |
| 583 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | |
| 505 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> | |
| 584 | 506 | <div className="flex flex-col gap-5"> |
| 585 | 507 | <div className="flex flex-col gap-1"> |
| 586 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 587 | Raw token cost | |
| 588 | </span> | |
| 589 | <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" /> | |
| 590 | <div className="h-3 w-28 rounded-sm bg-muted" /> | |
| 508 | <div className="h-8 w-36 rounded-sm bg-muted" /> | |
| 509 | <div className="h-3 w-32 rounded-sm bg-muted" /> | |
| 591 | 510 | </div> |
| 592 | ||
| 593 | 511 | {PROVIDER_ORDER.map((provider) => ( |
| 594 | <div key={provider} className="flex flex-col gap-1.5"> | |
| 595 | <div className="flex items-center justify-between"> | |
| 596 | <span className="flex items-center gap-2 text-sm text-foreground"> | |
| 512 | <div key={provider} className="flex flex-col gap-1"> | |
| 513 | <div className="flex items-center justify-between gap-4"> | |
| 514 | <span className="flex items-center gap-2"> | |
| 597 | 515 | <ProviderMark provider={provider} className="size-4" /> |
| 598 | {PROVIDER_LABEL[provider]} | |
| 516 | <div className="h-3.5 w-20 rounded-sm bg-muted" /> | |
| 599 | 517 | </span> |
| 600 | 518 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 601 | 519 | </div> |
| 602 | <div className="h-1 w-full rounded-full bg-muted" /> | |
| 603 | 520 | <div className="h-3 w-36 rounded-sm bg-muted" /> |
| 604 | 521 | </div> |
| 605 | 522 | ))} |
| 606 | 523 | </div> |
| 607 | 524 | |
| 608 | 525 | <div className="flex flex-col gap-3"> |
| 609 | <h2 className="py-1 text-sm font-medium text-foreground"> | |
| 610 | {resolution === "hour" ? "Hourly" : "Daily"} cost | |
| 611 | </h2> | |
| 612 | {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a | |
| 613 | relayout when the real chart swaps in. */} | |
| 614 | <div className="flex h-56 items-end gap-1 pl-16"> | |
| 615 | {SKELETON_BAR_HEIGHTS.map((height) => ( | |
| 616 | <div | |
| 617 | key={height} | |
| 618 | className="flex-1 rounded-sm bg-muted" | |
| 619 | style={{ height: `${height}%` }} | |
| 620 | /> | |
| 621 | ))} | |
| 622 | </div> | |
| 526 | <div className="h-4 w-24 rounded-sm bg-muted" /> | |
| 527 | <div className="h-56 rounded-sm bg-muted/35" /> | |
| 623 | 528 | </div> |
| 624 | 529 | </section> |
| 625 | 530 | |
| 626 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | |
| 627 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | |
| 628 | (label) => ( | |
| 629 | <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3"> | |
| 630 | <span className="text-xs text-muted-foreground">{label}</span> | |
| 631 | <div className="my-1 h-5 w-16 rounded-sm bg-muted" /> | |
| 632 | <div className="h-3 w-24 rounded-sm bg-muted" /> | |
| 633 | </div> | |
| 634 | ), | |
| 635 | )} | |
| 531 | <section className="flex flex-col gap-2"> | |
| 532 | <h2 className="text-sm font-medium text-foreground">Totals</h2> | |
| 533 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | |
| 534 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | |
| 535 | (label) => ( | |
| 536 | <div key={label} className="flex flex-col gap-0.5"> | |
| 537 | <span className="text-xs text-muted-foreground">{label}</span> | |
| 538 | <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" /> | |
| 539 | </div> | |
| 540 | ), | |
| 541 | )} | |
| 542 | </div> | |
| 636 | 543 | </section> |
| 637 | 544 | </> |
| 638 | 545 | ); |
apps/web/src/components/usage/UsageProviderChart.tsx+72-65| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 2 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | 5 | import { |
| ... | ... | @@ -68,13 +68,7 @@ function buildPeriodColumns( |
| 68 | 68 | }); |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | /** | |
| 72 | * Monotone cubic tangents (Fritsch-Carlson). | |
| 73 | * | |
| 74 | * Plain cubic smoothing overshoots on spiky daily data and would dip the area | |
| 75 | * below zero between points, which reads as negative spend. This variant is | |
| 76 | * shape-preserving, so a smoothed series never leaves the range of its samples. | |
| 77 | */ | |
| 71 | /** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ | |
| 78 | 72 | function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 79 | 73 | const count = points.length; |
| 80 | 74 | if (count < 2) return [0]; |
| ... | ... | @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 115 | 109 | return tangents; |
| 116 | 110 | } |
| 117 | 111 | |
| 118 | /** One cubic segment of a smoothed boundary. */ | |
| 119 | 112 | interface CurveSegment { |
| 120 | 113 | readonly from: Point; |
| 121 | 114 | readonly c1: Point; |
| ... | ... | @@ -123,7 +116,6 @@ interface CurveSegment { |
| 123 | 116 | readonly to: Point; |
| 124 | 117 | } |
| 125 | 118 | |
| 126 | /** Smoothed polyline through `points`, as explicit cubic control points. */ | |
| 127 | 119 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 128 | 120 | if (points.length < 2) return []; |
| 129 | 121 | const tangents = monotoneTangents(points); |
| ... | ... | @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 144 | 136 | return segments; |
| 145 | 137 | } |
| 146 | 138 | |
| 147 | function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { | |
| 139 | function curvePath(segments: readonly CurveSegment[]): string { | |
| 148 | 140 | const first = segments[0]; |
| 149 | 141 | if (first === undefined) return ""; |
| 150 | let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; | |
| 142 | let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; | |
| 151 | 143 | for (const segment of segments) { |
| 152 | 144 | path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; |
| 153 | 145 | } |
| ... | ... | @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re |
| 179 | 171 | /** |
| 180 | 172 | * Turns the merged daily totals into one column per day. |
| 181 | 173 | * |
| 182 | * Values are absolute, not cumulative: the series are layered from a shared | |
| 183 | * zero baseline rather than stacked. A stacked chart puts whichever provider is | |
| 184 | * drawn last permanently above the other, which reads as "that one is bigger" | |
| 185 | * even on days where it is not. | |
| 174 | * Values are absolute, not cumulative: each provider is drawn from the same | |
| 175 | * zero baseline so the chart never implies that one provider is always larger. | |
| 186 | 176 | * |
| 187 | 177 | * The chart paths and the hover readout both consume this, so the number under |
| 188 | 178 | * the cursor is by construction the number that was plotted rather than a |
| ... | ... | @@ -216,42 +206,39 @@ export function UsageProviderChart({ |
| 216 | 206 | ); |
| 217 | 207 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); |
| 218 | 208 | const plotRef = useRef<HTMLDivElement | null>(null); |
| 209 | const tooltipRef = useRef<HTMLDivElement | null>(null); | |
| 210 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); | |
| 219 | 211 | |
| 220 | const { paths, ticks, stepX, toY, series } = useMemo(() => { | |
| 212 | const { paths, series, stepX, ticks, toY } = useMemo(() => { | |
| 221 | 213 | if (periods.length === 0) { |
| 222 | 214 | return { |
| 223 | 215 | paths: [], |
| 224 | ticks: [0] as readonly number[], | |
| 216 | series: [] as readonly DayColumn[], | |
| 225 | 217 | stepX: 0, |
| 218 | ticks: [0] as readonly number[], | |
| 226 | 219 | toY: () => VIEW_HEIGHT, |
| 227 | series: [] as readonly DayColumn[], | |
| 228 | 220 | }; |
| 229 | 221 | } |
| 230 | 222 | |
| 231 | 223 | const columns = buildPeriodColumns(periods, byPeriod, metric); |
| 232 | ||
| 233 | // The scale tops out at the largest single provider-day, not the largest | |
| 234 | // sum: layered series each measure from zero, so a combined peak would | |
| 235 | // leave the plot permanently half empty. | |
| 236 | 224 | const peak = columns.reduce( |
| 237 | 225 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), |
| 238 | 226 | 0, |
| 239 | 227 | ); |
| 240 | 228 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 241 | 229 | const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); |
| 242 | // Reserve a sliver above the top gridline so the series stroke, which is | |
| 243 | // drawn at constant screen width, is not shaved off at a peak. | |
| 244 | 230 | const toY = (value: number) => |
| 245 | 231 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 246 | 232 | |
| 247 | 233 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 248 | const curve = smoothCurve( | |
| 249 | columns.map((column, dayIndex) => ({ | |
| 250 | x: dayIndex * step, | |
| 251 | y: toY(column.bands[providerIndex]?.value ?? 0), | |
| 252 | })), | |
| 234 | const line = curvePath( | |
| 235 | smoothCurve( | |
| 236 | columns.map((column, periodIndex) => ({ | |
| 237 | x: periodIndex * step, | |
| 238 | y: toY(column.bands[providerIndex]?.value ?? 0), | |
| 239 | })), | |
| 240 | ), | |
| 253 | 241 | ); |
| 254 | const line = curvePath(curve, "M"); | |
| 255 | 242 | return { |
| 256 | 243 | provider, |
| 257 | 244 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| ... | ... | @@ -260,30 +247,65 @@ export function UsageProviderChart({ |
| 260 | 247 | }; |
| 261 | 248 | }); |
| 262 | 249 | |
| 263 | // Paint the heavier series first so the lighter one is never buried under | |
| 264 | // it. The fills are faint enough that the order barely shows, but the | |
| 265 | // strokes are drawn in a second pass regardless, so neither can be hidden. | |
| 266 | const ordered = [...built].sort((a, b) => b.total - a.total); | |
| 267 | ||
| 268 | return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; | |
| 250 | return { | |
| 251 | paths: built.toSorted((a, b) => b.total - a.total), | |
| 252 | series: columns, | |
| 253 | stepX: step, | |
| 254 | ticks: tickValues, | |
| 255 | toY, | |
| 256 | }; | |
| 269 | 257 | }, [byPeriod, metric, periods]); |
| 270 | 258 | |
| 271 | 259 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| 272 | 260 | |
| 261 | const positionTooltip = useCallback(() => { | |
| 262 | const plot = plotRef.current; | |
| 263 | const tooltip = tooltipRef.current; | |
| 264 | const hoverPosition = hoverPositionRef.current; | |
| 265 | if (plot === null || tooltip === null || hoverPosition === null) return; | |
| 266 | ||
| 267 | const gap = 12; | |
| 268 | const tooltipWidth = tooltip.offsetWidth; | |
| 269 | const tooltipHeight = tooltip.offsetHeight; | |
| 270 | const plotWidth = plot.clientWidth; | |
| 271 | const plotHeight = plot.clientHeight; | |
| 272 | const preferredLeft = | |
| 273 | hoverPosition.x + gap + tooltipWidth <= plotWidth | |
| 274 | ? hoverPosition.x + gap | |
| 275 | : hoverPosition.x - gap - tooltipWidth; | |
| 276 | const preferredTop = | |
| 277 | hoverPosition.y + gap + tooltipHeight <= plotHeight | |
| 278 | ? hoverPosition.y + gap | |
| 279 | : hoverPosition.y - gap - tooltipHeight; | |
| 280 | const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); | |
| 281 | const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); | |
| 282 | plot.style.setProperty("--usage-tooltip-left", `${left}px`); | |
| 283 | plot.style.setProperty("--usage-tooltip-top", `${top}px`); | |
| 284 | }, []); | |
| 285 | ||
| 286 | useLayoutEffect(() => { | |
| 287 | if (hoverIndex !== null) positionTooltip(); | |
| 288 | }, [hoverIndex, positionTooltip]); | |
| 289 | ||
| 273 | 290 | const handleMove = useCallback( |
| 274 | 291 | (event: React.MouseEvent<HTMLDivElement>) => { |
| 275 | const bounds = plotRef.current?.getBoundingClientRect(); | |
| 276 | if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; | |
| 277 | const fraction = (event.clientX - bounds.left) / bounds.width; | |
| 292 | const plot = plotRef.current; | |
| 293 | if (plot === null || periods.length === 0) return; | |
| 294 | const bounds = plot.getBoundingClientRect(); | |
| 295 | if (bounds.width === 0) return; | |
| 296 | const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); | |
| 297 | const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); | |
| 298 | const fraction = localX / bounds.width; | |
| 278 | 299 | const index = Math.round(fraction * (periods.length - 1)); |
| 300 | hoverPositionRef.current = { x: localX, y: localY }; | |
| 301 | positionTooltip(); | |
| 279 | 302 | setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); |
| 280 | 303 | }, |
| 281 | [periods.length], | |
| 304 | [periods.length, positionTooltip], | |
| 282 | 305 | ); |
| 283 | 306 | |
| 284 | 307 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; |
| 285 | 308 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; |
| 286 | const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; | |
| 287 | 309 | const formatPeriod = (period: string) => |
| 288 | 310 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); |
| 289 | 311 | const formatTooltipPeriod = (period: string) => |
| ... | ... | @@ -311,7 +333,10 @@ export function UsageProviderChart({ |
| 311 | 333 | ref={plotRef} |
| 312 | 334 | className="relative h-56 flex-1" |
| 313 | 335 | onMouseMove={handleMove} |
| 314 | onMouseLeave={() => setHoverIndex(null)} | |
| 336 | onMouseLeave={() => { | |
| 337 | hoverPositionRef.current = null; | |
| 338 | setHoverIndex(null); | |
| 339 | }} | |
| 315 | 340 | > |
| 316 | 341 | <svg |
| 317 | 342 | className="h-full w-full" |
| ... | ... | @@ -337,7 +362,6 @@ export function UsageProviderChart({ |
| 337 | 362 | ); |
| 338 | 363 | })} |
| 339 | 364 | |
| 340 | {/* Fills first, then every stroke, so no series covers another's line. */} | |
| 341 | 365 | {paths.map(({ provider, area }) => ( |
| 342 | 366 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> |
| 343 | 367 | ))} |
| ... | ... | @@ -368,10 +392,11 @@ export function UsageProviderChart({ |
| 368 | 392 | |
| 369 | 393 | {hoveredPeriod === undefined ? null : ( |
| 370 | 394 | <div |
| 371 | className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs" | |
| 395 | ref={tooltipRef} | |
| 396 | className="pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 bg-background/65 px-2.5 py-2 text-xs shadow-lg backdrop-blur-xl backdrop-saturate-150" | |
| 372 | 397 | style={{ |
| 373 | left: `${hoverLeft}%`, | |
| 374 | transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)", | |
| 398 | left: "var(--usage-tooltip-left, 0px)", | |
| 399 | top: "var(--usage-tooltip-top, 0px)", | |
| 375 | 400 | }} |
| 376 | 401 | > |
| 377 | 402 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| ... | ... | @@ -418,21 +443,3 @@ export function UsageProviderChart({ |
| 418 | 443 | </div> |
| 419 | 444 | ); |
| 420 | 445 | } |
| 421 | ||
| 422 | export function UsageChartLegend() { | |
| 423 | return ( | |
| 424 | <div className="flex items-center gap-4"> | |
| 425 | {PROVIDER_ORDER.map((provider) => { | |
| 426 | // The marks carry the same fills as the bands, so they key the chart | |
| 427 | // just as a colour swatch would. | |
| 428 | const Mark = PROVIDER_MARK[provider]; | |
| 429 | return ( | |
| 430 | <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground"> | |
| 431 | <Mark className="size-3.5 shrink-0" aria-hidden /> | |
| 432 | {PROVIDER_LABEL[provider]} | |
| 433 | </span> | |
| 434 | ); | |
| 435 | })} | |
| 436 | </div> | |
| 437 | ); | |
| 438 | } |
apps/web/src/components/usage/usageProviders.ts+1-3| ... | ... | @@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; |
| 3 | 3 | import { ClaudeAI, type Icon, OpenAI } from "../Icons"; |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | * Series and table order. The chart layers both providers from a shared zero | |
| 7 | * baseline, so this only fixes the reading order of legends, tables and hover | |
| 8 | * rows; it does not decide which series sits above the other. | |
| 6 | * Stable provider reading order across summaries, tables, and hover rows. | |
| 9 | 7 | */ |
| 10 | 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; |
| 11 | 9 |
apps/web/src/contextMenuFallback.ts+92-6| ... | ... | @@ -4,6 +4,15 @@ const SVG_NS = "http://www.w3.org/2000/svg"; |
| 4 | 4 | |
| 5 | 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). |
| 6 | 6 | const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = { |
| 7 | "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], | |
| 8 | "circle-check": [ | |
| 9 | { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, | |
| 10 | { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, | |
| 11 | ], | |
| 12 | clock: [ | |
| 13 | { tag: "path", attrs: { d: "M12 6v6l4 2" } }, | |
| 14 | { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, | |
| 15 | ], | |
| 7 | 16 | pencil: [ |
| 8 | 17 | { |
| 9 | 18 | tag: "path", |
| ... | ... | @@ -17,6 +26,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 17 | 26 | { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, |
| 18 | 27 | { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } }, |
| 19 | 28 | ], |
| 29 | folder: [ | |
| 30 | { | |
| 31 | tag: "path", | |
| 32 | attrs: { | |
| 33 | d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", | |
| 34 | }, | |
| 35 | }, | |
| 36 | ], | |
| 37 | "git-branch": [ | |
| 38 | { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } }, | |
| 39 | { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } }, | |
| 40 | { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } }, | |
| 41 | { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } }, | |
| 42 | ], | |
| 43 | hash: [ | |
| 44 | { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } }, | |
| 45 | { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } }, | |
| 46 | { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } }, | |
| 47 | { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } }, | |
| 48 | ], | |
| 49 | "mail-open": [ | |
| 50 | { | |
| 51 | tag: "path", | |
| 52 | attrs: { | |
| 53 | d: "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z", | |
| 54 | }, | |
| 55 | }, | |
| 56 | { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } }, | |
| 57 | ], | |
| 58 | "message-square-plus": [ | |
| 59 | { | |
| 60 | tag: "path", | |
| 61 | attrs: { | |
| 62 | d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z", | |
| 63 | }, | |
| 64 | }, | |
| 65 | { tag: "path", attrs: { d: "M12 8v6" } }, | |
| 66 | { tag: "path", attrs: { d: "M9 11h6" } }, | |
| 67 | ], | |
| 68 | pin: [ | |
| 69 | { tag: "path", attrs: { d: "M12 17v5" } }, | |
| 70 | { | |
| 71 | tag: "path", | |
| 72 | attrs: { | |
| 73 | d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z", | |
| 74 | }, | |
| 75 | }, | |
| 76 | ], | |
| 77 | "pin-off": [ | |
| 78 | { tag: "path", attrs: { d: "M12 17v5" } }, | |
| 79 | { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } }, | |
| 80 | { tag: "path", attrs: { d: "m2 2 20 20" } }, | |
| 81 | { | |
| 82 | tag: "path", | |
| 83 | attrs: { | |
| 84 | d: "M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11", | |
| 85 | }, | |
| 86 | }, | |
| 87 | ], | |
| 88 | "refresh-cw": [ | |
| 89 | { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } }, | |
| 90 | { tag: "path", attrs: { d: "M21 3v5h-5" } }, | |
| 91 | { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } }, | |
| 92 | { tag: "path", attrs: { d: "M8 16H3v5" } }, | |
| 93 | ], | |
| 20 | 94 | "folder-tree": [ |
| 21 | 95 | { |
| 22 | 96 | tag: "path", |
| ... | ... | @@ -44,7 +118,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 44 | 118 | |
| 45 | 119 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { |
| 46 | 120 | const paths = ICON_PATHS[name]; |
| 47 | if (!paths) { | |
| 121 | if (!paths || typeof document.createElementNS !== "function") { | |
| 48 | 122 | return null; |
| 49 | 123 | } |
| 50 | 124 | const svg = document.createElementNS(SVG_NS, "svg"); |
| ... | ... | @@ -57,7 +131,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV |
| 57 | 131 | svg.setAttribute("stroke-linejoin", "round"); |
| 58 | 132 | svg.setAttribute( |
| 59 | 133 | "class", |
| 60 | tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground", | |
| 134 | tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground", | |
| 61 | 135 | ); |
| 62 | 136 | for (const node of paths) { |
| 63 | 137 | const child = document.createElementNS(SVG_NS, node.tag); |
| ... | ... | @@ -200,6 +274,16 @@ export function showContextMenuFallback<T extends string>( |
| 200 | 274 | "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; |
| 201 | 275 | |
| 202 | 276 | for (const item of entries) { |
| 277 | if (item.separatorBefore === true && inner.childElementCount > 0) { | |
| 278 | const separator = document.createElement("div"); | |
| 279 | separator.className = "my-1 h-px bg-border/70"; | |
| 280 | separator.style.cssText = | |
| 281 | "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; | |
| 282 | separator.dataset.contextMenuSeparator = "true"; | |
| 283 | separator.setAttribute("role", "separator"); | |
| 284 | inner.appendChild(separator); | |
| 285 | } | |
| 286 | ||
| 203 | 287 | if (item.header === true) { |
| 204 | 288 | const header = document.createElement("div"); |
| 205 | 289 | header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; |
| ... | ... | @@ -247,10 +331,12 @@ export function showContextMenuFallback<T extends string>( |
| 247 | 331 | button.appendChild(label); |
| 248 | 332 | |
| 249 | 333 | if (hasChildren) { |
| 250 | const chevron = document.createElement("span"); | |
| 251 | chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; | |
| 252 | chevron.textContent = ">"; | |
| 253 | button.appendChild(chevron); | |
| 334 | const chevron = createIconElement("chevron-right", "neutral"); | |
| 335 | if (chevron) { | |
| 336 | chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); | |
| 337 | chevron.dataset.contextMenuChevron = "true"; | |
| 338 | button.appendChild(chevron); | |
| 339 | } | |
| 254 | 340 | } |
| 255 | 341 | |
| 256 | 342 | if (!isDisabled) { |
apps/web/src/index.css+78-5| ... | ... | @@ -241,6 +241,22 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 241 | 241 | opacity: 1; |
| 242 | 242 | } |
| 243 | 243 | } |
| 244 | @keyframes live-activity-focus { | |
| 245 | 0% { | |
| 246 | transform: translateX(0); | |
| 247 | } | |
| 248 | 100% { | |
| 249 | transform: translateX(100%); | |
| 250 | } | |
| 251 | } | |
| 252 | @keyframes live-activity-focus-counter { | |
| 253 | 0% { | |
| 254 | transform: translateX(0); | |
| 255 | } | |
| 256 | 100% { | |
| 257 | transform: translateX(-100%); | |
| 258 | } | |
| 259 | } | |
| 244 | 260 | @keyframes status-ping { |
| 245 | 261 | /* Burst first (immediate feedback for click ripples), then hold |
| 246 | 262 | invisible for the rest of the cycle. Mirrors animate-ping's |
| ... | ... | @@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 431 | 447 | } |
| 432 | 448 | } |
| 433 | 449 | |
| 450 | @utility live-activity-focus { | |
| 451 | --live-activity-focus-width: 4.5rem; | |
| 452 | ||
| 453 | right: auto; | |
| 454 | left: calc(-1 * var(--live-activity-focus-width)); | |
| 455 | width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); | |
| 456 | -webkit-mask-image: linear-gradient( | |
| 457 | to right, | |
| 458 | transparent 0, | |
| 459 | rgb(0 0 0 / 12%) 0.675rem, | |
| 460 | rgb(0 0 0 / 55%) 1.575rem, | |
| 461 | black 2.25rem, | |
| 462 | rgb(0 0 0 / 55%) 2.925rem, | |
| 463 | rgb(0 0 0 / 12%) 3.825rem, | |
| 464 | transparent var(--live-activity-focus-width), | |
| 465 | transparent 100% | |
| 466 | ); | |
| 467 | -webkit-mask-repeat: no-repeat; | |
| 468 | mask-image: linear-gradient( | |
| 469 | to right, | |
| 470 | transparent 0, | |
| 471 | rgb(0 0 0 / 12%) 0.675rem, | |
| 472 | rgb(0 0 0 / 55%) 1.575rem, | |
| 473 | black 2.25rem, | |
| 474 | rgb(0 0 0 / 55%) 2.925rem, | |
| 475 | rgb(0 0 0 / 12%) 3.825rem, | |
| 476 | transparent var(--live-activity-focus-width), | |
| 477 | transparent 100% | |
| 478 | ); | |
| 479 | mask-repeat: no-repeat; | |
| 480 | animation: live-activity-focus 2.2s linear infinite; | |
| 481 | will-change: transform; | |
| 482 | ||
| 483 | @media (prefers-reduced-motion: reduce) { | |
| 484 | animation: none; | |
| 485 | opacity: 0; | |
| 486 | will-change: auto; | |
| 487 | } | |
| 488 | } | |
| 489 | ||
| 490 | @utility live-activity-focus-counter { | |
| 491 | width: 100%; | |
| 492 | animation: live-activity-focus-counter 2.2s linear infinite; | |
| 493 | will-change: transform; | |
| 494 | ||
| 495 | @media (prefers-reduced-motion: reduce) { | |
| 496 | animation: none; | |
| 497 | will-change: auto; | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | @utility live-activity-focus-aligned { | |
| 502 | width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); | |
| 503 | margin-left: var(--live-activity-focus-width); | |
| 504 | } | |
| 505 | ||
| 434 | 506 | @layer base { |
| 435 | 507 | :root { |
| 436 | 508 | /* Keep the original T3 Code artwork palettes as the defaults. Built-in |
| ... | ... | @@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { |
| 1349 | 1421 | |
| 1350 | 1422 | /* The panel layout toggles stay ghost: they render both inside the header and |
| 1351 | 1423 | in the titlebar strip, so filling them would make them change appearance as |
| 1352 | the panel opens. They only take the themed foreground; hover and pressed | |
| 1353 | keep the base ghost accent. The tooltip trigger's data-slot wins over the | |
| 1354 | toggle's when the trigger renders the toggle, so match both. */ | |
| 1424 | the panel opens. Their icons use the same themed foreground as the toolbar | |
| 1425 | action text; hover and pressed keep the base ghost accent. The tooltip | |
| 1426 | trigger's data-slot wins over the toggle's when it renders the toggle, so | |
| 1427 | match both. */ | |
| 1355 | 1428 | html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], |
| 1356 | 1429 | html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], |
| 1357 | 1430 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], |
| 1358 | 1431 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { |
| 1359 | --control-icon-color: var(--toolbar-foreground); | |
| 1360 | color: var(--toolbar-foreground); | |
| 1432 | --control-icon-color: var(--toolbar-control-foreground); | |
| 1433 | color: var(--toolbar-control-foreground); | |
| 1361 | 1434 | } |
| 1362 | 1435 | |
| 1363 | 1436 | html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, |
apps/web/src/lib/openPullRequestLink.ts+11| ... | ... | @@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu |
| 118 | 118 | return null; |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | /** The repository root behind a recognised change-request URL, without PR-specific state. */ | |
| 122 | export 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 | ||
| 121 | 132 | function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { |
| 122 | 133 | const repository = match?.[1]; |
| 123 | 134 | const number = Number(match?.[2]); |
apps/web/src/routes/-chatIndexTitlebar.test.ts+1-3| ... | ... | @@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => { |
| 15 | 15 | |
| 16 | 16 | const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); |
| 17 | 17 | |
| 18 | expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); | |
| 19 | expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); | |
| 20 | expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); | |
| 18 | expect(onboardingHeader).toContain('<WorkspacePageHeader className="border-b border-border">'); | |
| 21 | 19 | expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); |
| 22 | 20 | }); |
| 23 | 21 | }); |
apps/web/src/routes/_chat.index.tsx+3-9| ... | ... | @@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; |
| 8 | 8 | import { Button } from "../components/ui/button"; |
| 9 | 9 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; |
| 10 | 10 | import { SidebarInset } from "../components/ui/sidebar"; |
| 11 | import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; | |
| 11 | 12 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 12 | 13 | import { |
| 13 | 14 | useAllEnvironmentShellsBootstrapped, |
| ... | ... | @@ -17,8 +18,6 @@ import { |
| 17 | 18 | import { useEnvironments } from "../state/environments"; |
| 18 | 19 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 19 | 20 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 20 | import { cn } from "~/lib/utils"; | |
| 21 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 22 | 21 | |
| 23 | 22 | function ChatIndexRouteView() { |
| 24 | 23 | const { authGateState } = Route.useRouteContext(); |
| ... | ... | @@ -143,18 +142,13 @@ function HostedStaticOnboardingState() { |
| 143 | 142 | return ( |
| 144 | 143 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 145 | 144 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 146 | <header | |
| 147 | className={cn( | |
| 148 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | |
| 149 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 150 | )} | |
| 151 | > | |
| 145 | <WorkspacePageHeader className="border-b border-border"> | |
| 152 | 146 | <div className="flex items-center gap-2"> |
| 153 | 147 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| 154 | 148 | {APP_DISPLAY_NAME} |
| 155 | 149 | </span> |
| 156 | 150 | </div> |
| 157 | </header> | |
| 151 | </WorkspacePageHeader> | |
| 158 | 152 | |
| 159 | 153 | <Empty className="flex-1"> |
| 160 | 154 | <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5"> |
apps/web/src/routes/_chat.pull-requests.tsx+66-37| ... | ... | @@ -74,6 +74,12 @@ import { |
| 74 | 74 | WorkspaceBreadcrumbItem, |
| 75 | 75 | WorkspaceBreadcrumbSeparator, |
| 76 | 76 | } from "../components/WorkspaceBreadcrumb"; |
| 77 | import { | |
| 78 | WorkspacePageContainer, | |
| 79 | WorkspacePageHeader, | |
| 80 | WorkspacePageHeaderEdgeControl, | |
| 81 | } from "../components/WorkspacePageContainer"; | |
| 82 | import { isElectron } from "../env"; | |
| 77 | 83 | import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; |
| 78 | 84 | import { Button } from "../components/ui/button"; |
| 79 | 85 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; |
| ... | ... | @@ -99,7 +105,6 @@ import { |
| 99 | 105 | import { useAtomCommand } from "../state/use-atom-command"; |
| 100 | 106 | import { cn } from "~/lib/utils"; |
| 101 | 107 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 102 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 103 | 108 | |
| 104 | 109 | export interface PullRequestsSearch { |
| 105 | 110 | readonly involvement: PullRequestInvolvement; |
| ... | ... | @@ -1181,6 +1186,17 @@ function PullRequestsRouteView() { |
| 1181 | 1186 | : null, |
| 1182 | 1187 | [search.number, search.repository, selectedProject], |
| 1183 | 1188 | ); |
| 1189 | const linkedSelectionMatchesSurface = | |
| 1190 | linkedSelection !== null && | |
| 1191 | selectedPullRequestSurface !== null && | |
| 1192 | linkedSelection.environmentId === selectedPullRequestSurface.environmentId && | |
| 1193 | linkedSelection.projectId === selectedPullRequestSurface.projectId && | |
| 1194 | linkedSelection.repository === selectedPullRequestSurface.repository && | |
| 1195 | linkedSelection.number === selectedPullRequestSurface.number; | |
| 1196 | // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are | |
| 1197 | // history, though, not a current selection: without this check they leave the toggle looking | |
| 1198 | // available after the selected pull request has been cleared. | |
| 1199 | const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; | |
| 1184 | 1200 | useEffect(() => { |
| 1185 | 1201 | if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; |
| 1186 | 1202 | useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); |
| ... | ... | @@ -1294,9 +1310,10 @@ function PullRequestsRouteView() { |
| 1294 | 1310 | terminalAvailable={false} |
| 1295 | 1311 | terminalOpen={false} |
| 1296 | 1312 | terminalShortcutLabel={null} |
| 1297 | rightPanelAvailable={rightPanelState.surfaces.length > 0} | |
| 1313 | rightPanelAvailable={rightPanelAvailable} | |
| 1298 | 1314 | rightPanelOpen={rightPanelState.isOpen} |
| 1299 | 1315 | rightPanelShortcutLabel={null} |
| 1316 | rightPanelUnavailableLabel="Select a pull request first" | |
| 1300 | 1317 | liveAgentCount={0} |
| 1301 | 1318 | onToggleTerminal={() => undefined} |
| 1302 | 1319 | onToggleRightPanel={toggleRightPanel} |
| ... | ... | @@ -1603,7 +1620,6 @@ function PullRequestsRouteView() { |
| 1603 | 1620 | reviewingQuery.refresh(); |
| 1604 | 1621 | }} |
| 1605 | 1622 | onStateChange={handlePullRequestTabStatusChange} |
| 1606 | chromeVariant="collapse" | |
| 1607 | 1623 | /> |
| 1608 | 1624 | </RightPanelTabs> |
| 1609 | 1625 | ) : null} |
| ... | ... | @@ -1827,18 +1843,10 @@ function PullRequestsColumn({ |
| 1827 | 1843 | // Painted flat like the chat column: the inset underneath carries the chrome grain, and a |
| 1828 | 1844 | // content surface that lets it show reads as a different background than every thread. |
| 1829 | 1845 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> |
| 1830 | <header | |
| 1831 | className={cn( | |
| 1832 | "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1.5 px-3 sm:px-5", | |
| 1833 | // A closed right panel leaves this column full-width, so its header runs | |
| 1834 | // underneath the native window controls on Windows; reserve the inset the | |
| 1835 | // way Settings and the chat view do. While the panel is open the column | |
| 1836 | // ends at the panel's left edge and the absolute controls strip (already | |
| 1837 | // WCO-aware) owns the top-right corner. | |
| 1838 | !rightPanelOpen && "wco:pr-[var(--workspace-native-controls-inset)]", | |
| 1839 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 1840 | )} | |
| 1841 | > | |
| 1846 | {/* A closed right panel leaves this column full-width, so the shared header | |
| 1847 | reserves native window controls. While the panel is open, the column ends | |
| 1848 | at the panel and the absolute controls strip owns the top-right corner. */} | |
| 1849 | <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}> | |
| 1842 | 1850 | {condensed ? ( |
| 1843 | 1851 | <WorkspaceBreadcrumb ariaLabel="Pull request scope"> |
| 1844 | 1852 | {/* The page name remains the foreground anchor in both states; the live filters are |
| ... | ... | @@ -1880,27 +1888,24 @@ function PullRequestsColumn({ |
| 1880 | 1888 | )} |
| 1881 | 1889 | <div className="min-w-0 flex-1" /> |
| 1882 | 1890 | {condensed ? ( |
| 1883 | <ExpandableSearch | |
| 1884 | searchInput={searchInput} | |
| 1885 | searchValue={searchValue} | |
| 1886 | open={searchOpen} | |
| 1887 | onOpenChange={setSearchOpen} | |
| 1888 | focusToken={searchFocusToken} | |
| 1889 | onFocusWithin={(focused) => { | |
| 1890 | topbarSearchFocusedRef.current = focused; | |
| 1891 | }} | |
| 1892 | /> | |
| 1891 | <div className="flex shrink-0 items-center gap-1.5"> | |
| 1892 | <ExpandableSearch | |
| 1893 | searchInput={searchInput} | |
| 1894 | searchValue={searchValue} | |
| 1895 | open={searchOpen} | |
| 1896 | onOpenChange={setSearchOpen} | |
| 1897 | focusToken={searchFocusToken} | |
| 1898 | onFocusWithin={(focused) => { | |
| 1899 | topbarSearchFocusedRef.current = focused; | |
| 1900 | }} | |
| 1901 | /> | |
| 1902 | <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} /> | |
| 1903 | </div> | |
| 1904 | ) : null} | |
| 1905 | {rightPanelControl ? ( | |
| 1906 | <WorkspacePageHeaderEdgeControl>{rightPanelControl}</WorkspacePageHeaderEdgeControl> | |
| 1893 | 1907 | ) : null} |
| 1894 | <Button | |
| 1895 | size="icon-sm" | |
| 1896 | variant="ghost" | |
| 1897 | aria-label="Refresh pull requests" | |
| 1898 | onClick={onRefresh} | |
| 1899 | > | |
| 1900 | <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} /> | |
| 1901 | </Button> | |
| 1902 | {rightPanelControl} | |
| 1903 | </header> | |
| 1908 | </WorkspacePageHeader> | |
| 1904 | 1909 | |
| 1905 | 1910 | <div |
| 1906 | 1911 | ref={scrollRef} |
| ... | ... | @@ -1909,19 +1914,43 @@ function PullRequestsColumn({ |
| 1909 | 1914 | {/* The top padding is the fade band's own height (1.5rem here), the same pairing the |
| 1910 | 1915 | settings page makes: at rest the controls sit fully below the mask, and only |
| 1911 | 1916 | content actually passing under the chrome fades. */} |
| 1912 | <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12"> | |
| 1917 | <WorkspacePageContainer className="gap-4"> | |
| 1913 | 1918 | <div className="flex flex-col gap-3"> |
| 1914 | 1919 | <div ref={inFlowSearchRef} className="flex items-center gap-2"> |
| 1915 | 1920 | {searchInput} |
| 1916 | 1921 | {filtersMenu} |
| 1922 | {!condensed ? ( | |
| 1923 | <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} /> | |
| 1924 | ) : null} | |
| 1917 | 1925 | </div> |
| 1918 | 1926 | {/* Scrolled past this marker, the controls are gone and the title takes over. */} |
| 1919 | 1927 | <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" /> |
| 1920 | 1928 | </div> |
| 1921 | 1929 | |
| 1922 | 1930 | {listBody} |
| 1923 | </div> | |
| 1931 | </WorkspacePageContainer> | |
| 1924 | 1932 | </div> |
| 1925 | 1933 | </div> |
| 1926 | 1934 | ); |
| 1927 | 1935 | } |
| 1936 | ||
| 1937 | function PullRequestRefreshControl({ | |
| 1938 | compact = false, | |
| 1939 | refreshing, | |
| 1940 | onRefresh, | |
| 1941 | }: { | |
| 1942 | compact?: boolean; | |
| 1943 | refreshing: boolean; | |
| 1944 | onRefresh: () => void; | |
| 1945 | }) { | |
| 1946 | return ( | |
| 1947 | <Button | |
| 1948 | size={compact ? "icon-sm" : "icon"} | |
| 1949 | variant={compact ? "ghost" : "outline"} | |
| 1950 | aria-label="Refresh pull requests" | |
| 1951 | onClick={onRefresh} | |
| 1952 | > | |
| 1953 | <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} /> | |
| 1954 | </Button> | |
| 1955 | ); | |
| 1956 | } |
apps/web/src/routes/settings.tsx+10-36| ... | ... | @@ -13,9 +13,8 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 13 | 13 | import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; |
| 14 | 14 | import { Button } from "../components/ui/button"; |
| 15 | 15 | import { SidebarInset } from "../components/ui/sidebar"; |
| 16 | import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; | |
| 16 | 17 | import { isElectron } from "../env"; |
| 17 | import { cn } from "~/lib/utils"; | |
| 18 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 19 | 18 | |
| 20 | 19 | function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { |
| 21 | 20 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); |
| ... | ... | @@ -72,41 +71,16 @@ function SettingsContentLayout() { |
| 72 | 71 | return ( |
| 73 | 72 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 74 | 73 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 75 | {!isElectron && ( | |
| 76 | <header | |
| 77 | className={cn( | |
| 78 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | |
| 79 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 80 | )} | |
| 81 | > | |
| 82 | <div className="flex w-full items-center gap-2"> | |
| 83 | <SettingsBreadcrumb pathname={location.pathname} /> | |
| 84 | {showRestoreDefaults ? ( | |
| 85 | <div className="ms-auto flex items-center gap-2"> | |
| 86 | <RestoreDefaultsButton onRestored={handleRestored} /> | |
| 87 | </div> | |
| 88 | ) : null} | |
| 89 | </div> | |
| 90 | </header> | |
| 91 | )} | |
| 92 | ||
| 93 | {isElectron && ( | |
| 94 | <div | |
| 95 | className={cn( | |
| 96 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | |
| 97 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 98 | )} | |
| 99 | > | |
| 100 | <div className="flex w-full items-center gap-2"> | |
| 101 | <SettingsBreadcrumb pathname={location.pathname} /> | |
| 102 | {showRestoreDefaults ? ( | |
| 103 | <div className="ms-auto flex items-center gap-2"> | |
| 104 | <RestoreDefaultsButton onRestored={handleRestored} /> | |
| 105 | </div> | |
| 106 | ) : null} | |
| 107 | </div> | |
| 74 | <WorkspacePageHeader electron={isElectron}> | |
| 75 | <div className="flex w-full items-center gap-3"> | |
| 76 | <SettingsBreadcrumb pathname={location.pathname} /> | |
| 77 | {showRestoreDefaults ? ( | |
| 78 | <div className="ms-auto flex items-center gap-2"> | |
| 79 | <RestoreDefaultsButton onRestored={handleRestored} /> | |
| 80 | </div> | |
| 81 | ) : null} | |
| 108 | 82 | </div> |
| 109 | )} | |
| 83 | </WorkspacePageHeader> | |
| 110 | 84 | |
| 111 | 85 | <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col"> |
| 112 | 86 | <Outlet /> |
apps/web/src/session-logic.test.ts+127-6| ... | ... | @@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => { |
| 722 | 722 | }); |
| 723 | 723 | |
| 724 | 724 | describe("deriveWorkLogEntries", () => { |
| 725 | it("omits tool started entries and keeps completed entries", () => { | |
| 725 | it("shows a command from its start event while it is still running", () => { | |
| 726 | 726 | const activities: OrchestrationThreadActivity[] = [ |
| 727 | makeActivity({ | |
| 728 | id: "tool-start", | |
| 729 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 730 | summary: "Command run started", | |
| 731 | kind: "tool.started", | |
| 732 | payload: { | |
| 733 | itemType: "command_execution", | |
| 734 | toolCallId: "call-1", | |
| 735 | status: "inProgress", | |
| 736 | title: "Command run", | |
| 737 | detail: "Bash: vp test run", | |
| 738 | data: { | |
| 739 | toolName: "Bash", | |
| 740 | input: { command: "vp test run" }, | |
| 741 | }, | |
| 742 | }, | |
| 743 | }), | |
| 744 | ]; | |
| 745 | ||
| 746 | const [entry] = deriveWorkLogEntries(activities); | |
| 747 | expect(entry).toMatchObject({ | |
| 748 | id: "tool-start", | |
| 749 | command: "vp test run", | |
| 750 | toolCallId: "call-1", | |
| 751 | toolLifecycleStatus: "inProgress", | |
| 752 | sourceActivityKind: "tool.started", | |
| 753 | }); | |
| 754 | }); | |
| 755 | ||
| 756 | it("retains the start command when the matching completion omits it", () => { | |
| 757 | const activities: OrchestrationThreadActivity[] = [ | |
| 758 | makeActivity({ | |
| 759 | id: "tool-start", | |
| 760 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 761 | summary: "Command run started", | |
| 762 | kind: "tool.started", | |
| 763 | payload: { | |
| 764 | itemType: "command_execution", | |
| 765 | toolCallId: "call-1", | |
| 766 | status: "inProgress", | |
| 767 | title: "Command run", | |
| 768 | data: { input: { command: "vp test run" } }, | |
| 769 | }, | |
| 770 | }), | |
| 771 | makeActivity({ | |
| 772 | id: "other-tool-start", | |
| 773 | createdAt: "2026-02-23T00:00:02.500Z", | |
| 774 | summary: "Other command started", | |
| 775 | kind: "tool.started", | |
| 776 | payload: { | |
| 777 | itemType: "command_execution", | |
| 778 | toolCallId: "call-2", | |
| 779 | status: "inProgress", | |
| 780 | title: "Other command", | |
| 781 | data: { input: { command: "vp lint" } }, | |
| 782 | }, | |
| 783 | }), | |
| 727 | 784 | makeActivity({ |
| 728 | 785 | id: "tool-complete", |
| 729 | 786 | createdAt: "2026-02-23T00:00:03.000Z", |
| 730 | summary: "Tool call complete", | |
| 787 | summary: "Command run", | |
| 731 | 788 | kind: "tool.completed", |
| 789 | payload: { | |
| 790 | itemType: "command_execution", | |
| 791 | toolCallId: "call-1", | |
| 792 | status: "completed", | |
| 793 | title: "Command run", | |
| 794 | }, | |
| 732 | 795 | }), |
| 733 | 796 | makeActivity({ |
| 734 | id: "tool-start", | |
| 797 | id: "other-tool-complete", | |
| 798 | createdAt: "2026-02-23T00:00:04.000Z", | |
| 799 | summary: "Other command", | |
| 800 | kind: "tool.completed", | |
| 801 | payload: { | |
| 802 | itemType: "command_execution", | |
| 803 | toolCallId: "call-2", | |
| 804 | status: "completed", | |
| 805 | title: "Other command", | |
| 806 | }, | |
| 807 | }), | |
| 808 | ]; | |
| 809 | ||
| 810 | const entries = deriveWorkLogEntries(activities); | |
| 811 | expect(entries).toHaveLength(2); | |
| 812 | expect(entries[0]).toMatchObject({ | |
| 813 | id: "tool-complete", | |
| 814 | command: "vp test run", | |
| 815 | toolCallId: "call-1", | |
| 816 | toolLifecycleStatus: "completed", | |
| 817 | sourceActivityKind: "tool.completed", | |
| 818 | }); | |
| 819 | expect(entries[1]).toMatchObject({ | |
| 820 | id: "other-tool-complete", | |
| 821 | command: "vp lint", | |
| 822 | toolCallId: "call-2", | |
| 823 | toolLifecycleStatus: "completed", | |
| 824 | sourceActivityKind: "tool.completed", | |
| 825 | }); | |
| 826 | }); | |
| 827 | ||
| 828 | it("does not merge non-adjacent tool starts without stable call ids", () => { | |
| 829 | const activities: OrchestrationThreadActivity[] = [ | |
| 830 | makeActivity({ | |
| 831 | id: "unkeyed-start-1", | |
| 832 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 833 | summary: "Search started", | |
| 834 | kind: "tool.started", | |
| 835 | payload: { itemType: "search", title: "Search", status: "inProgress" }, | |
| 836 | }), | |
| 837 | makeActivity({ | |
| 838 | id: "keyed-start", | |
| 735 | 839 | createdAt: "2026-02-23T00:00:02.000Z", |
| 736 | summary: "Tool call", | |
| 840 | summary: "Command started", | |
| 841 | kind: "tool.started", | |
| 842 | payload: { | |
| 843 | itemType: "command_execution", | |
| 844 | toolCallId: "call-between", | |
| 845 | title: "Command", | |
| 846 | status: "inProgress", | |
| 847 | }, | |
| 848 | }), | |
| 849 | makeActivity({ | |
| 850 | id: "unkeyed-start-2", | |
| 851 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 852 | summary: "Search started", | |
| 737 | 853 | kind: "tool.started", |
| 854 | payload: { itemType: "search", title: "Search", status: "inProgress" }, | |
| 738 | 855 | }), |
| 739 | 856 | ]; |
| 740 | 857 | |
| 741 | const entries = deriveWorkLogEntries(activities); | |
| 742 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | |
| 858 | expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ | |
| 859 | "unkeyed-start-1", | |
| 860 | "keyed-start", | |
| 861 | "unkeyed-start-2", | |
| 862 | ]); | |
| 743 | 863 | }); |
| 744 | 864 | |
| 745 | 865 | it("omits task.started but shows task.progress and task.completed", () => { |
| ... | ... | @@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => { |
| 1239 | 1359 | expect(entries).toHaveLength(1); |
| 1240 | 1360 | expect(entries[0]).toMatchObject({ |
| 1241 | 1361 | id: "grep-complete", |
| 1362 | toolCallId: "tool-grep-1", | |
| 1242 | 1363 | toolTitle: "grep", |
| 1243 | 1364 | detail: "19 files", |
| 1244 | 1365 | itemType: "web_search", |
apps/web/src/session-logic.ts+98-7| ... | ... | @@ -65,6 +65,8 @@ export interface WorkLogEntry { |
| 65 | 65 | id: string; |
| 66 | 66 | createdAt: string; |
| 67 | 67 | turnId?: TurnId | null; |
| 68 | /** Stable provider identity across in-progress and completed lifecycle updates. */ | |
| 69 | toolCallId?: string; | |
| 68 | 70 | label: string; |
| 69 | 71 | detail?: string; |
| 70 | 72 | command?: string; |
| ... | ... | @@ -748,7 +750,6 @@ export function deriveWorkLogEntries( |
| 748 | 750 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 749 | 751 | const entries: DerivedWorkLogEntry[] = []; |
| 750 | 752 | for (const activity of ordered) { |
| 751 | if (activity.kind === "tool.started") continue; | |
| 752 | 753 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| 753 | 754 | // which is the batch key (completions of background subagents arrive |
| 754 | 755 | // under later synthetic turns and must not start new batches). They |
| ... | ... | @@ -757,8 +758,13 @@ export function deriveWorkLogEntries( |
| 757 | 758 | if (activity.kind === "task.updated") continue; |
| 758 | 759 | if (activity.kind === "tool.progress") continue; |
| 759 | 760 | if (activity.kind === "context-window.updated") continue; |
| 761 | // Plan updates have a dedicated task row. Keeping the raw activity here | |
| 762 | // duplicates it as a legacy "Work Log / Plan updated" row when history | |
| 763 | // is expanded. | |
| 764 | if (activity.kind === "turn.plan.updated") continue; | |
| 760 | 765 | if (activity.summary === "Checkpoint captured") continue; |
| 761 | 766 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 767 | if (isCodexTerminalInteractionActivity(activity)) continue; | |
| 762 | 768 | if (isAgentInternalActivity(activity)) continue; |
| 763 | 769 | entries.push(toDerivedWorkLogEntry(activity)); |
| 764 | 770 | } |
| ... | ... | @@ -769,7 +775,11 @@ export function deriveWorkLogEntries( |
| 769 | 775 | } |
| 770 | 776 | |
| 771 | 777 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { |
| 772 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { | |
| 778 | if ( | |
| 779 | activity.kind !== "tool.started" && | |
| 780 | activity.kind !== "tool.updated" && | |
| 781 | activity.kind !== "tool.completed" | |
| 782 | ) { | |
| 773 | 783 | return false; |
| 774 | 784 | } |
| 775 | 785 | |
| ... | ... | @@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool |
| 780 | 790 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); |
| 781 | 791 | } |
| 782 | 792 | |
| 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 | */ | |
| 799 | function 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 | ||
| 783 | 815 | function extractWorkLogToolLifecycleStatus( |
| 784 | 816 | payload: Record<string, unknown> | null, |
| 785 | 817 | ): WorkLogToolLifecycleStatus | undefined { |
| ... | ... | @@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 878 | 910 | entry.toolCallId = toolCallId; |
| 879 | 911 | } |
| 880 | 912 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); |
| 913 | if (!toolLifecycleStatus && activity.kind === "tool.started") { | |
| 914 | toolLifecycleStatus = "inProgress"; | |
| 915 | } | |
| 881 | 916 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { |
| 882 | 917 | toolLifecycleStatus = "completed"; |
| 883 | 918 | } |
| ... | ... | @@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { |
| 933 | 968 | return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; |
| 934 | 969 | } |
| 935 | 970 | |
| 971 | function 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 | ||
| 936 | 982 | function collapseDerivedWorkLogEntries( |
| 937 | 983 | entries: ReadonlyArray<DerivedWorkLogEntry>, |
| 938 | 984 | ): DerivedWorkLogEntry[] { |
| ... | ... | @@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries( |
| 949 | 995 | // own turn splintered one batch into a stream of "Kicked off N subagents" |
| 950 | 996 | // rows (live-test finding, thread 7ac7ef05). |
| 951 | 997 | const groupKeyByTaskId = new Map<string, string>(); |
| 998 | const toolLifecycleRowIndex = new Map<string, number>(); | |
| 952 | 999 | for (const entry of entries) { |
| 953 | 1000 | const isTaskRow = |
| 954 | 1001 | entry.taskId !== undefined && |
| ... | ... | @@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries( |
| 993 | 1040 | }); |
| 994 | 1041 | continue; |
| 995 | 1042 | } |
| 1043 | const lifecycleKey = toolLifecycleCollapseMapKey(entry); | |
| 1044 | if (lifecycleKey !== undefined) { | |
| 1045 | const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); | |
| 1046 | if (matchingLifecycleIndex !== undefined) { | |
| 1047 | const matchingEntry = collapsed[matchingLifecycleIndex]; | |
| 1048 | if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { | |
| 1049 | toolLifecycleRowIndex.delete(lifecycleKey); | |
| 1050 | const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); | |
| 1051 | collapsed[matchingLifecycleIndex] = merged; | |
| 1052 | if (merged.activityKind !== "tool.completed") { | |
| 1053 | toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); | |
| 1054 | } | |
| 1055 | continue; | |
| 1056 | } | |
| 1057 | toolLifecycleRowIndex.delete(lifecycleKey); | |
| 1058 | } | |
| 1059 | } | |
| 996 | 1060 | const previous = collapsed.at(-1); |
| 997 | 1061 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { |
| 998 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | |
| 1062 | const previousIndex = collapsed.length - 1; | |
| 1063 | const previousKey = toolLifecycleCollapseMapKey(previous); | |
| 1064 | if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); | |
| 1065 | const merged = mergeDerivedWorkLogEntries(previous, entry); | |
| 1066 | collapsed[previousIndex] = merged; | |
| 1067 | const mergedKey = toolLifecycleCollapseMapKey(merged); | |
| 1068 | if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { | |
| 1069 | toolLifecycleRowIndex.set(mergedKey, previousIndex); | |
| 1070 | } | |
| 999 | 1071 | continue; |
| 1000 | 1072 | } |
| 1001 | 1073 | collapsed.push(entry); |
| 1074 | if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { | |
| 1075 | toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); | |
| 1076 | } | |
| 1002 | 1077 | } |
| 1003 | 1078 | return collapsed; |
| 1004 | 1079 | } |
| ... | ... | @@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries( |
| 1007 | 1082 | previous: DerivedWorkLogEntry, |
| 1008 | 1083 | next: DerivedWorkLogEntry, |
| 1009 | 1084 | ): boolean { |
| 1010 | if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { | |
| 1085 | if ( | |
| 1086 | previous.activityKind !== "tool.started" && | |
| 1087 | previous.activityKind !== "tool.updated" && | |
| 1088 | previous.activityKind !== "tool.completed" | |
| 1089 | ) { | |
| 1011 | 1090 | return false; |
| 1012 | 1091 | } |
| 1013 | if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { | |
| 1092 | if ( | |
| 1093 | next.activityKind !== "tool.started" && | |
| 1094 | next.activityKind !== "tool.updated" && | |
| 1095 | next.activityKind !== "tool.completed" | |
| 1096 | ) { | |
| 1014 | 1097 | return false; |
| 1015 | 1098 | } |
| 1016 | 1099 | if (previous.activityKind === "tool.completed") { |
| ... | ... | @@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un |
| 1080 | 1163 | ) { |
| 1081 | 1164 | return `task${entry.taskId}`; |
| 1082 | 1165 | } |
| 1083 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { | |
| 1166 | if ( | |
| 1167 | entry.activityKind !== "tool.started" && | |
| 1168 | entry.activityKind !== "tool.updated" && | |
| 1169 | entry.activityKind !== "tool.completed" | |
| 1170 | ) { | |
| 1084 | 1171 | return undefined; |
| 1085 | 1172 | } |
| 1086 | 1173 | if (entry.toolCallId) { |
| ... | ... | @@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): { |
| 1283 | 1370 | const item = asRecord(data?.item); |
| 1284 | 1371 | const itemResult = asRecord(item?.result); |
| 1285 | 1372 | const itemInput = asRecord(item?.input); |
| 1373 | const dataInput = asRecord(data?.input); | |
| 1374 | const stateInput = asRecord(asRecord(data?.state)?.input); | |
| 1286 | 1375 | const itemType = asTrimmedString(payload?.itemType); |
| 1287 | 1376 | const detail = asTrimmedString(payload?.detail); |
| 1288 | 1377 | const candidates: unknown[] = [ |
| ... | ... | @@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record<string, unknown> | null): { |
| 1290 | 1379 | itemInput?.command, |
| 1291 | 1380 | itemResult?.command, |
| 1292 | 1381 | data?.command, |
| 1382 | dataInput?.command, | |
| 1383 | stateInput?.command, | |
| 1293 | 1384 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, |
| 1294 | 1385 | ]; |
| 1295 | 1386 | |
| ... | ... | @@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul |
| 1316 | 1407 | |
| 1317 | 1408 | function extractToolCallId(payload: Record<string, unknown> | null): string | null { |
| 1318 | 1409 | const data = asRecord(payload?.data); |
| 1319 | return asTrimmedString(data?.toolCallId); | |
| 1410 | return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); | |
| 1320 | 1411 | } |
| 1321 | 1412 | |
| 1322 | 1413 | function normalizeInlinePreview(value: string): string { |
apps/web/src/terminalUiStateStore.test.ts+8| ... | ... | @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { |
| 18 | 18 | useTerminalUiStateStore.persist.clearStorage(); |
| 19 | 19 | useTerminalUiStateStore.setState({ |
| 20 | 20 | terminalUiStateByThreadKey: {}, |
| 21 | terminalCustomLabelsByThreadKey: {}, | |
| 21 | 22 | suppressedTerminalIdsByThreadKey: {}, |
| 22 | 23 | }); |
| 23 | 24 | }); |
| ... | ... | @@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => { |
| 248 | 249 | it("reconciles terminal ids from an external ordered list", () => { |
| 249 | 250 | const store = useTerminalUiStateStore.getState(); |
| 250 | 251 | store.setTerminalOpen(THREAD_REF, true); |
| 252 | store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); | |
| 253 | store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); | |
| 251 | 254 | store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); |
| 252 | 255 | |
| 253 | 256 | const terminalUiState = selectThreadTerminalUiState( |
| ... | ... | @@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => { |
| 260 | 263 | { id: "group-term-a", terminalIds: ["term-a"] }, |
| 261 | 264 | { id: "group-term-b", terminalIds: ["term-b"] }, |
| 262 | 265 | ]); |
| 266 | expect( | |
| 267 | useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ | |
| 268 | scopedThreadKey(THREAD_REF) | |
| 269 | ], | |
| 270 | ).toEqual({ "term-a": "API server" }); | |
| 263 | 271 | }); |
| 264 | 272 | |
| 265 | 273 | it("does not import a closed panel terminal from stale metadata", () => { |
apps/web/src/terminalUiStateStore.ts+154-16| ... | ... | @@ -32,8 +32,11 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 32 | 32 | interface PersistedTerminalUiStateStoreState { |
| 33 | 33 | terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 34 | 34 | terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 35 | terminalCustomLabelsByThreadKey?: Record<string, Record<string, string>>; | |
| 35 | 36 | } |
| 36 | 37 | |
| 38 | const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({}); | |
| 39 | ||
| 37 | 40 | export function migratePersistedTerminalUiStateStoreState( |
| 38 | 41 | persistedState: unknown, |
| 39 | 42 | _version: number, |
| ... | ... | @@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState( |
| 50 | 53 | parseScopedThreadKey(threadKey), |
| 51 | 54 | ), |
| 52 | 55 | ); |
| 56 | const terminalCustomLabelsByThreadKey = Object.fromEntries( | |
| 57 | Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( | |
| 58 | ([threadKey, labels]) => { | |
| 59 | if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; | |
| 60 | const normalizedLabels = Object.fromEntries( | |
| 61 | Object.entries(labels).flatMap(([terminalId, label]) => { | |
| 62 | const normalizedTerminalId = terminalId.trim(); | |
| 63 | const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; | |
| 64 | return normalizedTerminalId && normalizedLabel | |
| 65 | ? [[normalizedTerminalId, normalizedLabel] as const] | |
| 66 | : []; | |
| 67 | }), | |
| 68 | ); | |
| 69 | return Object.keys(normalizedLabels).length > 0 | |
| 70 | ? [[threadKey, normalizedLabels] as const] | |
| 71 | : []; | |
| 72 | }, | |
| 73 | ), | |
| 74 | ); | |
| 53 | 75 | |
| 54 | return { terminalUiStateByThreadKey }; | |
| 76 | return { | |
| 77 | terminalUiStateByThreadKey, | |
| 78 | ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 | |
| 79 | ? { terminalCustomLabelsByThreadKey } | |
| 80 | : {}), | |
| 81 | }; | |
| 55 | 82 | } |
| 56 | 83 | |
| 57 | 84 | function createTerminalUiStateStorage() { |
| ... | ... | @@ -489,6 +516,18 @@ export function selectThreadTerminalUiState( |
| 489 | 516 | ); |
| 490 | 517 | } |
| 491 | 518 | |
| 519 | export 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 | ||
| 492 | 531 | function updateTerminalUiStateByThreadKey( |
| 493 | 532 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 494 | 533 | threadRef: ScopedThreadRef, |
| ... | ... | @@ -562,6 +601,7 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st |
| 562 | 601 | |
| 563 | 602 | interface TerminalUiStateStoreState { |
| 564 | 603 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; |
| 604 | terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>; | |
| 565 | 605 | /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ |
| 566 | 606 | suppressedTerminalIdsByThreadKey: Record<string, string[]>; |
| 567 | 607 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| ... | ... | @@ -575,6 +615,11 @@ interface TerminalUiStateStoreState { |
| 575 | 615 | options?: { open?: boolean; active?: boolean }, |
| 576 | 616 | ) => void; |
| 577 | 617 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 618 | setTerminalCustomLabel: ( | |
| 619 | threadRef: ScopedThreadRef, | |
| 620 | terminalId: string, | |
| 621 | label: string | null, | |
| 622 | ) => void; | |
| 578 | 623 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 579 | 624 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; |
| 580 | 625 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| ... | ... | @@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 591 | 636 | state: ThreadTerminalUiState, |
| 592 | 637 | suppressedTerminalIds: readonly string[], |
| 593 | 638 | ) => ThreadTerminalUiState, |
| 594 | suppression?: { terminalId: string; suppressed: boolean }, | |
| 639 | suppression?: { | |
| 640 | terminalId: string; | |
| 641 | suppressed: boolean; | |
| 642 | clearCustomLabel?: boolean; | |
| 643 | }, | |
| 644 | pruneCustomLabels = false, | |
| 595 | 645 | ) => { |
| 596 | 646 | set((state) => { |
| 597 | 647 | const threadKey = terminalThreadKey(threadRef); |
| ... | ... | @@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 609 | 659 | suppression.suppressed, |
| 610 | 660 | ) |
| 611 | 661 | : state.suppressedTerminalIdsByThreadKey; |
| 662 | const terminalIdToClear = suppression?.clearCustomLabel | |
| 663 | ? suppression.terminalId.trim() | |
| 664 | : ""; | |
| 665 | const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; | |
| 666 | let nextTerminalCustomLabelsByThreadKey = | |
| 667 | terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined | |
| 668 | ? Object.keys(currentLabels).length === 1 | |
| 669 | ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) | |
| 670 | : { | |
| 671 | ...state.terminalCustomLabelsByThreadKey, | |
| 672 | [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), | |
| 673 | } | |
| 674 | : state.terminalCustomLabelsByThreadKey; | |
| 675 | if (pruneCustomLabels) { | |
| 676 | const survivingIds = new Set( | |
| 677 | selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, | |
| 678 | ); | |
| 679 | const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; | |
| 680 | const survivingLabels = Object.fromEntries( | |
| 681 | Object.entries(labelsForThread).filter(([terminalId]) => | |
| 682 | survivingIds.has(terminalId), | |
| 683 | ), | |
| 684 | ); | |
| 685 | if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { | |
| 686 | nextTerminalCustomLabelsByThreadKey = | |
| 687 | Object.keys(survivingLabels).length > 0 | |
| 688 | ? { | |
| 689 | ...nextTerminalCustomLabelsByThreadKey, | |
| 690 | [threadKey]: survivingLabels, | |
| 691 | } | |
| 692 | : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); | |
| 693 | } | |
| 694 | } | |
| 612 | 695 | if ( |
| 613 | 696 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 614 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey | |
| 697 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && | |
| 698 | nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey | |
| 615 | 699 | ) { |
| 616 | 700 | return state; |
| 617 | 701 | } |
| 618 | 702 | return { |
| 619 | 703 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 620 | 704 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 705 | terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, | |
| 621 | 706 | }; |
| 622 | 707 | }); |
| 623 | 708 | }; |
| 624 | 709 | |
| 625 | 710 | return { |
| 626 | 711 | terminalUiStateByThreadKey: {}, |
| 712 | terminalCustomLabelsByThreadKey: {}, | |
| 627 | 713 | suppressedTerminalIdsByThreadKey: {}, |
| 628 | 714 | setTerminalOpen: (threadRef, open) => { |
| 629 | 715 | const terminalState = selectThreadTerminalUiState( |
| ... | ... | @@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 682 | 768 | ), |
| 683 | 769 | setActiveTerminal: (threadRef, terminalId) => |
| 684 | 770 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 771 | setTerminalCustomLabel: (threadRef, terminalId, label) => | |
| 772 | set((state) => { | |
| 773 | const normalizedTerminalId = terminalId.trim(); | |
| 774 | if (normalizedTerminalId.length === 0) return state; | |
| 775 | const threadKey = terminalThreadKey(threadRef); | |
| 776 | const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; | |
| 777 | const normalizedLabel = label?.trim().slice(0, 80) ?? ""; | |
| 778 | if (normalizedLabel.length > 0) { | |
| 779 | if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; | |
| 780 | return { | |
| 781 | terminalCustomLabelsByThreadKey: { | |
| 782 | ...state.terminalCustomLabelsByThreadKey, | |
| 783 | [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, | |
| 784 | }, | |
| 785 | }; | |
| 786 | } | |
| 787 | if (currentLabels[normalizedTerminalId] === undefined) return state; | |
| 788 | const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; | |
| 789 | return { | |
| 790 | terminalCustomLabelsByThreadKey: | |
| 791 | Object.keys(remainingLabels).length > 0 | |
| 792 | ? { | |
| 793 | ...state.terminalCustomLabelsByThreadKey, | |
| 794 | [threadKey]: remainingLabels, | |
| 795 | } | |
| 796 | : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), | |
| 797 | }; | |
| 798 | }), | |
| 685 | 799 | closeTerminal: (threadRef, terminalId) => |
| 686 | 800 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { |
| 687 | 801 | terminalId, |
| 688 | 802 | suppressed: true, |
| 803 | clearCustomLabel: true, | |
| 689 | 804 | }), |
| 690 | 805 | reconcileTerminalIds: (threadRef, nextIds) => |
| 691 | updateTerminal(threadRef, (state, suppressedTerminalIds) => { | |
| 692 | if (suppressedTerminalIds.length === 0) { | |
| 693 | return reconcileThreadTerminalSessionIds(state, nextIds); | |
| 694 | } | |
| 695 | const suppressedIds = new Set(suppressedTerminalIds); | |
| 696 | return reconcileThreadTerminalSessionIds( | |
| 697 | state, | |
| 698 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), | |
| 699 | ); | |
| 700 | }), | |
| 806 | updateTerminal( | |
| 807 | threadRef, | |
| 808 | (state, suppressedTerminalIds) => { | |
| 809 | if (suppressedTerminalIds.length === 0) { | |
| 810 | return reconcileThreadTerminalSessionIds(state, nextIds); | |
| 811 | } | |
| 812 | const suppressedIds = new Set(suppressedTerminalIds); | |
| 813 | return reconcileThreadTerminalSessionIds( | |
| 814 | state, | |
| 815 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), | |
| 816 | ); | |
| 817 | }, | |
| 818 | undefined, | |
| 819 | true, | |
| 820 | ), | |
| 701 | 821 | clearTerminalUiState: (threadRef) => |
| 702 | 822 | set((state) => { |
| 703 | 823 | const threadKey = terminalThreadKey(threadRef); |
| ... | ... | @@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 708 | 828 | ); |
| 709 | 829 | const hadSuppressedTerminalIds = |
| 710 | 830 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 831 | const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; | |
| 711 | 832 | if ( |
| 712 | 833 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 713 | !hadSuppressedTerminalIds | |
| 834 | !hadSuppressedTerminalIds && | |
| 835 | !hadCustomLabels | |
| 714 | 836 | ) { |
| 715 | 837 | return state; |
| 716 | 838 | } |
| 717 | 839 | return { |
| 718 | 840 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 841 | terminalCustomLabelsByThreadKey: removeRecordEntry( | |
| 842 | state.terminalCustomLabelsByThreadKey, | |
| 843 | threadKey, | |
| 844 | ), | |
| 719 | 845 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 720 | 846 | state.suppressedTerminalIdsByThreadKey, |
| 721 | 847 | threadKey, |
| ... | ... | @@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 728 | 854 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; |
| 729 | 855 | const hadSuppressedTerminalIds = |
| 730 | 856 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 731 | if (!hadTerminalUiState && !hadSuppressedTerminalIds) { | |
| 857 | const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; | |
| 858 | if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { | |
| 732 | 859 | return state; |
| 733 | 860 | } |
| 734 | 861 | return { |
| ... | ... | @@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 736 | 863 | state.terminalUiStateByThreadKey, |
| 737 | 864 | threadKey, |
| 738 | 865 | ), |
| 866 | terminalCustomLabelsByThreadKey: removeRecordEntry( | |
| 867 | state.terminalCustomLabelsByThreadKey, | |
| 868 | threadKey, | |
| 869 | ), | |
| 739 | 870 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 740 | 871 | state.suppressedTerminalIdsByThreadKey, |
| 741 | 872 | threadKey, |
| ... | ... | @@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 747 | 878 | const orphanedIds = new Set( |
| 748 | 879 | [ |
| 749 | 880 | ...Object.keys(state.terminalUiStateByThreadKey), |
| 881 | ...Object.keys(state.terminalCustomLabelsByThreadKey), | |
| 750 | 882 | ...Object.keys(state.suppressedTerminalIdsByThreadKey), |
| 751 | 883 | ].filter((key) => !activeThreadKeys.has(key)), |
| 752 | 884 | ); |
| ... | ... | @@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 757 | 889 | const nextSuppressedTerminalIdsByThreadKey = { |
| 758 | 890 | ...state.suppressedTerminalIdsByThreadKey, |
| 759 | 891 | }; |
| 892 | const nextTerminalCustomLabelsByThreadKey = { | |
| 893 | ...state.terminalCustomLabelsByThreadKey, | |
| 894 | }; | |
| 760 | 895 | for (const id of orphanedIds) { |
| 761 | 896 | delete nextTerminalUiStateByThreadKey[id]; |
| 897 | delete nextTerminalCustomLabelsByThreadKey[id]; | |
| 762 | 898 | delete nextSuppressedTerminalIdsByThreadKey[id]; |
| 763 | 899 | } |
| 764 | 900 | return { |
| 765 | 901 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 902 | terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, | |
| 766 | 903 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 767 | 904 | }; |
| 768 | 905 | }), |
| ... | ... | @@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 770 | 907 | }, |
| 771 | 908 | { |
| 772 | 909 | name: TERMINAL_UI_STATE_STORAGE_KEY, |
| 773 | version: 4, | |
| 910 | version: 5, | |
| 774 | 911 | storage: createJSONStorage(createTerminalUiStateStorage), |
| 775 | 912 | migrate: migratePersistedTerminalUiStateStoreState, |
| 776 | 913 | partialize: (state) => ({ |
| 777 | 914 | terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, |
| 915 | terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, | |
| 778 | 916 | }), |
| 779 | 917 | }, |
| 780 | 918 | ), |
packages/contracts/src/ipc.ts+4| ... | ... | @@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> { |
| 111 | 111 | header?: boolean; |
| 112 | 112 | /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ |
| 113 | 113 | icon?: string; |
| 114 | /** Inserts a visual section divider immediately before this item. */ | |
| 115 | separatorBefore?: boolean; | |
| 114 | 116 | children?: readonly ContextMenuItem<T>[]; |
| 115 | 117 | } |
| 116 | 118 | |
| ... | ... | @@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType { |
| 121 | 123 | readonly disabled?: boolean; |
| 122 | 124 | readonly header?: boolean; |
| 123 | 125 | readonly icon?: string; |
| 126 | readonly separatorBefore?: boolean; | |
| 124 | 127 | readonly children?: readonly ContextMenuItemSchemaType[]; |
| 125 | 128 | } |
| 126 | 129 | |
| ... | ... | @@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc |
| 131 | 134 | disabled: Schema.optionalKey(Schema.Boolean), |
| 132 | 135 | header: Schema.optionalKey(Schema.Boolean), |
| 133 | 136 | icon: Schema.optionalKey(Schema.String), |
| 137 | separatorBefore: Schema.optionalKey(Schema.Boolean), | |
| 134 | 138 | children: Schema.optionalKey( |
| 135 | 139 | Schema.Array( |
| 136 | 140 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), |
packages/shared/src/usageMerge.test.ts+1| ... | ... | @@ -248,6 +248,7 @@ describe("mergeUsage", () => { |
| 248 | 248 | ); |
| 249 | 249 | |
| 250 | 250 | expect(merged.sessions).toBe(1); |
| 251 | expect(merged.providers[0]?.sessions).toBe(1); | |
| 251 | 252 | }); |
| 252 | 253 | |
| 253 | 254 | it("returns empty totals with no environments", () => { |
packages/shared/src/usageMerge.ts+29-11| ... | ... | @@ -25,6 +25,7 @@ export interface ProviderTotals { |
| 25 | 25 | readonly costUsd: number; |
| 26 | 26 | readonly totalTokens: number; |
| 27 | 27 | readonly records: number; |
| 28 | readonly sessions: number; | |
| 28 | 29 | readonly costShare: number; |
| 29 | 30 | readonly tokenShare: number; |
| 30 | 31 | } |
| ... | ... | @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { |
| 135 | 136 | function ownedContribution( |
| 136 | 137 | environment: EnvironmentUsage, |
| 137 | 138 | ownerByFingerprint: ReadonlyMap<string, EnvironmentId>, |
| 138 | ): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { | |
| 139 | ): { | |
| 140 | readonly buckets: readonly UsageBucket[]; | |
| 141 | readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>; | |
| 142 | } { | |
| 139 | 143 | const ownedProviders = new Set<UsageProviderKind>(); |
| 140 | let sessions = 0; | |
| 144 | const sessionsByProvider = new Map<UsageProviderKind, number>(); | |
| 141 | 145 | for (const source of environment.summary.sources) { |
| 142 | 146 | if (source.status === "missing") continue; |
| 143 | 147 | const key = fingerprintKey(source.fingerprint); |
| 144 | 148 | if (ownerByFingerprint.get(key) === environment.environmentId) { |
| 145 | ownedProviders.add(source.fingerprint.provider); | |
| 149 | const provider = source.fingerprint.provider; | |
| 150 | ownedProviders.add(provider); | |
| 146 | 151 | // Distinct within a directory. Summing per-bucket session counts instead |
| 147 | 152 | // would count a session once per day and model it spans. |
| 148 | sessions += source.distinctSessions; | |
| 153 | sessionsByProvider.set( | |
| 154 | provider, | |
| 155 | (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, | |
| 156 | ); | |
| 149 | 157 | } |
| 150 | 158 | } |
| 151 | 159 | return { |
| 152 | 160 | buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), |
| 153 | sessions, | |
| 161 | sessionsByProvider, | |
| 154 | 162 | }; |
| 155 | 163 | } |
| 156 | 164 | |
| ... | ... | @@ -228,7 +236,7 @@ export function mergeUsage( |
| 228 | 236 | |
| 229 | 237 | const providerAccumulator = new Map< |
| 230 | 238 | UsageProviderKind, |
| 231 | { costUsd: number; totalTokens: number; records: number } | |
| 239 | { costUsd: number; totalTokens: number; records: number; sessions: number } | |
| 232 | 240 | >(); |
| 233 | 241 | const modelAccumulator = new Map< |
| 234 | 242 | string, |
| ... | ... | @@ -255,12 +263,20 @@ export function mergeUsage( |
| 255 | 263 | const contributingEnvironments: EnvironmentId[] = []; |
| 256 | 264 | |
| 257 | 265 | for (const environment of current) { |
| 258 | const { buckets, sessions: environmentSessions } = ownedContribution( | |
| 259 | environment, | |
| 260 | ownerByFingerprint, | |
| 261 | ); | |
| 266 | const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); | |
| 262 | 267 | if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); |
| 263 | sessions += environmentSessions; | |
| 268 | ||
| 269 | for (const [providerKind, providerSessions] of sessionsByProvider) { | |
| 270 | sessions += providerSessions; | |
| 271 | const provider = providerAccumulator.get(providerKind) ?? { | |
| 272 | costUsd: 0, | |
| 273 | totalTokens: 0, | |
| 274 | records: 0, | |
| 275 | sessions: 0, | |
| 276 | }; | |
| 277 | provider.sessions += providerSessions; | |
| 278 | providerAccumulator.set(providerKind, provider); | |
| 279 | } | |
| 264 | 280 | |
| 265 | 281 | for (const bucket of buckets) { |
| 266 | 282 | const tokens = bucketTokens(bucket); |
| ... | ... | @@ -280,6 +296,7 @@ export function mergeUsage( |
| 280 | 296 | costUsd: 0, |
| 281 | 297 | totalTokens: 0, |
| 282 | 298 | records: 0, |
| 299 | sessions: 0, | |
| 283 | 300 | }; |
| 284 | 301 | provider.costUsd += bucket.costUsd; |
| 285 | 302 | provider.totalTokens += tokens; |
| ... | ... | @@ -341,6 +358,7 @@ export function mergeUsage( |
| 341 | 358 | costUsd: totals.costUsd, |
| 342 | 359 | totalTokens: totals.totalTokens, |
| 343 | 360 | records: totals.records, |
| 361 | sessions: totals.sessions, | |
| 344 | 362 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 345 | 363 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, |
| 346 | 364 | })) |