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