| 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,10 +98,7 @@ describe("ElectronMenu", () => { |
| 98 | const electronMenu = yield* ElectronMenu.ElectronMenu; | 98 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 99 | const selectedItemId = yield* electronMenu.showContextMenu({ | 99 | const selectedItemId = yield* electronMenu.showContextMenu({ |
| 100 | window: makeWindow(2), | 100 | window: makeWindow(2), |
| 101 | items: [ | 101 | items: [{ id: "copy", label: "Copy" }], |
| 102 | { id: "copy", label: "Copy" }, | ||
| 103 | { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, | ||
| 104 | ], | ||
| 105 | position: Option.some({ x: 10.8, y: 20.2 }), | 102 | position: Option.some({ x: 10.8, y: 20.2 }), |
| 106 | }); | 103 | }); |
| 107 | 104 | ||
| ... | @@ -113,12 +110,6 @@ describe("ElectronMenu", () => { | ... | @@ -113,12 +110,6 @@ describe("ElectronMenu", () => { |
| 113 | enabled: true, | 110 | enabled: true, |
| 114 | click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, | 111 | click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, |
| 115 | }); | 112 | }); |
| 116 | assert.deepEqual( | ||
| 117 | buildFromTemplateMock.mock.calls[0]?.[0].map( | ||
| 118 | (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, | ||
| 119 | ), | ||
| 120 | ["Copy", "separator", "Delete"], | ||
| 121 | ); | ||
| 122 | }).pipe(Effect.provide(TestLayer)), | 113 | }).pipe(Effect.provide(TestLayer)), |
| 123 | ); | 114 | ); |
| 124 | 115 |
apps/desktop/src/electron/ElectronMenu.ts+1-9| ... | @@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM | ... | @@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM |
| 78 | label: sourceItem.label, | 78 | label: sourceItem.label, |
| 79 | destructive: sourceItem.destructive === true, | 79 | destructive: sourceItem.destructive === true, |
| 80 | disabled: sourceItem.disabled === true, | 80 | disabled: sourceItem.disabled === true, |
| 81 | ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), | ||
| 82 | }; | 81 | }; |
| 83 | 82 | ||
| 84 | if (sourceItem.children) { | 83 | if (sourceItem.children) { |
| ... | @@ -142,17 +141,10 @@ export const make = Effect.gen(function* () { | ... | @@ -142,17 +141,10 @@ export const make = Effect.gen(function* () { |
| 142 | ): Electron.MenuItemConstructorOptions[] => { | 141 | ): Electron.MenuItemConstructorOptions[] => { |
| 143 | const template: Electron.MenuItemConstructorOptions[] = []; | 142 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 144 | let hasInsertedDestructiveSeparator = false; | 143 | let hasInsertedDestructiveSeparator = false; |
| 145 | const appendSeparator = () => { | ||
| 146 | if (template.length === 0 || template.at(-1)?.type === "separator") return; | ||
| 147 | template.push({ type: "separator" }); | ||
| 148 | }; | ||
| 149 | 144 | ||
| 150 | for (const item of entries) { | 145 | for (const item of entries) { |
| 151 | if (item.separatorBefore) { | ||
| 152 | appendSeparator(); | ||
| 153 | } | ||
| 154 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | 146 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { |
| 155 | appendSeparator(); | 147 | template.push({ type: "separator" }); |
| 156 | hasInsertedDestructiveSeparator = true; | 148 | hasInsertedDestructiveSeparator = true; |
| 157 | } | 149 | } |
| 158 | 150 |
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+1-40| ... | @@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity | ... | @@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity |
| 20 | * If slimming ever moves to an allowlist over the whole payload, these | 20 | * If slimming ever moves to an allowlist over the whole payload, these |
| 21 | * assertions are the tripwire. | 21 | * assertions are the tripwire. |
| 22 | */ | 22 | */ |
| 23 | describe("projectActivityPayload", () => { | 23 | describe("projectActivityPayload agent-field survival", () => { |
| 24 | it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { | 24 | it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { |
| 25 | const projected = projectActivityPayload( | 25 | const projected = projectActivityPayload( |
| 26 | activity({ | 26 | activity({ |
| ... | @@ -44,45 +44,6 @@ describe("projectActivityPayload", () => { | ... | @@ -44,45 +44,6 @@ describe("projectActivityPayload", () => { |
| 44 | expect(data.somethingClientNeverReads).toBeUndefined(); | 44 | expect(data.somethingClientNeverReads).toBeUndefined(); |
| 45 | }); | 45 | }); |
| 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 | it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { | 47 | it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { |
| 87 | const projected = projectActivityPayload( | 48 | const projected = projectActivityPayload( |
| 88 | activity({ | 49 | activity({ |
apps/server/src/orchestration/ActivityPayloadProjection.ts+7-27| ... | @@ -104,24 +104,6 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno | ... | @@ -104,24 +104,6 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno |
| 104 | return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; | 104 | return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; |
| 105 | } | 105 | } |
| 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 | function summarizeToolTextOutput(value: string): string | null { | 107 | function summarizeToolTextOutput(value: string): string | null { |
| 126 | const lines: string[] = []; | 108 | const lines: string[] = []; |
| 127 | for (const rawLine of value.split(/\r?\n/u)) { | 109 | for (const rawLine of value.split(/\r?\n/u)) { |
| ... | @@ -305,9 +287,8 @@ export function projectActivityPayload( | ... | @@ -305,9 +287,8 @@ export function projectActivityPayload( |
| 305 | if (item) { | 287 | if (item) { |
| 306 | projectedData.item = item; | 288 | projectedData.item = item; |
| 307 | } | 289 | } |
| 308 | const command = projectCommandValue(data); | 290 | if ("command" in data) { |
| 309 | if (command !== undefined) { | 291 | projectedData.command = data.command; |
| 310 | projectedData.command = command; | ||
| 311 | } | 292 | } |
| 312 | 293 | ||
| 313 | const changedFiles: string[] = []; | 294 | const changedFiles: string[] = []; |
| ... | @@ -387,10 +368,10 @@ function dropStaleContextWindowActivities( | ... | @@ -387,10 +368,10 @@ function dropStaleContextWindowActivities( |
| 387 | /** | 368 | /** |
| 388 | * Identity both clients use to fold a tool lifecycle row into the call it | 369 | * Identity both clients use to fold a tool lifecycle row into the call it |
| 389 | * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and | 370 | * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and |
| 390 | * mobile's `threadActivity`): the runtime item id ingestion stamps as | 371 | * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter |
| 391 | * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. | 372 | * emits one, otherwise the itemType/title/detail triple. Returns null for rows |
| 392 | * Returns null for rows with no identity at all — those never collapse on the | 373 | * with no identity at all — those never collapse on the client either, so they |
| 393 | * client either, so they must not be dropped here. | 374 | * must not be dropped here. |
| 394 | */ | 375 | */ |
| 395 | function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { | 376 | function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { |
| 396 | const payload = asRecord(activity.payload); | 377 | const payload = asRecord(activity.payload); |
| ... | @@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | | ... | @@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | |
| 398 | return null; | 379 | return null; |
| 399 | } | 380 | } |
| 400 | 381 | ||
| 401 | const toolCallId = | 382 | const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); |
| 402 | asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); | ||
| 403 | if (toolCallId) { | 383 | if (toolCallId) { |
| 404 | return `id:${toolCallId}`; | 384 | return `id:${toolCallId}`; |
| 405 | } | 385 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-22| ... | @@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 2811 | createdAt: now, | 2811 | createdAt: now, |
| 2812 | threadId: asThreadId("thread-1"), | 2812 | threadId: asThreadId("thread-1"), |
| 2813 | turnId: asTurnId("turn-9"), | 2813 | turnId: asTurnId("turn-9"), |
| 2814 | itemId: asItemId("tool-call-9"), | ||
| 2815 | payload: { | 2814 | payload: { |
| 2816 | itemType: "command_execution", | 2815 | itemType: "command_execution", |
| 2817 | status: "inProgress", | 2816 | status: "in_progress", |
| 2818 | title: "Command run", | 2817 | title: "Read file", |
| 2819 | detail: "Bash: vp test run", | 2818 | detail: "/tmp/file.ts", |
| 2820 | data: { | ||
| 2821 | toolName: "Bash", | ||
| 2822 | input: { command: "vp test run" }, | ||
| 2823 | }, | ||
| 2824 | }, | 2819 | }, |
| 2825 | }); | 2820 | }); |
| 2826 | 2821 | ||
| ... | @@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 2835 | ); | 2830 | ); |
| 2836 | 2831 | ||
| 2837 | expect(thread.session?.status).toBe("ready"); | 2832 | expect(thread.session?.status).toBe("ready"); |
| 2838 | const activity = thread.activities.find( | 2833 | expect( |
| 2839 | (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", | 2834 | thread.activities.some( |
| 2840 | ); | 2835 | (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", |
| 2841 | const payload = activity?.payload as Record<string, unknown> | undefined; | 2836 | ), |
| 2842 | expect(payload).toMatchObject({ | 2837 | ).toBe(true); |
| 2843 | itemType: "command_execution", | ||
| 2844 | toolCallId: "tool-call-9", | ||
| 2845 | status: "inProgress", | ||
| 2846 | detail: "Bash: vp test run", | ||
| 2847 | data: { | ||
| 2848 | toolName: "Bash", | ||
| 2849 | input: { command: "vp test run" }, | ||
| 2850 | }, | ||
| 2851 | }); | ||
| 2852 | }); | 2838 | }); |
| 2853 | 2839 | ||
| 2854 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { | 2840 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts-6| ... | @@ -794,7 +794,6 @@ export function runtimeEventToActivities( | ... | @@ -794,7 +794,6 @@ export function runtimeEventToActivities( |
| 794 | summary: event.payload.title ?? "Tool updated", | 794 | summary: event.payload.title ?? "Tool updated", |
| 795 | payload: { | 795 | payload: { |
| 796 | itemType: event.payload.itemType, | 796 | itemType: event.payload.itemType, |
| 797 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | ||
| 798 | ...(event.payload.status ? { status: event.payload.status } : {}), | 797 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 799 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 798 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 800 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 799 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| ... | @@ -822,8 +821,6 @@ export function runtimeEventToActivities( | ... | @@ -822,8 +821,6 @@ export function runtimeEventToActivities( |
| 822 | summary: event.payload.title ?? "Tool", | 821 | summary: event.payload.title ?? "Tool", |
| 823 | payload: { | 822 | payload: { |
| 824 | itemType: event.payload.itemType, | 823 | itemType: event.payload.itemType, |
| 825 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | ||
| 826 | ...(event.payload.status ? { status: event.payload.status } : {}), | ||
| 827 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 824 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 828 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 825 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 829 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | 826 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| ... | @@ -850,10 +847,7 @@ export function runtimeEventToActivities( | ... | @@ -850,10 +847,7 @@ export function runtimeEventToActivities( |
| 850 | summary: `${event.payload.title ?? "Tool"} started`, | 847 | summary: `${event.payload.title ?? "Tool"} started`, |
| 851 | payload: { | 848 | payload: { |
| 852 | itemType: event.payload.itemType, | 849 | itemType: event.payload.itemType, |
| 853 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | ||
| 854 | ...(event.payload.status ? { status: event.payload.status } : {}), | ||
| 855 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 850 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 856 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | ||
| 857 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | 851 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 858 | ...(event.payload.parentToolUseId | 852 | ...(event.payload.parentToolUseId |
| 859 | ? { parentToolUseId: event.payload.parentToolUseId } | 853 | ? { parentToolUseId: event.payload.parentToolUseId } |
apps/web/src/components/ChatView.tsx+26-32| ... | @@ -167,6 +167,7 @@ import { | ... | @@ -167,6 +167,7 @@ import { |
| 167 | WifiOffIcon, | 167 | WifiOffIcon, |
| 168 | } from "lucide-react"; | 168 | } from "lucide-react"; |
| 169 | import { cn, randomHex } from "~/lib/utils"; | 169 | import { cn, randomHex } from "~/lib/utils"; |
| 170 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | ||
| 170 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 171 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 171 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 172 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 172 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 173 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | @@ -219,11 +220,7 @@ import { | ... | @@ -219,11 +220,7 @@ import { |
| 219 | import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; | 220 | import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; |
| 220 | import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; | 221 | import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; |
| 221 | import { environmentCatalog } from "../connection/catalog"; | 222 | import { environmentCatalog } from "../connection/catalog"; |
| 222 | import { | 223 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 223 | selectThreadTerminalCustomLabels, | ||
| 224 | selectThreadTerminalUiState, | ||
| 225 | useTerminalUiStateStore, | ||
| 226 | } from "../terminalUiStateStore"; | ||
| 227 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; | 224 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 228 | import { projectEnvironment } from "../state/projects"; | 225 | import { projectEnvironment } from "../state/projects"; |
| 229 | import { useEnvironmentQuery } from "../state/query"; | 226 | import { useEnvironmentQuery } from "../state/query"; |
| ... | @@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader"; | ... | @@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader"; |
| 259 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 256 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 260 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 257 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 261 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 258 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 262 | import { WorkspacePageHeader } from "./WorkspacePageContainer"; | ||
| 263 | import { | 259 | import { |
| 264 | resolveEffectiveEnvMode, | 260 | resolveEffectiveEnvMode, |
| 265 | resolveLocalCheckoutBranchMismatch, | 261 | resolveLocalCheckoutBranchMismatch, |
| ... | @@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps { | ... | @@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps { |
| 657 | newShortcutLabel: string | undefined; | 653 | newShortcutLabel: string | undefined; |
| 658 | closeShortcutLabel: string | undefined; | 654 | closeShortcutLabel: string | undefined; |
| 659 | keybindings: ResolvedKeybindingsConfig; | 655 | keybindings: ResolvedKeybindingsConfig; |
| 660 | onHide: () => void; | ||
| 661 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | 656 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 662 | } | 657 | } |
| 663 | 658 | ||
| ... | @@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 672 | newShortcutLabel, | 667 | newShortcutLabel, |
| 673 | closeShortcutLabel, | 668 | closeShortcutLabel, |
| 674 | keybindings, | 669 | keybindings, |
| 675 | onHide, | ||
| 676 | onAddTerminalContext, | 670 | onAddTerminalContext, |
| 677 | }: PersistentThreadTerminalDrawerProps) { | 671 | }: PersistentThreadTerminalDrawerProps) { |
| 678 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); | 672 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| ... | @@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 996 | onSplitTerminal={splitTerminal} | 990 | onSplitTerminal={splitTerminal} |
| 997 | onSplitTerminalVertical={splitTerminalVertical} | 991 | onSplitTerminalVertical={splitTerminalVertical} |
| 998 | onNewTerminal={createNewTerminal} | 992 | onNewTerminal={createNewTerminal} |
| 999 | onHide={onHide} | ||
| 1000 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} | 993 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} |
| 1001 | splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} | 994 | splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} |
| 1002 | newShortcutLabel={visible ? newShortcutLabel : undefined} | 995 | newShortcutLabel={visible ? newShortcutLabel : undefined} |
| ... | @@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1547 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 1540 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1548 | const activeThreadId = activeThread?.id ?? null; | 1541 | const activeThreadId = activeThread?.id ?? null; |
| 1549 | const activeThreadEnvironmentId = activeThread?.environmentId ?? null; | 1542 | const activeThreadEnvironmentId = activeThread?.environmentId ?? null; |
| 1550 | const activeThreadRef = useMemo( | ||
| 1551 | () => | ||
| 1552 | activeThreadEnvironmentId && activeThreadId | ||
| 1553 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | ||
| 1554 | : null, | ||
| 1555 | [activeThreadEnvironmentId, activeThreadId], | ||
| 1556 | ); | ||
| 1557 | const activeTerminalCustomLabels = useTerminalUiStateStore((state) => | ||
| 1558 | selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), | ||
| 1559 | ); | ||
| 1560 | const runningTerminalIds = useThreadRunningTerminalIds({ | 1543 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1561 | environmentId: activeThread?.environmentId ?? null, | 1544 | environmentId: activeThread?.environmentId ?? null, |
| 1562 | threadId: activeThreadId, | 1545 | threadId: activeThreadId, |
| ... | @@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 1586 | for (const session of activeThreadKnownSessions) { | 1569 | for (const session of activeThreadKnownSessions) { |
| 1587 | labels.set( | 1570 | labels.set( |
| 1588 | session.target.terminalId, | 1571 | session.target.terminalId, |
| 1589 | activeTerminalCustomLabels[session.target.terminalId] ?? | 1572 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), |
| 1590 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 1591 | ); | 1573 | ); |
| 1592 | } | 1574 | } |
| 1593 | for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { | ||
| 1594 | if (!labels.has(terminalId)) labels.set(terminalId, label); | ||
| 1595 | } | ||
| 1596 | return labels; | 1575 | return labels; |
| 1597 | }, [activeTerminalCustomLabels, activeThreadKnownSessions]); | 1576 | }, [activeThreadKnownSessions]); |
| 1577 | const activeThreadRef = useMemo( | ||
| 1578 | () => | ||
| 1579 | activeThreadEnvironmentId && activeThreadId | ||
| 1580 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | ||
| 1581 | : null, | ||
| 1582 | [activeThreadEnvironmentId, activeThreadId], | ||
| 1583 | ); | ||
| 1598 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1584 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1599 | const [timelineAnchor, setTimelineAnchor] = useState<{ | 1585 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1600 | readonly threadKey: string | null; | 1586 | readonly threadKey: string | null; |
| ... | @@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2822 | }, | 2808 | }, |
| 2823 | [activeThreadRef, storeSetTerminalOpen], | 2809 | [activeThreadRef, storeSetTerminalOpen], |
| 2824 | ); | 2810 | ); |
| 2825 | const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); | ||
| 2826 | const toggleTerminalVisibility = useCallback(() => { | 2811 | const toggleTerminalVisibility = useCallback(() => { |
| 2827 | if (!activeThreadRef) return; | 2812 | if (!activeThreadRef) return; |
| 2828 | const nextOpen = !terminalUiState.terminalOpen; | 2813 | const nextOpen = !terminalUiState.terminalOpen; |
| ... | @@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6129 | ? "thread" | 6114 | ? "thread" |
| 6130 | : "page" | 6115 | : "page" |
| 6131 | } | 6116 | } |
| 6117 | chromeVariant="collapse" | ||
| 6132 | composerDraftTarget={composerDraftTarget} | 6118 | composerDraftTarget={composerDraftTarget} |
| 6133 | onStateChange={handlePullRequestTabStatusChange} | 6119 | onStateChange={handlePullRequestTabStatusChange} |
| 6134 | /> | 6120 | /> |
| ... | @@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 6174 | data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} | 6160 | data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} |
| 6175 | > | 6161 | > |
| 6176 | {/* Top bar */} | 6162 | {/* Top bar */} |
| 6177 | <WorkspacePageHeader | 6163 | <header |
| 6178 | data-chat-header | 6164 | data-chat-header |
| 6179 | electron={isElectron} | 6165 | className={cn( |
| 6180 | reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar} | 6166 | "bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none", |
| 6181 | className="relative bg-background" | 6167 | isElectron |
| 6168 | ? cn( | ||
| 6169 | "drag-region relative flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5", | ||
| 6170 | reserveTitleBarControlInset && | ||
| 6171 | !inlineRightPanelOwnsTitleBar && | ||
| 6172 | "wco:pr-[var(--workspace-native-controls-inset)]", | ||
| 6173 | ) | ||
| 6174 | : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]", | ||
| 6175 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 6176 | )} | ||
| 6182 | > | 6177 | > |
| 6183 | {!rightPanelOpen ? panelLayoutControls : null} | 6178 | {!rightPanelOpen ? panelLayoutControls : null} |
| 6184 | <ChatHeader | 6179 | <ChatHeader |
| ... | @@ -6209,7 +6204,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6209,7 +6204,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6209 | onUpdateProjectScript={updateProjectScript} | 6204 | onUpdateProjectScript={updateProjectScript} |
| 6210 | onDeleteProjectScript={deleteProjectScript} | 6205 | onDeleteProjectScript={deleteProjectScript} |
| 6211 | /> | 6206 | /> |
| 6212 | </WorkspacePageHeader> | 6207 | </header> |
| 6213 | 6208 | ||
| 6214 | <ThreadErrorBanner | 6209 | <ThreadErrorBanner |
| 6215 | error={visibleThreadError} | 6210 | error={visibleThreadError} |
| ... | @@ -6547,7 +6542,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6547,7 +6542,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6547 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | 6542 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 6548 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | 6543 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 6549 | keybindings={keybindings} | 6544 | keybindings={keybindings} |
| 6550 | onHide={hideTerminal} | ||
| 6551 | onAddTerminalContext={addTerminalContextToDraft} | 6545 | onAddTerminalContext={addTerminalContextToDraft} |
| 6552 | /> | 6546 | /> |
| 6553 | ))} | 6547 | ))} |
apps/web/src/components/NoActiveThreadState.tsx+15-4| ... | @@ -1,15 +1,26 @@ | ... | @@ -1,15 +1,26 @@ |
| 1 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; | 1 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; |
| 2 | import { SidebarInset } from "./ui/sidebar"; | 2 | import { SidebarInset } from "./ui/sidebar"; |
| 3 | import { isElectron } from "../env"; | 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 | export function NoActiveThreadState() { | 7 | export function NoActiveThreadState() { |
| 7 | return ( | 8 | return ( |
| 8 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | 9 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 9 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | 10 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 10 | <WorkspacePageHeader electron={isElectron} className="border-b border-border"> | 11 | <header |
| 12 | className={cn( | ||
| 13 | "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | ||
| 14 | isElectron | ||
| 15 | ? "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center" | ||
| 16 | : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center", | ||
| 17 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 18 | )} | ||
| 19 | > | ||
| 11 | {isElectron ? ( | 20 | {isElectron ? ( |
| 12 | <span className="text-xs text-muted-foreground/50">No active thread</span> | 21 | <span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]"> |
| 22 | No active thread | ||
| 23 | </span> | ||
| 13 | ) : ( | 24 | ) : ( |
| 14 | <div className="flex items-center gap-2"> | 25 | <div className="flex items-center gap-2"> |
| 15 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> | 26 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| ... | @@ -17,7 +28,7 @@ export function NoActiveThreadState() { | ... | @@ -17,7 +28,7 @@ export function NoActiveThreadState() { |
| 17 | </span> | 28 | </span> |
| 18 | </div> | 29 | </div> |
| 19 | )} | 30 | )} |
| 20 | </WorkspacePageHeader> | 31 | </header> |
| 21 | 32 | ||
| 22 | <Empty className="flex-1"> | 33 | <Empty className="flex-1"> |
| 23 | <div className="w-full max-w-lg px-8 py-12"> | 34 | <div className="w-full max-w-lg px-8 py-12"> |
apps/web/src/components/Sidebar.logic.ts+1-2| ... | @@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { | ... | @@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { |
| 299 | export function shouldCreateNewThreadInCurrentProject( | 299 | export function shouldCreateNewThreadInCurrentProject( |
| 300 | shiftKey: boolean, | 300 | shiftKey: boolean, |
| 301 | projectGroupCount: number, | 301 | projectGroupCount: number, |
| 302 | hasProjectScope = false, | ||
| 303 | ): boolean { | 302 | ): boolean { |
| 304 | return hasProjectScope || shiftKey || projectGroupCount <= 1; | 303 | return shiftKey || projectGroupCount <= 1; |
| 305 | } | 304 | } |
| 306 | 305 | ||
| 307 | export function orderItemsByPreferredIds<TItem, TId>(input: { | 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,7 +184,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25; |
| 184 | // Keep the v2 key so existing preferences survive the v2-to-default rename. | 184 | // Keep the v2 key so existing preferences survive the v2-to-default rename. |
| 185 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; | 185 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; |
| 186 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; | 186 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; |
| 187 | const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; | ||
| 188 | 187 | ||
| 189 | function compactSidebarTimeLabel(label: string): string { | 188 | function compactSidebarTimeLabel(label: string): string { |
| 190 | if (label === "just now") return "now"; | 189 | if (label === "just now") return "now"; |
| ... | @@ -367,20 +366,26 @@ function SnoozePopoverButton(props: { | ... | @@ -367,20 +366,26 @@ function SnoozePopoverButton(props: { |
| 367 | ); | 366 | ); |
| 368 | return ( | 367 | return ( |
| 369 | <Popover open={open} onOpenChange={onOpenChange}> | 368 | <Popover open={open} onOpenChange={onOpenChange}> |
| 370 | <PopoverTrigger | 369 | <Tooltip> |
| 371 | render={ | 370 | <TooltipTrigger |
| 372 | <Button | 371 | render={ |
| 373 | size="icon-micro" | 372 | <PopoverTrigger |
| 374 | variant="ghost-muted" | 373 | render={ |
| 375 | aria-label="Snooze thread" | 374 | <button |
| 376 | title="Snooze thread" | 375 | type="button" |
| 377 | onClick={(event) => event.stopPropagation()} | 376 | aria-label="Snooze thread" |
| 378 | onDoubleClick={(event) => event.stopPropagation()} | 377 | onClick={(event) => event.stopPropagation()} |
| 379 | /> | 378 | onDoubleClick={(event) => event.stopPropagation()} |
| 380 | } | 379 | className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" |
| 381 | > | 380 | /> |
| 382 | <ClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 381 | } |
| 383 | </PopoverTrigger> | 382 | /> |
| 383 | } | ||
| 384 | > | ||
| 385 | <ClockIcon className="size-3" /> | ||
| 386 | </TooltipTrigger> | ||
| 387 | <TooltipPopup>Snooze thread</TooltipPopup> | ||
| 388 | </Tooltip> | ||
| 384 | <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> | 389 | <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> |
| 385 | {presets.map((preset) => ( | 390 | {presets.map((preset) => ( |
| 386 | <button | 391 | <button |
| ... | @@ -1194,9 +1199,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1194,9 +1199,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1194 | aria-label="Dismiss Woke notification" | 1199 | aria-label="Dismiss Woke notification" |
| 1195 | title="Dismiss Woke notification" | 1200 | title="Dismiss Woke notification" |
| 1196 | onClick={handleAcknowledgeWokeClick} | 1201 | onClick={handleAcknowledgeWokeClick} |
| 1197 | className="inline-flex h-6 cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" | 1202 | className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" |
| 1198 | > | 1203 | > |
| 1199 | <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1204 | <AlarmClockIcon aria-hidden className="size-3" /> |
| 1200 | <span role="status">Woke</span> | 1205 | <span role="status">Woke</span> |
| 1201 | </button> | 1206 | </button> |
| 1202 | ) : ( | 1207 | ) : ( |
| ... | @@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1218 | isWoke && "group-hover/sidebar-row:static", | 1223 | isWoke && "group-hover/sidebar-row:static", |
| 1219 | )} | 1224 | )} |
| 1220 | > | 1225 | > |
| 1221 | <AlarmClockOffIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1226 | <AlarmClockOffIcon className="mb-px size-3" /> |
| 1222 | </button> | 1227 | </button> |
| 1223 | ) | 1228 | ) |
| 1224 | ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( | 1229 | ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( |
| ... | @@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1231 | isWoke && "group-hover/sidebar-row:static", | 1236 | isWoke && "group-hover/sidebar-row:static", |
| 1232 | )} | 1237 | )} |
| 1233 | > | 1238 | > |
| 1234 | <Undo2Icon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1239 | <Undo2Icon className="mb-px size-3.5" /> |
| 1235 | </button> | 1240 | </button> |
| 1236 | ) : ( | 1241 | ) : ( |
| 1237 | <button | 1242 | <button |
| ... | @@ -1243,7 +1248,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1243,7 +1248,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1243 | isWoke && "group-hover/sidebar-row:static", | 1248 | isWoke && "group-hover/sidebar-row:static", |
| 1244 | )} | 1249 | )} |
| 1245 | > | 1250 | > |
| 1246 | <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1251 | <CheckIcon className="size-3" /> |
| 1247 | </button> | 1252 | </button> |
| 1248 | )} | 1253 | )} |
| 1249 | </span> | 1254 | </span> |
| ... | @@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1312 | ) : ( | 1317 | ) : ( |
| 1313 | <span className="flex-1" /> | 1318 | <span className="flex-1" /> |
| 1314 | )} | 1319 | )} |
| 1315 | <span className="ml-auto flex h-5 shrink-0 items-center"> | 1320 | {props.isPinned ? ( |
| 1316 | {props.isPinned ? ( | 1321 | props.pinningSupported ? ( |
| 1317 | props.pinningSupported ? ( | 1322 | <Tooltip> |
| 1318 | <Button | 1323 | <TooltipTrigger |
| 1319 | size="icon-micro" | 1324 | render={ |
| 1320 | variant="ghost-muted" | 1325 | <button |
| 1321 | aria-label="Unpin thread" | 1326 | type="button" |
| 1322 | title="Unpin thread" | 1327 | aria-label="Unpin thread" |
| 1323 | onClick={handleUnpinClick} | 1328 | onClick={handleUnpinClick} |
| 1329 | className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | ||
| 1330 | /> | ||
| 1331 | } | ||
| 1324 | > | 1332 | > |
| 1325 | <PinIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1333 | <PinIcon aria-hidden className="size-3 shrink-0" /> |
| 1326 | </Button> | 1334 | </TooltipTrigger> |
| 1327 | ) : ( | 1335 | <TooltipPopup>Unpin thread</TooltipPopup> |
| 1328 | <span className="inline-flex size-5 shrink-0 items-center justify-center"> | 1336 | </Tooltip> |
| 1329 | <PinIcon | 1337 | ) : ( |
| 1330 | aria-label="Pinned" | 1338 | <PinIcon |
| 1331 | role="img" | 1339 | aria-label="Pinned" |
| 1332 | className={cn(SIDEBAR_LIFECYCLE_ICON_CLASS, "text-muted-foreground")} | 1340 | role="img" |
| 1333 | /> | 1341 | className="size-3 shrink-0 text-muted-foreground/65" |
| 1334 | </span> | 1342 | /> |
| 1335 | ) | 1343 | ) |
| 1336 | ) : null} | 1344 | ) : null} |
| 1337 | {/* Only the visible state owns this slot's width: the pin stays | 1345 | {/* The visible state owns this slot's width: status at rest, |
| 1338 | directly beside the idle status and beside the first action | 1346 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1339 | when the hover controls replace it. */} | 1347 | the hidden state out of flow lets the project label reclaim |
| 1348 | space without either state overlapping it. */} | ||
| 1349 | <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs"> | ||
| 1350 | {/* Read-only status labels yield to the hover actions. Woke is | ||
| 1351 | itself an action, so it stays pointer-enabled and visible | ||
| 1352 | while the other controls appear beside it. */} | ||
| 1340 | <span | 1353 | <span |
| 1341 | className={cn( | 1354 | className={cn( |
| 1342 | "group/sidebar-status-slot relative flex h-5 shrink-0 items-stretch justify-end text-xs", | 1355 | isWokeStatus |
| 1343 | props.isPinned ? "min-w-0" : "min-w-8", | 1356 | ? "pointer-events-auto" |
| 1357 | : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", | ||
| 1358 | "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", | ||
| 1359 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", | ||
| 1344 | )} | 1360 | )} |
| 1345 | > | 1361 | > |
| 1346 | {/* Read-only status labels yield to the hover actions. Woke is | 1362 | {topStatus ? ( |
| 1347 | itself an action, so it stays pointer-enabled and visible | 1363 | isWokeStatus ? ( |
| 1348 | while the other controls appear beside it. */} | 1364 | <button |
| 1365 | type="button" | ||
| 1366 | aria-label="Dismiss Woke notification" | ||
| 1367 | title="Dismiss Woke notification" | ||
| 1368 | onClick={handleAcknowledgeWokeClick} | ||
| 1369 | className={cn( | ||
| 1370 | "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", | ||
| 1371 | topStatus.className, | ||
| 1372 | )} | ||
| 1373 | > | ||
| 1374 | <AlarmClockIcon aria-hidden className="size-4 shrink-0" /> | ||
| 1375 | <span role="status">{topStatus.label}</span> | ||
| 1376 | </button> | ||
| 1377 | ) : ( | ||
| 1378 | <span | ||
| 1379 | className={cn( | ||
| 1380 | "inline-flex items-center gap-1 font-medium", | ||
| 1381 | topStatus.className, | ||
| 1382 | )} | ||
| 1383 | > | ||
| 1384 | {topStatus.icon === "working" ? ( | ||
| 1385 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> | ||
| 1386 | ) : topStatus.icon === "done" ? ( | ||
| 1387 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> | ||
| 1388 | ) : null} | ||
| 1389 | {/* The label alone is the live region: a role="status" | ||
| 1390 | wrapper around the ticking duration would make | ||
| 1391 | screen readers announce every second. */} | ||
| 1392 | <span role="status">{topStatus.label}</span> | ||
| 1393 | {status === "working" ? ( | ||
| 1394 | <span aria-hidden> | ||
| 1395 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> | ||
| 1396 | </span> | ||
| 1397 | ) : null} | ||
| 1398 | </span> | ||
| 1399 | ) | ||
| 1400 | ) : ( | ||
| 1401 | threadTimeLabel(thread) | ||
| 1402 | )} | ||
| 1403 | </span> | ||
| 1404 | {props.settlementSupported || showSnoozeButton ? ( | ||
| 1349 | <span | 1405 | <span |
| 1350 | className={cn( | 1406 | className={cn( |
| 1351 | isWokeStatus | 1407 | // focus-visible, not focus-within: a mouse click leaves |
| 1352 | ? "pointer-events-auto" | 1408 | // the Settle button focused, and a plain focus-within |
| 1353 | : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", | 1409 | // would keep the controls pinned over the status label |
| 1354 | "self-center tabular-nums text-secondary-label transition-opacity", | 1410 | // once the pointer moves away (e.g. after a failed |
| 1355 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", | 1411 | // settle) instead of cross-fading back. |
| 1412 | "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", | ||
| 1413 | snoozeMenuOpen && "pointer-events-auto static opacity-100", | ||
| 1356 | )} | 1414 | )} |
| 1357 | > | 1415 | > |
| 1358 | {topStatus ? ( | 1416 | {showSnoozeButton ? ( |
| 1359 | isWokeStatus ? ( | 1417 | <SnoozePopoverButton |
| 1360 | <button | 1418 | open={snoozeMenuOpen} |
| 1361 | type="button" | 1419 | onOpenChange={setSnoozeMenuOpen} |
| 1362 | aria-label="Dismiss Woke notification" | 1420 | onSnooze={handleSnoozePreset} |
| 1363 | title="Dismiss Woke notification" | 1421 | timestampFormat={props.timestampFormat} |
| 1364 | onClick={handleAcknowledgeWokeClick} | 1422 | /> |
| 1365 | className={cn( | 1423 | ) : null} |
| 1366 | "inline-flex h-5 cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", | 1424 | {props.settlementSupported ? ( |
| 1367 | topStatus.className, | 1425 | <Tooltip> |
| 1368 | )} | 1426 | <TooltipTrigger |
| 1369 | > | 1427 | render={ |
| 1370 | <AlarmClockIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1428 | <button |
| 1371 | <span role="status">{topStatus.label}</span> | 1429 | type="button" |
| 1372 | </button> | 1430 | aria-label="Settle thread" |
| 1373 | ) : ( | 1431 | onClick={handleSettleClick} |
| 1374 | <span | 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" |
| 1375 | className={cn( | ||
| 1376 | "inline-flex items-center gap-1 font-medium", | ||
| 1377 | topStatus.className, | ||
| 1378 | )} | ||
| 1379 | > | ||
| 1380 | {topStatus.icon === "working" ? ( | ||
| 1381 | <CircleDashedIcon | ||
| 1382 | aria-hidden | ||
| 1383 | className={SIDEBAR_LIFECYCLE_ICON_CLASS} | ||
| 1384 | /> | 1433 | /> |
| 1385 | ) : topStatus.icon === "done" ? ( | 1434 | } |
| 1386 | <CircleCheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | ||
| 1387 | ) : null} | ||
| 1388 | {/* The label alone is the live region: a role="status" | ||
| 1389 | wrapper around the ticking duration would make | ||
| 1390 | screen readers announce every second. */} | ||
| 1391 | <span role="status">{topStatus.label}</span> | ||
| 1392 | {status === "working" ? ( | ||
| 1393 | <span aria-hidden> | ||
| 1394 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> | ||
| 1395 | </span> | ||
| 1396 | ) : null} | ||
| 1397 | </span> | ||
| 1398 | ) | ||
| 1399 | ) : ( | ||
| 1400 | threadTimeLabel(thread) | ||
| 1401 | )} | ||
| 1402 | </span> | ||
| 1403 | {props.settlementSupported || showSnoozeButton ? ( | ||
| 1404 | <span | ||
| 1405 | className={cn( | ||
| 1406 | // focus-visible, not focus-within: a mouse click leaves | ||
| 1407 | // the Settle button focused, and a plain focus-within | ||
| 1408 | // would keep the controls pinned over the status label | ||
| 1409 | // once the pointer moves away (e.g. after a failed | ||
| 1410 | // settle) instead of cross-fading back. | ||
| 1411 | "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", | ||
| 1412 | snoozeMenuOpen && "pointer-events-auto static opacity-100", | ||
| 1413 | )} | ||
| 1414 | > | ||
| 1415 | {showSnoozeButton ? ( | ||
| 1416 | <SnoozePopoverButton | ||
| 1417 | open={snoozeMenuOpen} | ||
| 1418 | onOpenChange={setSnoozeMenuOpen} | ||
| 1419 | onSnooze={handleSnoozePreset} | ||
| 1420 | timestampFormat={props.timestampFormat} | ||
| 1421 | /> | ||
| 1422 | ) : null} | ||
| 1423 | {props.settlementSupported ? ( | ||
| 1424 | <button | ||
| 1425 | type="button" | ||
| 1426 | aria-label="Settle thread" | ||
| 1427 | onClick={handleSettleClick} | ||
| 1428 | className="-mr-1 inline-flex h-full cursor-pointer items-center gap-1 rounded-md bg-transparent ps-1 pe-1.5 text-xs text-muted-foreground hover:text-foreground" | ||
| 1429 | > | 1435 | > |
| 1430 | <CheckIcon aria-hidden className={SIDEBAR_LIFECYCLE_ICON_CLASS} /> | 1436 | <CheckIcon className="size-3.5" /> |
| 1431 | Settle | 1437 | Settle |
| 1432 | </button> | 1438 | </TooltipTrigger> |
| 1433 | ) : null} | 1439 | <TooltipPopup>Settle thread</TooltipPopup> |
| 1434 | </span> | 1440 | </Tooltip> |
| 1435 | ) : null} | 1441 | ) : null} |
| 1436 | </span> | 1442 | </span> |
| 1443 | ) : null} | ||
| 1437 | </span> | 1444 | </span> |
| 1438 | </div> | 1445 | </div> |
| 1439 | <div className="mt-1 flex min-w-0"> | 1446 | <div className="mt-1 flex min-w-0"> |
| ... | @@ -3211,25 +3218,17 @@ export default function Sidebar() { | ... | @@ -3211,25 +3218,17 @@ export default function Sidebar() { |
| 3211 | autoAnimate(node, { duration: 150, easing: "ease-out" }); | 3218 | autoAnimate(node, { duration: 150, easing: "ease-out" }); |
| 3212 | }, []); | 3219 | }, []); |
| 3213 | 3220 | ||
| 3214 | // A selected project scope owns creation: users should not have to choose | 3221 | // New thread defaults to the project you're in (active thread's project, |
| 3215 | // the same project twice. "All projects" keeps the picker in multi-project | 3222 | // falling back to the top project) — same resolution the command palette |
| 3216 | // setups, while Shift+click retains the direct-create shortcut. | 3223 | // uses. The command palette already offers a "New thread in..." submenu |
| 3224 | // for multi-project setups. | ||
| 3217 | const handleNewThreadClick = useCallback( | 3225 | const handleNewThreadClick = useCallback( |
| 3218 | (event?: ReactMouseEvent) => { | 3226 | (event?: ReactMouseEvent) => { |
| 3219 | if ( | 3227 | // One project: nothing to pick, create immediately. Shift+click creates |
| 3220 | shouldCreateNewThreadInCurrentProject( | 3228 | // directly in the current project even with several projects, skipping |
| 3221 | event?.shiftKey ?? false, | 3229 | // the palette picker. |
| 3222 | projectGroups.length, | 3230 | if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { |
| 3223 | scopedProjectGroup !== null, | ||
| 3224 | ) | ||
| 3225 | ) { | ||
| 3226 | if (isMobile) setOpenMobile(false); | 3231 | if (isMobile) setOpenMobile(false); |
| 3227 | if (scopedProjectGroup) { | ||
| 3228 | void newThreadContext.handleNewThread( | ||
| 3229 | scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), | ||
| 3230 | ); | ||
| 3231 | return; | ||
| 3232 | } | ||
| 3233 | void startNewThreadFromContext({ | 3232 | void startNewThreadFromContext({ |
| 3234 | activeDraftThread: newThreadContext.activeDraftThread, | 3233 | activeDraftThread: newThreadContext.activeDraftThread, |
| 3235 | activeThread: newThreadContext.activeThread ?? undefined, | 3234 | activeThread: newThreadContext.activeThread ?? undefined, |
| ... | @@ -3241,19 +3240,20 @@ export default function Sidebar() { | ... | @@ -3241,19 +3240,20 @@ export default function Sidebar() { |
| 3241 | if (isMobile) setOpenMobile(false); | 3240 | if (isMobile) setOpenMobile(false); |
| 3242 | openCommandPalette({ open: "new-thread-in" }); | 3241 | openCommandPalette({ open: "new-thread-in" }); |
| 3243 | }, | 3242 | }, |
| 3244 | [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], | 3243 | [isMobile, newThreadContext, projectGroups.length, setOpenMobile], |
| 3245 | ); | 3244 | ); |
| 3246 | 3245 | ||
| 3247 | // With no explicit scope the button mirrors chat.new. A scoped button has | 3246 | // The button mirrors chat.new: in multi-project setups both route through |
| 3248 | // intentionally more specific behavior, so it does not advertise the | 3247 | // the command palette's "New thread in..." picker, and in single-project |
| 3249 | // broader command's shortcut. | 3248 | // setups both create immediately. In multi-project setups the label is only |
| 3249 | // the picker's shortcut: falling back to chat.newLocal would advertise the | ||
| 3250 | // same shortcut for both the picker and direct create. In single-project | ||
| 3251 | // setups both commands create directly, so chat.newLocal is a valid | ||
| 3252 | // fallback. The second tooltip line (multi-project only) advertises | ||
| 3253 | // shift+click and its keyboard twin chat.newLocal for direct create. | ||
| 3250 | const newThreadShortcutLabel = | 3254 | const newThreadShortcutLabel = |
| 3251 | scopedProjectGroup === null | 3255 | shortcutLabelForCommand(keybindings, "chat.new") ?? |
| 3252 | ? (shortcutLabelForCommand(keybindings, "chat.new") ?? | 3256 | (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); |
| 3253 | (projectGroups.length <= 1 | ||
| 3254 | ? shortcutLabelForCommand(keybindings, "chat.newLocal") | ||
| 3255 | : undefined)) | ||
| 3256 | : undefined; | ||
| 3257 | const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); | 3257 | const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); |
| 3258 | return ( | 3258 | return ( |
| 3259 | <> | 3259 | <> |
| ... | @@ -3332,9 +3332,7 @@ export default function Sidebar() { | ... | @@ -3332,9 +3332,7 @@ export default function Sidebar() { |
| 3332 | /> | 3332 | /> |
| 3333 | </TooltipTrigger> | 3333 | </TooltipTrigger> |
| 3334 | <TooltipPopup side="right"> | 3334 | <TooltipPopup side="right"> |
| 3335 | {scopedProjectGroup ? ( | 3335 | {projectGroups.length > 1 ? ( |
| 3336 | `New thread in ${scopedProjectGroup.displayName}` | ||
| 3337 | ) : projectGroups.length > 1 ? ( | ||
| 3338 | <span className="flex flex-col gap-0.5"> | 3336 | <span className="flex flex-col gap-0.5"> |
| 3339 | <span> | 3337 | <span> |
| 3340 | {newThreadShortcutLabel | 3338 | {newThreadShortcutLabel |
apps/web/src/components/ThreadTerminalDrawer.tsx+308-356| ... | @@ -5,11 +5,11 @@ import { | ... | @@ -5,11 +5,11 @@ import { |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; | 6 | import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; |
| 7 | import { | 7 | import { |
| 8 | PanelBottomCloseIcon, | ||
| 9 | Plus, | 8 | Plus, |
| 10 | SquareSplitHorizontal, | 9 | SquareSplitHorizontal, |
| 11 | SquareSplitVertical, | 10 | SquareSplitVertical, |
| 12 | TerminalSquare, | 11 | TerminalSquare, |
| 12 | Trash2, | ||
| 13 | XIcon, | 13 | XIcon, |
| 14 | } from "lucide-react"; | 14 | } from "lucide-react"; |
| 15 | import { | 15 | import { |
| ... | @@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ... | @@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 21 | import * as Schema from "effect/Schema"; | 21 | import * as Schema from "effect/Schema"; |
| 22 | import { | 22 | import { |
| 23 | type PointerEvent as ReactPointerEvent, | 23 | type PointerEvent as ReactPointerEvent, |
| 24 | type ReactNode, | ||
| 24 | type SetStateAction, | 25 | type SetStateAction, |
| 25 | useCallback, | 26 | useCallback, |
| 26 | useEffect, | 27 | useEffect, |
| ... | @@ -29,9 +30,9 @@ import { | ... | @@ -29,9 +30,9 @@ import { |
| 29 | useRef, | 30 | useRef, |
| 30 | useState, | 31 | useState, |
| 31 | } from "react"; | 32 | } from "react"; |
| 33 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | ||
| 32 | import { Button } from "~/components/ui/button"; | 34 | import { Button } from "~/components/ui/button"; |
| 33 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 35 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | import { useResizableWidth } from "~/hooks/useResizableWidth"; | ||
| 35 | import { cn } from "~/lib/utils"; | 36 | import { cn } from "~/lib/utils"; |
| 36 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | 37 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 37 | import { | 38 | import { |
| ... | @@ -59,7 +60,6 @@ import { | ... | @@ -59,7 +60,6 @@ import { |
| 59 | import { readLocalApi } from "~/localApi"; | 60 | import { readLocalApi } from "~/localApi"; |
| 60 | import { useClientSettings } from "../hooks/useSettings"; | 61 | import { useClientSettings } from "../hooks/useSettings"; |
| 61 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 62 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 62 | import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 63 | import { useAttachedTerminalSession } from "../state/terminalSessions"; | 63 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 64 | import { serverEnvironment } from "../state/server"; | 64 | import { serverEnvironment } from "../state/server"; |
| 65 | import { previewEnvironment } from "../state/preview"; | 65 | import { previewEnvironment } from "../state/preview"; |
| ... | @@ -72,15 +72,10 @@ import { | ... | @@ -72,15 +72,10 @@ import { |
| 72 | resolveTerminalFontSizePreference, | 72 | resolveTerminalFontSizePreference, |
| 73 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, | 73 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 74 | } from "../appearanceFonts"; | 74 | } from "../appearanceFonts"; |
| 75 | import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; | ||
| 76 | 75 | ||
| 77 | const MIN_DRAWER_HEIGHT = 180; | 76 | const MIN_DRAWER_HEIGHT = 180; |
| 78 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 77 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| 79 | const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; | 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 | function maxDrawerHeight(): number { | 80 | function maxDrawerHeight(): number { |
| 86 | if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; | 81 | if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; |
| ... | @@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp( | ... | @@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp( |
| 249 | return selectionGestureActive && button === 0; | 244 | return selectionGestureActive && button === 0; |
| 250 | } | 245 | } |
| 251 | 246 | ||
| 252 | export function shouldShowTerminalSidebar(terminalCount: number): boolean { | ||
| 253 | return terminalCount > 1; | ||
| 254 | } | ||
| 255 | |||
| 256 | export function terminalSelectionLineRange(position: { | 247 | export function terminalSelectionLineRange(position: { |
| 257 | start: { y: number }; | 248 | start: { y: number }; |
| 258 | end: { y: number }; | 249 | end: { y: number }; |
| ... | @@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps { | ... | @@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps { |
| 885 | onSplitTerminal: () => void; | 876 | onSplitTerminal: () => void; |
| 886 | onSplitTerminalVertical: () => void; | 877 | onSplitTerminalVertical: () => void; |
| 887 | onNewTerminal: () => void; | 878 | onNewTerminal: () => void; |
| 888 | onHide?: () => void; | ||
| 889 | splitShortcutLabel?: string | undefined; | 879 | splitShortcutLabel?: string | undefined; |
| 890 | splitVerticalShortcutLabel?: string | undefined; | 880 | splitVerticalShortcutLabel?: string | undefined; |
| 891 | newShortcutLabel?: string | undefined; | 881 | newShortcutLabel?: string | undefined; |
| ... | @@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps { | ... | @@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps { |
| 901 | terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>; | 891 | terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>; |
| 902 | } | 892 | } |
| 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 | export default function ThreadTerminalDrawer({ | 923 | export default function ThreadTerminalDrawer({ |
| 905 | mode = "drawer", | 924 | mode = "drawer", |
| 906 | threadRef, | 925 | threadRef, |
| ... | @@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({ |
| 918 | onSplitTerminal, | 937 | onSplitTerminal, |
| 919 | onSplitTerminalVertical, | 938 | onSplitTerminalVertical, |
| 920 | onNewTerminal, | 939 | onNewTerminal, |
| 921 | onHide, | ||
| 922 | splitShortcutLabel, | 940 | splitShortcutLabel, |
| 923 | splitVerticalShortcutLabel, | 941 | splitVerticalShortcutLabel, |
| 924 | newShortcutLabel, | 942 | newShortcutLabel, |
| ... | @@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({ |
| 932 | terminalLaunchLocationsById, | 950 | terminalLaunchLocationsById, |
| 933 | }: ThreadTerminalDrawerProps) { | 951 | }: ThreadTerminalDrawerProps) { |
| 934 | const isPanel = mode === "panel"; | 952 | const isPanel = mode === "panel"; |
| 935 | const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = | ||
| 936 | useResizableWidth({ | ||
| 937 | storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, | ||
| 938 | defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, | ||
| 939 | minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, | ||
| 940 | maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, | ||
| 941 | edge: "left", | ||
| 942 | }); | ||
| 943 | const terminalCustomLabels = useTerminalUiStateStore((state) => | ||
| 944 | selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), | ||
| 945 | ); | ||
| 946 | const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); | ||
| 947 | const [renamingTerminalId, setRenamingTerminalId] = useState<string | null>(null); | ||
| 948 | const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); | ||
| 949 | const cancelTerminalRenameRef = useRef(false); | ||
| 950 | const [advancedTypography] = useLocalStorage( | 953 | const [advancedTypography] = useLocalStorage( |
| 951 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, | 954 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 952 | false, | 955 | false, |
| ... | @@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({ |
| 1095 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); | 1098 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 1096 | const splitDirection = | 1099 | const splitDirection = |
| 1097 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; | 1100 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; |
| 1098 | const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); | 1101 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 1099 | const isSplitView = visibleTerminalIds.length > 1; | 1102 | const isSplitView = visibleTerminalIds.length > 1; |
| 1103 | const showGroupHeaders = | ||
| 1104 | resolvedTerminalGroups.length > 1 || | ||
| 1105 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); | ||
| 1100 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; | 1106 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; |
| 1101 | const automaticTerminalLabelById = useMemo(() => { | 1107 | const terminalLabelById = useMemo(() => { |
| 1102 | const next = new Map<string, string>(); | 1108 | const next = new Map<string, string>(); |
| 1103 | for (const terminalId of normalizedTerminalIds) { | 1109 | for (const terminalId of normalizedTerminalIds) { |
| 1104 | next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); | 1110 | next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); |
| 1105 | } | 1111 | } |
| 1106 | return next; | 1112 | return next; |
| 1107 | }, [normalizedTerminalIds, terminalLabelsById]); | 1113 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 1108 | const terminalLabelById = useMemo(() => { | ||
| 1109 | const next = new Map<string, string>(); | ||
| 1110 | for (const terminalId of normalizedTerminalIds) { | ||
| 1111 | next.set( | ||
| 1112 | terminalId, | ||
| 1113 | terminalCustomLabels[terminalId]?.trim() || | ||
| 1114 | automaticTerminalLabelById.get(terminalId) || | ||
| 1115 | getTerminalLabel(terminalId), | ||
| 1116 | ); | ||
| 1117 | } | ||
| 1118 | return next; | ||
| 1119 | }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); | ||
| 1120 | const resolveTerminalLaunchLocation = useCallback( | 1114 | const resolveTerminalLaunchLocation = useCallback( |
| 1121 | (terminalId: string): TerminalLaunchLocation => { | 1115 | (terminalId: string): TerminalLaunchLocation => { |
| 1122 | return ( | 1116 | return ( |
| ... | @@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({ |
| 1129 | }, | 1123 | }, |
| 1130 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], | 1124 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], |
| 1131 | ); | 1125 | ); |
| 1132 | const newTerminalActionLabel = newShortcutLabel | ||
| 1133 | ? `New Terminal (${newShortcutLabel})` | ||
| 1134 | : "New Terminal"; | ||
| 1135 | const splitTerminalActionLabel = hasReachedSplitLimit | 1126 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 1136 | ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` | 1127 | ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| 1137 | : splitShortcutLabel | 1128 | : splitShortcutLabel |
| ... | @@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({ |
| 1142 | : splitVerticalShortcutLabel | 1133 | : splitVerticalShortcutLabel |
| 1143 | ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` | 1134 | ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` |
| 1144 | : "Split Terminal Vertically"; | 1135 | : "Split Terminal Vertically"; |
| 1136 | const newTerminalActionLabel = newShortcutLabel | ||
| 1137 | ? `New Terminal (${newShortcutLabel})` | ||
| 1138 | : "New Terminal"; | ||
| 1145 | const closeTerminalActionLabel = closeShortcutLabel | 1139 | const closeTerminalActionLabel = closeShortcutLabel |
| 1146 | ? `Close Terminal (${closeShortcutLabel})` | 1140 | ? `Close Terminal (${closeShortcutLabel})` |
| 1147 | : "Close Terminal"; | 1141 | : "Close Terminal"; |
| ... | @@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({ |
| 1153 | if (hasReachedSplitLimit) return; | 1147 | if (hasReachedSplitLimit) return; |
| 1154 | onSplitTerminalVertical(); | 1148 | onSplitTerminalVertical(); |
| 1155 | }, [hasReachedSplitLimit, onSplitTerminalVertical]); | 1149 | }, [hasReachedSplitLimit, onSplitTerminalVertical]); |
| 1156 | const startTerminalRename = useCallback( | 1150 | const onNewTerminalAction = useCallback(() => { |
| 1157 | (terminalId: string) => { | 1151 | onNewTerminal(); |
| 1158 | cancelTerminalRenameRef.current = false; | 1152 | }, [onNewTerminal]); |
| 1159 | setRenamingTerminalId(terminalId); | ||
| 1160 | setTerminalRenameDraft( | ||
| 1161 | terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", | ||
| 1162 | ); | ||
| 1163 | }, | ||
| 1164 | [terminalCustomLabels, terminalLabelById], | ||
| 1165 | ); | ||
| 1166 | const finishTerminalRename = useCallback(() => { | ||
| 1167 | if (!renamingTerminalId) return; | ||
| 1168 | const nextLabel = terminalRenameDraft.trim(); | ||
| 1169 | const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; | ||
| 1170 | setTerminalCustomLabel( | ||
| 1171 | threadRef, | ||
| 1172 | renamingTerminalId, | ||
| 1173 | nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, | ||
| 1174 | ); | ||
| 1175 | setRenamingTerminalId(null); | ||
| 1176 | }, [ | ||
| 1177 | automaticTerminalLabelById, | ||
| 1178 | renamingTerminalId, | ||
| 1179 | setTerminalCustomLabel, | ||
| 1180 | terminalRenameDraft, | ||
| 1181 | threadRef, | ||
| 1182 | ]); | ||
| 1183 | const cancelTerminalRename = useCallback(() => { | ||
| 1184 | cancelTerminalRenameRef.current = true; | ||
| 1185 | setRenamingTerminalId(null); | ||
| 1186 | }, []); | ||
| 1187 | |||
| 1188 | useEffect(() => { | ||
| 1189 | cancelTerminalRenameRef.current = false; | ||
| 1190 | setRenamingTerminalId(null); | ||
| 1191 | setTerminalRenameDraft(""); | ||
| 1192 | }, [threadRef.environmentId, threadRef.threadId]); | ||
| 1193 | 1153 | ||
| 1194 | useEffect(() => { | 1154 | useEffect(() => { |
| 1195 | onHeightChangeRef.current = onHeightChange; | 1155 | onHeightChangeRef.current = onHeightChange; |
| ... | @@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({ |
| 1314 | ) : null} | 1274 | ) : null} |
| 1315 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> | 1275 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> |
| 1316 | <p>No terminal sessions for this thread yet.</p> | 1276 | <p>No terminal sessions for this thread yet.</p> |
| 1317 | <Button size="xs" variant="outline" onClick={onNewTerminal}> | 1277 | <Button size="xs" variant="outline" onClick={onNewTerminalAction}> |
| 1318 | {newTerminalActionLabel} | 1278 | {newTerminalActionLabel} |
| 1319 | </Button> | 1279 | </Button> |
| 1320 | </div> | 1280 | </div> |
| ... | @@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({ |
| 1323 | } | 1283 | } |
| 1324 | 1284 | ||
| 1325 | const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); | 1285 | const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); |
| 1326 | const compactTerminalToolbar = ( | 1286 | |
| 1327 | <> | ||
| 1328 | <Button | ||
| 1329 | type="button" | ||
| 1330 | size="icon-xs" | ||
| 1331 | variant="ghost" | ||
| 1332 | className="rounded-md" | ||
| 1333 | disabled={hasReachedSplitLimit} | ||
| 1334 | onClick={onSplitTerminalAction} | ||
| 1335 | aria-label={splitTerminalActionLabel} | ||
| 1336 | title={splitTerminalActionLabel} | ||
| 1337 | > | ||
| 1338 | <SquareSplitHorizontal className="size-3.5" /> | ||
| 1339 | </Button> | ||
| 1340 | <Button | ||
| 1341 | type="button" | ||
| 1342 | size="icon-xs" | ||
| 1343 | variant="ghost" | ||
| 1344 | className="rounded-md" | ||
| 1345 | disabled={hasReachedSplitLimit} | ||
| 1346 | onClick={onSplitTerminalVerticalAction} | ||
| 1347 | aria-label={splitTerminalVerticalActionLabel} | ||
| 1348 | title={splitTerminalVerticalActionLabel} | ||
| 1349 | > | ||
| 1350 | <SquareSplitVertical className="size-3.5" /> | ||
| 1351 | </Button> | ||
| 1352 | <Button | ||
| 1353 | type="button" | ||
| 1354 | size="icon-xs" | ||
| 1355 | variant="ghost" | ||
| 1356 | className="rounded-md" | ||
| 1357 | onClick={onNewTerminal} | ||
| 1358 | aria-label={newTerminalActionLabel} | ||
| 1359 | title={newTerminalActionLabel} | ||
| 1360 | > | ||
| 1361 | <Plus className="size-3.5" /> | ||
| 1362 | </Button> | ||
| 1363 | <Button | ||
| 1364 | type="button" | ||
| 1365 | size="icon-xs" | ||
| 1366 | variant="ghost" | ||
| 1367 | className="rounded-md" | ||
| 1368 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | ||
| 1369 | aria-label={closeTerminalActionLabel} | ||
| 1370 | title={closeTerminalActionLabel} | ||
| 1371 | > | ||
| 1372 | <XIcon className="size-3.5" /> | ||
| 1373 | </Button> | ||
| 1374 | {!isPanel && onHide ? ( | ||
| 1375 | <> | ||
| 1376 | <span aria-hidden className="mx-0.5 h-4 w-px bg-border/70" /> | ||
| 1377 | <Button | ||
| 1378 | type="button" | ||
| 1379 | size="icon-xs" | ||
| 1380 | variant="ghost" | ||
| 1381 | className="rounded-md" | ||
| 1382 | onClick={onHide} | ||
| 1383 | aria-label="Hide terminal drawer" | ||
| 1384 | title="Hide terminal drawer" | ||
| 1385 | > | ||
| 1386 | <PanelBottomCloseIcon className="size-3.5" /> | ||
| 1387 | </Button> | ||
| 1388 | </> | ||
| 1389 | ) : null} | ||
| 1390 | </> | ||
| 1391 | ); | ||
| 1392 | return ( | 1287 | return ( |
| 1393 | <aside | 1288 | <aside |
| 1394 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} | 1289 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} |
| ... | @@ -1408,220 +1303,277 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1408,220 +1303,277 @@ export default function ThreadTerminalDrawer({ |
| 1408 | /> | 1303 | /> |
| 1409 | ) : null} | 1304 | ) : null} |
| 1410 | 1305 | ||
| 1411 | {!hasTerminalSidebar ? ( | 1306 | {!hasTerminalSidebar && ( |
| 1412 | <div className="pointer-events-none absolute top-2 right-2 z-20"> | 1307 | <div className="pointer-events-none absolute right-2 top-2 z-20"> |
| 1413 | <div className="pointer-events-auto flex h-8 items-center rounded-lg border border-border/70 bg-background p-0.5 shadow-xs"> | 1308 | <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs"> |
| 1414 | {compactTerminalToolbar} | 1309 | <TerminalActionButton |
| 1310 | className={`p-1 text-foreground/90 transition-colors ${ | ||
| 1311 | hasReachedSplitLimit | ||
| 1312 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | ||
| 1313 | : "hover:bg-accent" | ||
| 1314 | }`} | ||
| 1315 | onClick={onSplitTerminalAction} | ||
| 1316 | label={splitTerminalActionLabel} | ||
| 1317 | > | ||
| 1318 | <SquareSplitHorizontal className="size-3.25" /> | ||
| 1319 | </TerminalActionButton> | ||
| 1320 | <div className="h-4 w-px bg-border/80" /> | ||
| 1321 | <TerminalActionButton | ||
| 1322 | className={`p-1 text-foreground/90 transition-colors ${ | ||
| 1323 | hasReachedSplitLimit | ||
| 1324 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | ||
| 1325 | : "hover:bg-accent" | ||
| 1326 | }`} | ||
| 1327 | onClick={onSplitTerminalVerticalAction} | ||
| 1328 | label={splitTerminalVerticalActionLabel} | ||
| 1329 | > | ||
| 1330 | <SquareSplitVertical className="size-3.25" /> | ||
| 1331 | </TerminalActionButton> | ||
| 1332 | <div className="h-4 w-px bg-border/80" /> | ||
| 1333 | <TerminalActionButton | ||
| 1334 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | ||
| 1335 | onClick={onNewTerminalAction} | ||
| 1336 | label={newTerminalActionLabel} | ||
| 1337 | > | ||
| 1338 | <Plus className="size-3.25" /> | ||
| 1339 | </TerminalActionButton> | ||
| 1340 | <div className="h-4 w-px bg-border/80" /> | ||
| 1341 | <TerminalActionButton | ||
| 1342 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | ||
| 1343 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | ||
| 1344 | label={closeTerminalActionLabel} | ||
| 1345 | > | ||
| 1346 | <Trash2 className="size-3.25" /> | ||
| 1347 | </TerminalActionButton> | ||
| 1415 | </div> | 1348 | </div> |
| 1416 | </div> | 1349 | </div> |
| 1417 | ) : null} | 1350 | )} |
| 1418 | 1351 | ||
| 1419 | <div className="flex min-h-0 w-full flex-1"> | 1352 | <div className="min-h-0 w-full flex-1"> |
| 1420 | <div className="min-w-0 flex-1"> | 1353 | <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}> |
| 1421 | {isSplitView ? ( | 1354 | <div className="min-w-0 flex-1"> |
| 1422 | <div | 1355 | {isSplitView ? ( |
| 1423 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" | 1356 | <div |
| 1424 | style={ | 1357 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" |
| 1425 | splitDirection === "vertical" | 1358 | style={ |
| 1426 | ? { | 1359 | splitDirection === "vertical" |
| 1427 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | 1360 | ? { |
| 1428 | } | 1361 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, |
| 1429 | : { | 1362 | } |
| 1430 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | 1363 | : { |
| 1431 | } | 1364 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, |
| 1432 | } | 1365 | } |
| 1433 | > | 1366 | } |
| 1434 | {visibleTerminalIds.map((terminalId) => { | 1367 | > |
| 1435 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); | 1368 | {visibleTerminalIds.map((terminalId) => { |
| 1436 | const isActive = terminalId === resolvedActiveTerminalId; | 1369 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); |
| 1437 | return ( | 1370 | return ( |
| 1438 | <div | 1371 | <div |
| 1439 | key={terminalId} | 1372 | key={terminalId} |
| 1440 | className={cn( | 1373 | className={`min-h-0 min-w-0 ${ |
| 1441 | "min-h-0 min-w-0", | 1374 | splitDirection === "vertical" |
| 1442 | splitDirection === "vertical" | 1375 | ? "border-t first:border-t-0" |
| 1443 | ? "border-t first:border-t-0" | 1376 | : "border-l first:border-l-0" |
| 1444 | : "border-l first:border-l-0", | 1377 | } ${ |
| 1445 | isActive ? "border-border" : "border-border/70", | 1378 | terminalId === resolvedActiveTerminalId |
| 1446 | )} | 1379 | ? "border-border" |
| 1447 | onMouseDown={() => { | 1380 | : "border-border/70" |
| 1448 | if (!isActive) onActiveTerminalChange(terminalId); | 1381 | }`} |
| 1449 | }} | 1382 | onMouseDown={() => { |
| 1450 | > | 1383 | if (terminalId !== resolvedActiveTerminalId) { |
| 1451 | <div className="h-full p-1"> | 1384 | onActiveTerminalChange(terminalId); |
| 1452 | <TerminalViewport | 1385 | } |
| 1453 | advancedTypography={advancedTypography} | 1386 | }} |
| 1454 | threadRef={threadRef} | 1387 | > |
| 1455 | threadId={threadId} | 1388 | <div className="h-full p-1"> |
| 1456 | terminalId={terminalId} | 1389 | <TerminalViewport |
| 1457 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | 1390 | advancedTypography={advancedTypography} |
| 1458 | cwd={terminalLaunchLocation.cwd} | 1391 | threadRef={threadRef} |
| 1459 | {...(terminalLaunchLocation.worktreePath !== undefined | 1392 | threadId={threadId} |
| 1460 | ? { worktreePath: terminalLaunchLocation.worktreePath } | 1393 | terminalId={terminalId} |
| 1461 | : {})} | 1394 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1462 | {...(terminalLaunchLocation.runtimeEnv | 1395 | cwd={terminalLaunchLocation.cwd} |
| 1463 | ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } | 1396 | {...(terminalLaunchLocation.worktreePath !== undefined |
| 1464 | : {})} | 1397 | ? { worktreePath: terminalLaunchLocation.worktreePath } |
| 1465 | onSessionExited={() => onCloseTerminal(terminalId)} | 1398 | : {})} |
| 1466 | onAddTerminalContext={onAddTerminalContext} | 1399 | {...(terminalLaunchLocation.runtimeEnv |
| 1467 | focusRequestId={focusRequestId} | 1400 | ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } |
| 1468 | autoFocus={isActive} | 1401 | : {})} |
| 1469 | resizeEpoch={resizeEpoch} | 1402 | onSessionExited={() => onCloseTerminal(terminalId)} |
| 1470 | drawerHeight={drawerHeight} | 1403 | onAddTerminalContext={onAddTerminalContext} |
| 1471 | keybindings={keybindings} | 1404 | focusRequestId={focusRequestId} |
| 1472 | /> | 1405 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 1406 | resizeEpoch={resizeEpoch} | ||
| 1407 | drawerHeight={drawerHeight} | ||
| 1408 | keybindings={keybindings} | ||
| 1409 | /> | ||
| 1410 | </div> | ||
| 1473 | </div> | 1411 | </div> |
| 1474 | </div> | 1412 | ); |
| 1475 | ); | 1413 | })} |
| 1476 | })} | 1414 | </div> |
| 1477 | </div> | 1415 | ) : ( |
| 1478 | ) : ( | 1416 | <div className="h-full p-1"> |
| 1479 | <div className="h-full p-1"> | 1417 | <TerminalViewport |
| 1480 | <TerminalViewport | 1418 | advancedTypography={advancedTypography} |
| 1481 | advancedTypography={advancedTypography} | 1419 | key={resolvedActiveTerminalId} |
| 1482 | key={resolvedActiveTerminalId} | 1420 | threadRef={threadRef} |
| 1483 | threadRef={threadRef} | 1421 | threadId={threadId} |
| 1484 | threadId={threadId} | 1422 | terminalId={resolvedActiveTerminalId} |
| 1485 | terminalId={resolvedActiveTerminalId} | 1423 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} |
| 1486 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | 1424 | cwd={activeTerminalLaunchLocation.cwd} |
| 1487 | cwd={activeTerminalLaunchLocation.cwd} | 1425 | {...(activeTerminalLaunchLocation.worktreePath !== undefined |
| 1488 | {...(activeTerminalLaunchLocation.worktreePath !== undefined | 1426 | ? { worktreePath: activeTerminalLaunchLocation.worktreePath } |
| 1489 | ? { worktreePath: activeTerminalLaunchLocation.worktreePath } | 1427 | : {})} |
| 1490 | : {})} | 1428 | {...(activeTerminalLaunchLocation.runtimeEnv |
| 1491 | {...(activeTerminalLaunchLocation.runtimeEnv | 1429 | ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } |
| 1492 | ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } | 1430 | : {})} |
| 1493 | : {})} | 1431 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 1494 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} | 1432 | onAddTerminalContext={onAddTerminalContext} |
| 1495 | onAddTerminalContext={onAddTerminalContext} | 1433 | focusRequestId={focusRequestId} |
| 1496 | focusRequestId={focusRequestId} | 1434 | autoFocus |
| 1497 | autoFocus | 1435 | resizeEpoch={resizeEpoch} |
| 1498 | resizeEpoch={resizeEpoch} | 1436 | drawerHeight={drawerHeight} |
| 1499 | drawerHeight={drawerHeight} | 1437 | keybindings={keybindings} |
| 1500 | keybindings={keybindings} | 1438 | /> |
| 1501 | /> | 1439 | </div> |
| 1502 | </div> | 1440 | )} |
| 1503 | )} | ||
| 1504 | </div> | ||
| 1505 | |||
| 1506 | <aside | ||
| 1507 | className={cn( | ||
| 1508 | "relative flex shrink-0 flex-col border-l border-border/70 bg-muted/10", | ||
| 1509 | !hasTerminalSidebar && "hidden", | ||
| 1510 | )} | ||
| 1511 | style={{ width: `${terminalSidebarWidth}px` }} | ||
| 1512 | > | ||
| 1513 | <RightPanelResizeHandle handlers={terminalSidebarResizeHandlers} /> | ||
| 1514 | <div className="flex h-9 shrink-0 items-center justify-center border-b border-border/70 px-2"> | ||
| 1515 | {hasTerminalSidebar ? compactTerminalToolbar : null} | ||
| 1516 | </div> | 1441 | </div> |
| 1517 | 1442 | ||
| 1518 | <div className="min-h-0 flex-1 overflow-y-auto p-1"> | 1443 | {hasTerminalSidebar && ( |
| 1519 | {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { | 1444 | <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10"> |
| 1520 | const isSplitGroup = terminalGroup.terminalIds.length > 1; | 1445 | <div className="flex h-[22px] items-stretch justify-end border-b border-border/70"> |
| 1521 | const splitVertically = terminalGroup.splitDirection === "vertical"; | 1446 | <div className="inline-flex h-full items-stretch"> |
| 1522 | const SplitIcon = splitVertically ? SquareSplitVertical : SquareSplitHorizontal; | 1447 | <TerminalActionButton |
| 1523 | return ( | 1448 | className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${ |
| 1524 | <div | 1449 | hasReachedSplitLimit |
| 1525 | key={terminalGroup.id} | 1450 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" |
| 1526 | className={cn( | 1451 | : "hover:bg-accent/70" |
| 1527 | groupIndex > 0 && "mt-1", | 1452 | }`} |
| 1528 | isSplitGroup && "rounded-lg border border-border/80 bg-muted/20 p-1", | 1453 | onClick={onSplitTerminalAction} |
| 1529 | )} | 1454 | label={splitTerminalActionLabel} |
| 1530 | > | 1455 | > |
| 1531 | {isSplitGroup ? ( | 1456 | <SquareSplitHorizontal className="size-3.25" /> |
| 1532 | <div className="flex h-5 items-center gap-1 px-1 text-[10px] font-medium text-muted-foreground"> | 1457 | </TerminalActionButton> |
| 1533 | <SplitIcon className="size-3" /> | 1458 | <TerminalActionButton |
| 1534 | <span>{splitVertically ? "Split down" : "Split right"}</span> | 1459 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ |
| 1535 | </div> | 1460 | hasReachedSplitLimit |
| 1536 | ) : null} | 1461 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" |
| 1537 | <div className={cn(isSplitGroup && "space-y-0.5")}> | 1462 | : "hover:bg-accent/70" |
| 1538 | {terminalGroup.terminalIds.map((terminalId) => { | 1463 | }`} |
| 1539 | const isActive = terminalId === resolvedActiveTerminalId; | 1464 | onClick={onSplitTerminalVerticalAction} |
| 1540 | const automaticLabel = | 1465 | label={splitTerminalVerticalActionLabel} |
| 1541 | automaticTerminalLabelById.get(terminalId) ?? "Terminal"; | 1466 | > |
| 1542 | const customLabel = terminalCustomLabels[terminalId]?.trim() ?? ""; | 1467 | <SquareSplitVertical className="size-3.25" /> |
| 1543 | const displayLabel = terminalLabelById.get(terminalId) ?? automaticLabel; | 1468 | </TerminalActionButton> |
| 1544 | const isRenaming = renamingTerminalId === terminalId; | 1469 | <TerminalActionButton |
| 1545 | return ( | 1470 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 1546 | <div | 1471 | onClick={onNewTerminalAction} |
| 1547 | key={terminalId} | 1472 | label={newTerminalActionLabel} |
| 1548 | className={cn( | 1473 | > |
| 1549 | "group/tab flex h-6 items-center gap-0.5 rounded-md pr-2 text-xs", | 1474 | <Plus className="size-3.25" /> |
| 1550 | isSplitGroup ? "pl-0.5" : "pl-1.5", | 1475 | </TerminalActionButton> |
| 1551 | isActive | 1476 | <TerminalActionButton |
| 1552 | ? "bg-accent text-foreground" | 1477 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 1553 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", | 1478 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 1554 | )} | 1479 | label={closeTerminalActionLabel} |
| 1480 | > | ||
| 1481 | <Trash2 className="size-3.25" /> | ||
| 1482 | </TerminalActionButton> | ||
| 1483 | </div> | ||
| 1484 | </div> | ||
| 1485 | |||
| 1486 | <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1"> | ||
| 1487 | {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { | ||
| 1488 | const isGroupActive = | ||
| 1489 | terminalGroup.terminalIds.includes(resolvedActiveTerminalId); | ||
| 1490 | const groupActiveTerminalId = isGroupActive | ||
| 1491 | ? resolvedActiveTerminalId | ||
| 1492 | : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId); | ||
| 1493 | |||
| 1494 | return ( | ||
| 1495 | <div key={terminalGroup.id} className="pb-0.5"> | ||
| 1496 | {showGroupHeaders && ( | ||
| 1497 | <button | ||
| 1498 | type="button" | ||
| 1499 | className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${ | ||
| 1500 | isGroupActive | ||
| 1501 | ? "bg-accent/70 text-foreground" | ||
| 1502 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" | ||
| 1503 | }`} | ||
| 1504 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} | ||
| 1555 | > | 1505 | > |
| 1556 | <button | 1506 | Group {groupIndex + 1} |
| 1557 | type="button" | 1507 | </button> |
| 1558 | className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" | 1508 | )} |
| 1559 | aria-label={`Close ${displayLabel}`} | 1509 | |
| 1560 | onClick={() => onCloseTerminal(terminalId)} | 1510 | <div |
| 1561 | > | 1511 | className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""} |
| 1562 | <TerminalSquare className="size-3 shrink-0 group-hover/tab:hidden group-focus-visible/close:hidden pointer-coarse:hidden" /> | 1512 | > |
| 1563 | <XIcon className="hidden size-3 group-hover/tab:block group-focus-visible/close:block pointer-coarse:block" /> | 1513 | {terminalGroup.terminalIds.map((terminalId) => { |
| 1564 | </button> | 1514 | const isActive = terminalId === resolvedActiveTerminalId; |
| 1565 | {isRenaming ? ( | 1515 | const closeTerminalLabel = `Close ${ |
| 1566 | <input | 1516 | terminalLabelById.get(terminalId) ?? "terminal" |
| 1567 | autoFocus | 1517 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; |
| 1568 | value={terminalRenameDraft} | 1518 | return ( |
| 1569 | aria-label="Rename terminal" | 1519 | <div |
| 1570 | className="h-6 min-w-0 flex-1 rounded border border-input bg-background px-1.5 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring" | 1520 | key={terminalId} |
| 1571 | onFocus={(event) => event.currentTarget.select()} | 1521 | className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${ |
| 1572 | onChange={(event) => | 1522 | isActive |
| 1573 | setTerminalRenameDraft(event.currentTarget.value) | 1523 | ? "bg-accent text-foreground" |
| 1574 | } | 1524 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" |
| 1575 | onBlur={() => { | 1525 | }`} |
| 1576 | if (cancelTerminalRenameRef.current) { | ||
| 1577 | cancelTerminalRenameRef.current = false; | ||
| 1578 | return; | ||
| 1579 | } | ||
| 1580 | finishTerminalRename(); | ||
| 1581 | }} | ||
| 1582 | onKeyDown={(event) => { | ||
| 1583 | event.stopPropagation(); | ||
| 1584 | if (event.nativeEvent.isComposing) return; | ||
| 1585 | if (event.key === "Enter") { | ||
| 1586 | event.preventDefault(); | ||
| 1587 | finishTerminalRename(); | ||
| 1588 | } else if (event.key === "Escape") { | ||
| 1589 | event.preventDefault(); | ||
| 1590 | cancelTerminalRename(); | ||
| 1591 | } | ||
| 1592 | }} | ||
| 1593 | /> | ||
| 1594 | ) : ( | ||
| 1595 | <button | ||
| 1596 | type="button" | ||
| 1597 | className="flex min-w-0 flex-1 items-center text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 1598 | title={`${displayLabel}${customLabel && customLabel !== automaticLabel ? ` · ${automaticLabel}` : ""} — ${resolveTerminalLaunchLocation(terminalId).cwd}`} | ||
| 1599 | onClick={() => onActiveTerminalChange(terminalId)} | ||
| 1600 | onDoubleClick={(event) => { | ||
| 1601 | event.preventDefault(); | ||
| 1602 | onActiveTerminalChange(terminalId); | ||
| 1603 | startTerminalRename(terminalId); | ||
| 1604 | }} | ||
| 1605 | > | 1526 | > |
| 1606 | <span className="min-w-0 flex-1 truncate"> | 1527 | {showGroupHeaders && ( |
| 1607 | {displayLabel} | 1528 | <span className="text-[10px] text-muted-foreground/80">└</span> |
| 1608 | {customLabel && customLabel !== automaticLabel ? ( | 1529 | )} |
| 1609 | <span className="font-normal text-muted-foreground/80"> | 1530 | <button |
| 1610 | {` · ${automaticLabel}`} | 1531 | type="button" |
| 1611 | </span> | 1532 | className="flex min-w-0 flex-1 items-center gap-1 text-left" |
| 1612 | ) : null} | 1533 | onClick={() => onActiveTerminalChange(terminalId)} |
| 1613 | </span> | 1534 | > |
| 1614 | </button> | 1535 | <TerminalSquare className="size-3 shrink-0" /> |
| 1615 | )} | 1536 | <span className="truncate"> |
| 1616 | </div> | 1537 | {terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1617 | ); | 1538 | </span> |
| 1618 | })} | 1539 | </button> |
| 1619 | </div> | 1540 | {normalizedTerminalIds.length > 1 && ( |
| 1620 | </div> | 1541 | <Popover> |
| 1621 | ); | 1542 | <PopoverTrigger |
| 1622 | })} | 1543 | openOnHover |
| 1623 | </div> | 1544 | render={ |
| 1624 | </aside> | 1545 | <button |
| 1546 | type="button" | ||
| 1547 | className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | ||
| 1548 | onClick={() => onCloseTerminal(terminalId)} | ||
| 1549 | aria-label={closeTerminalLabel} | ||
| 1550 | /> | ||
| 1551 | } | ||
| 1552 | > | ||
| 1553 | <XIcon className="size-2.5" /> | ||
| 1554 | </PopoverTrigger> | ||
| 1555 | <PopoverPopup | ||
| 1556 | tooltipStyle | ||
| 1557 | side="bottom" | ||
| 1558 | sideOffset={6} | ||
| 1559 | align="center" | ||
| 1560 | className="pointer-events-none select-none" | ||
| 1561 | > | ||
| 1562 | {closeTerminalLabel} | ||
| 1563 | </PopoverPopup> | ||
| 1564 | </Popover> | ||
| 1565 | )} | ||
| 1566 | </div> | ||
| 1567 | ); | ||
| 1568 | })} | ||
| 1569 | </div> | ||
| 1570 | </div> | ||
| 1571 | ); | ||
| 1572 | })} | ||
| 1573 | </div> | ||
| 1574 | </aside> | ||
| 1575 | )} | ||
| 1576 | </div> | ||
| 1625 | </div> | 1577 | </div> |
| 1626 | </aside> | 1578 | </aside> |
| 1627 | ); | 1579 | ); |
apps/web/src/components/WorkspacePageContainer.tsx deleted-62| ... | @@ -1,62 +0,0 @@ | ||
| 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,9 +23,13 @@ describe("ChangedFilesCard", () => { |
| 23 | expect(markup).toContain('data-changed-files-state="expanded"'); | 23 | expect(markup).toContain('data-changed-files-state="expanded"'); |
| 24 | expect(markup).toContain('aria-expanded="true"'); | 24 | expect(markup).toContain('aria-expanded="true"'); |
| 25 | expect(markup).toContain("whitespace-nowrap"); | 25 | expect(markup).toContain("whitespace-nowrap"); |
| 26 | expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); | 26 | expect(markup).toContain( |
| 27 | 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', | ||
| 28 | ); | ||
| 27 | expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); | 29 | expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); |
| 28 | expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff</span>'); | 30 | expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); |
| 31 | expect(markup).toContain("@[24rem]/changed-files:inline"); | ||
| 32 | expect(markup).not.toContain("sm:inline"); | ||
| 29 | expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); | 33 | expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); |
| 30 | expect(markup).toContain("!size-[22px]"); | 34 | expect(markup).toContain("!size-[22px]"); |
| 31 | expect(markup).toContain("size-3"); | 35 | expect(markup).toContain("size-3"); |
| ... | @@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => { | ... | @@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => { |
| 34 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); | 38 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); |
| 35 | expect(markup).toContain("1 changed file"); | 39 | expect(markup).toContain("1 changed file"); |
| 36 | expect(markup).not.toContain("1 changed files"); | 40 | expect(markup).not.toContain("1 changed files"); |
| 37 | expect(markup).not.toContain("Hide files"); | ||
| 38 | expect(markup).not.toContain("ml-auto"); | ||
| 39 | }); | 41 | }); |
| 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 | const markup = renderToStaticMarkup( | 44 | const markup = renderToStaticMarkup( |
| 43 | <ChangedFilesCard | 45 | <ChangedFilesCard |
| 44 | turnId={TurnId.make("turn-1")} | 46 | turnId={TurnId.make("turn-1")} |
| ... | @@ -65,15 +67,14 @@ describe("ChangedFilesCard", () => { | ... | @@ -65,15 +67,14 @@ describe("ChangedFilesCard", () => { |
| 65 | 67 | ||
| 66 | expect(markup).toContain('data-changed-files-state="preview"'); | 68 | expect(markup).toContain('data-changed-files-state="preview"'); |
| 67 | expect(markup).toContain('aria-expanded="false"'); | 69 | expect(markup).toContain('aria-expanded="false"'); |
| 68 | expect(markup).toContain("apps/web/src/"); | 70 | expect(markup).toContain("apps"); |
| 69 | expect(markup).toContain("packages/shared/src/"); | 71 | expect(markup).toContain("2 files"); |
| 72 | expect(markup).toContain("packages"); | ||
| 73 | expect(markup).toContain("root"); | ||
| 70 | expect(markup).toContain("App.tsx"); | 74 | expect(markup).toContain("App.tsx"); |
| 71 | expect(markup).toContain("git.ts"); | 75 | expect(markup).toContain("git.ts"); |
| 72 | expect(markup).toContain("README.md"); | 76 | expect(markup).toContain("README.md"); |
| 73 | expect(markup).not.toContain("basis-0"); | 77 | expect(markup).toContain("Show all 4 files"); |
| 74 | expect(markup).not.toContain("+1 more"); | ||
| 75 | expect(markup).not.toContain("Show files"); | ||
| 76 | expect(markup).toContain('aria-label="120 additions, 20 deletions"'); | ||
| 77 | expect(markup).not.toContain("App.test.tsx"); | 78 | expect(markup).not.toContain("App.test.tsx"); |
| 78 | }); | 79 | }); |
| 79 | 80 |
apps/web/src/components/chat/ChangedFilesTree.tsx+39-23| ... | @@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | ... | @@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 20 | import { Button } from "../ui/button"; | 20 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 25 | 29 | ||
| ... | @@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 46 | onOpenTurnDiff, | 50 | onOpenTurnDiff, |
| 47 | } = props; | 51 | } = props; |
| 48 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); | 52 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 53 | const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); | ||
| 49 | const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); | 54 | const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); |
| 50 | const compactPreviewVisible = showCompactPreview && !expanded; | 55 | const compactPreviewVisible = showCompactPreview && !expanded; |
| 51 | 56 | ||
| 52 | return ( | 57 | return ( |
| 53 | <div | 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 | data-changed-files-state={ | 60 | data-changed-files-state={ |
| 56 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" | 61 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" |
| 57 | } | 62 | } |
| ... | @@ -59,7 +64,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -59,7 +64,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 59 | <div | 64 | <div |
| 60 | data-changed-files-header="" | 65 | data-changed-files-header="" |
| 61 | className={cn( | 66 | className={cn( |
| 62 | "flex items-center justify-between gap-2 rounded-lg px-1", | 67 | "flex items-center justify-between gap-2 rounded-xl px-1", |
| 63 | expanded && | 68 | expanded && |
| 64 | "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", | 69 | "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", |
| 65 | )} | 70 | )} |
| ... | @@ -68,7 +73,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -68,7 +73,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 68 | type="button" | 73 | type="button" |
| 69 | aria-expanded={expanded} | 74 | aria-expanded={expanded} |
| 70 | data-scroll-anchor-ignore | 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 | onClick={() => onExpandedChange(!expanded)} | 77 | onClick={() => onExpandedChange(!expanded)} |
| 73 | > | 78 | > |
| 74 | <ChevronRightIcon | 79 | <ChevronRightIcon |
| ... | @@ -91,6 +96,9 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -91,6 +96,9 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 91 | /> | 96 | /> |
| 92 | )} | 97 | )} |
| 93 | </span> | 98 | </span> |
| 99 | <span className="ml-1 hidden min-w-0 flex-1 truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 @[24rem]/changed-files:inline"> | ||
| 100 | {expanded ? "Hide files" : "Show files"} | ||
| 101 | </span> | ||
| 94 | </button> | 102 | </button> |
| 95 | <div className="flex shrink-0 items-center gap-1.5"> | 103 | <div className="flex shrink-0 items-center gap-1.5"> |
| 96 | {expanded ? ( | 104 | {expanded ? ( |
| ... | @@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 150 | onOpenTurnDiff={onOpenTurnDiff} | 158 | onOpenTurnDiff={onOpenTurnDiff} |
| 151 | /> | 159 | /> |
| 152 | ) : compactPreviewVisible ? ( | 160 | ) : compactPreviewVisible ? ( |
| 153 | <div className="px-1.5 pb-1 pt-0.5"> | 161 | <div className="px-2 pb-1.5 pt-1"> |
| 154 | <div className="flex min-w-0 items-center gap-x-3 overflow-hidden"> | 162 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground"> |
| 163 | {scopeSummary.map((scope, index) => ( | ||
| 164 | <span key={scope.label} className="inline-flex items-center gap-1"> | ||
| 165 | {index > 0 ? <span aria-hidden="true">·</span> : null} | ||
| 166 | <span className="font-mono text-foreground/75">{scope.label}</span> | ||
| 167 | <span> | ||
| 168 | {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} | ||
| 169 | </span> | ||
| 170 | </span> | ||
| 171 | ))} | ||
| 172 | </p> | ||
| 173 | <div className="mt-2 flex flex-wrap items-center gap-1.5"> | ||
| 155 | {previewFiles.map((file) => ( | 174 | {previewFiles.map((file) => ( |
| 156 | <button | 175 | <button |
| 157 | key={file.path} | 176 | key={file.path} |
| 158 | type="button" | 177 | type="button" |
| 159 | title={file.path} | 178 | title={file.path} |
| 160 | className="group flex min-h-6 min-w-0 max-w-72 shrink items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | 179 | className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 161 | onClick={() => onOpenTurnDiff(turnId, file.path)} | 180 | onClick={() => onOpenTurnDiff(turnId, file.path)} |
| 162 | > | 181 | > |
| 163 | <PierreEntryIcon | 182 | <PierreEntryIcon |
| 164 | pathValue={file.path} | 183 | pathValue={file.path} |
| 165 | kind="file" | 184 | kind="file" |
| 166 | theme={resolvedTheme} | 185 | theme={resolvedTheme} |
| 167 | className="size-3.5 shrink-0 text-muted-foreground/70" | 186 | className="size-3 shrink-0 text-muted-foreground/70" |
| 168 | /> | 187 | /> |
| 169 | <span className="min-w-0 truncate font-mono text-xs text-foreground/75 group-hover:text-foreground"> | 188 | <span className="truncate">{changedFileName(file.path)}</span> |
| 170 | {changedFileName(file.path)} | ||
| 171 | </span> | ||
| 172 | {hasNonZeroStat(file) ? ( | ||
| 173 | <DiffStatLabel | ||
| 174 | additions={file.additions} | ||
| 175 | deletions={file.deletions} | ||
| 176 | layout="inline" | ||
| 177 | className="shrink-0 text-[10px]" | ||
| 178 | /> | ||
| 179 | ) : null} | ||
| 180 | </button> | 189 | </button> |
| 181 | ))} | 190 | ))} |
| 191 | <button | ||
| 192 | type="button" | ||
| 193 | className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 194 | onClick={() => onExpandedChange(true)} | ||
| 195 | > | ||
| 196 | Show all {files.length} files | ||
| 197 | </button> | ||
| 182 | </div> | 198 | </div> |
| 183 | </div> | 199 | </div> |
| 184 | ) : null} | 200 | ) : null} |
| ... | @@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 254 | ) : ( | 270 | ) : ( |
| 255 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | 271 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| 256 | )} | 272 | )} |
| 257 | <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | 273 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> |
| 258 | {node.name} | 274 | {node.name} |
| 259 | </span> | 275 | </span> |
| 260 | {hasNonZeroStat(node.stat) && ( | 276 | {hasNonZeroStat(node.stat) && ( |
| 261 | <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums"> | 277 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 262 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | 278 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 263 | </span> | 279 | </span> |
| 264 | )} | 280 | )} |
| ... | @@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 289 | theme={resolvedTheme} | 305 | theme={resolvedTheme} |
| 290 | className="size-3.5 text-muted-foreground/70" | 306 | className="size-3.5 text-muted-foreground/70" |
| 291 | /> | 307 | /> |
| 292 | <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | 308 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> |
| 293 | {node.name} | 309 | {node.name} |
| 294 | </span> | 310 | </span> |
| 295 | {node.stat && ( | 311 | {node.stat && ( |
| 296 | <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums"> | 312 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 297 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | 313 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 298 | </span> | 314 | </span> |
| 299 | )} | 315 | )} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+7-209| ... | @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { |
| 531 | 531 | ||
| 532 | expect(expandedRows.map((row) => row.id)).toEqual([ | 532 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 533 | "user-entry", | 533 | "user-entry", |
| 534 | "assistant-thought-entry", | ||
| 535 | "work-toggle:work-entry-1", | ||
| 536 | "turn-fold:turn-1", | 534 | "turn-fold:turn-1", |
| 535 | "assistant-thought-entry", | ||
| 536 | "work-entry-1", | ||
| 537 | "assistant-final-entry", | 537 | "assistant-final-entry", |
| 538 | ]); | 538 | ]); |
| 539 | expect( | 539 | expect( |
| ... | @@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => { |
| 638 | expect(foldRow?.label).toBe("Worked for 12s"); | 638 | expect(foldRow?.label).toBe("Worked for 12s"); |
| 639 | }); | 639 | }); |
| 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 | it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { | 641 | it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { |
| 720 | const rows = deriveMessagesTimelineRows({ | 642 | const rows = deriveMessagesTimelineRows({ |
| 721 | timelineEntries: [ | 643 | timelineEntries: [ |
| ... | @@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => { |
| 849 | turnId: "turn-1" as never, | 771 | turnId: "turn-1" as never, |
| 850 | label: "Ran command", | 772 | label: "Ran command", |
| 851 | tone: "tool" as const, | 773 | tone: "tool" as const, |
| 852 | toolLifecycleStatus: "inProgress" as const, | ||
| 853 | }, | 774 | }, |
| 854 | }, | 775 | }, |
| 855 | ], | 776 | ], |
| ... | @@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => { |
| 867 | 788 | ||
| 868 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); | 789 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| 869 | expect(rows.map((row) => row.id)).toEqual([ | 790 | expect(rows.map((row) => row.id)).toEqual([ |
| 870 | "working-indicator-row", | ||
| 871 | "assistant-thought-entry", | 791 | "assistant-thought-entry", |
| 872 | "work-live:work-entry-1", | 792 | "work-entry-1", |
| 873 | ]); | ||
| 874 | }); | ||
| 875 | |||
| 876 | it("keeps the current tool batch expandable while live entries append", () => { | ||
| 877 | const timelineEntries = [ | ||
| 878 | { | ||
| 879 | id: "work-entry-1", | ||
| 880 | kind: "work" as const, | ||
| 881 | createdAt: "2026-01-01T00:00:01Z", | ||
| 882 | entry: { | ||
| 883 | id: "work-1", | ||
| 884 | createdAt: "2026-01-01T00:00:01Z", | ||
| 885 | turnId: "turn-1" as never, | ||
| 886 | toolCallId: "call-1", | ||
| 887 | label: "Read file", | ||
| 888 | tone: "tool" as const, | ||
| 889 | }, | ||
| 890 | }, | ||
| 891 | { | ||
| 892 | id: "work-entry-2", | ||
| 893 | kind: "work" as const, | ||
| 894 | createdAt: "2026-01-01T00:00:02Z", | ||
| 895 | entry: { | ||
| 896 | id: "work-2", | ||
| 897 | createdAt: "2026-01-01T00:00:02Z", | ||
| 898 | turnId: "turn-1" as never, | ||
| 899 | toolCallId: "call-2", | ||
| 900 | label: "Run command", | ||
| 901 | command: "vp test run", | ||
| 902 | tone: "tool" as const, | ||
| 903 | }, | ||
| 904 | }, | ||
| 905 | ]; | ||
| 906 | const baseInput = { | ||
| 907 | timelineEntries, | ||
| 908 | latestTurn: { | ||
| 909 | turnId: "turn-1" as never, | ||
| 910 | state: "running" as const, | ||
| 911 | startedAt: "2026-01-01T00:00:00Z", | ||
| 912 | completedAt: null, | ||
| 913 | }, | ||
| 914 | isWorking: true, | ||
| 915 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | ||
| 916 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 917 | revertTurnCountByUserMessageId: new Map(), | ||
| 918 | }; | ||
| 919 | |||
| 920 | const collapsedRows = deriveMessagesTimelineRows(baseInput); | ||
| 921 | const expandedRows = deriveMessagesTimelineRows({ | ||
| 922 | ...baseInput, | ||
| 923 | expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), | ||
| 924 | }); | ||
| 925 | |||
| 926 | expect(collapsedRows.map((row) => row.id)).toEqual([ | ||
| 927 | "working-indicator-row", | 793 | "working-indicator-row", |
| 928 | "work-live:tool:call-1", | ||
| 929 | ]); | 794 | ]); |
| 930 | expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ | ||
| 931 | groupId: "work-group:tool:call-1", | ||
| 932 | expanded: false, | ||
| 933 | groupedEntries: [{ id: "work-1" }, { id: "work-2" }], | ||
| 934 | }); | ||
| 935 | expect(expandedRows.map((row) => row.id)).toEqual([ | ||
| 936 | "working-indicator-row", | ||
| 937 | "work-live:tool:call-1", | ||
| 938 | "work-1", | ||
| 939 | "work-2", | ||
| 940 | ]); | ||
| 941 | expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ | ||
| 942 | groupId: "work-group:tool:call-1", | ||
| 943 | expanded: true, | ||
| 944 | }); | ||
| 945 | |||
| 946 | const appendedRows = deriveMessagesTimelineRows({ | ||
| 947 | ...baseInput, | ||
| 948 | timelineEntries: [ | ||
| 949 | ...timelineEntries, | ||
| 950 | { | ||
| 951 | id: "work-entry-3", | ||
| 952 | kind: "work" as const, | ||
| 953 | createdAt: "2026-01-01T00:00:03Z", | ||
| 954 | entry: { | ||
| 955 | id: "work-3", | ||
| 956 | createdAt: "2026-01-01T00:00:03Z", | ||
| 957 | turnId: "turn-1" as never, | ||
| 958 | toolCallId: "call-3", | ||
| 959 | label: "Changed file", | ||
| 960 | tone: "tool" as const, | ||
| 961 | }, | ||
| 962 | }, | ||
| 963 | ], | ||
| 964 | expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), | ||
| 965 | }); | ||
| 966 | |||
| 967 | expect(appendedRows.map((row) => row.id)).toEqual([ | ||
| 968 | "working-indicator-row", | ||
| 969 | "work-live:tool:call-1", | ||
| 970 | "work-1", | ||
| 971 | "work-2", | ||
| 972 | "work-3", | ||
| 973 | ]); | ||
| 974 | |||
| 975 | const rowsWithLaterPlan = deriveMessagesTimelineRows({ | ||
| 976 | ...baseInput, | ||
| 977 | timelineEntries: [ | ||
| 978 | ...timelineEntries, | ||
| 979 | { | ||
| 980 | id: "plan:thread-1:turn:turn-1", | ||
| 981 | kind: "proposed-plan" as const, | ||
| 982 | createdAt: "2026-01-01T00:00:03Z", | ||
| 983 | proposedPlan: { | ||
| 984 | id: "plan:thread-1:turn:turn-1", | ||
| 985 | turnId: "turn-1" as never, | ||
| 986 | planMarkdown: "# Next steps", | ||
| 987 | implementedAt: null, | ||
| 988 | implementationThreadId: null, | ||
| 989 | createdAt: "2026-01-01T00:00:03Z", | ||
| 990 | updatedAt: "2026-01-01T00:00:03Z", | ||
| 991 | }, | ||
| 992 | }, | ||
| 993 | ], | ||
| 994 | }); | ||
| 995 | expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); | ||
| 996 | expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); | ||
| 997 | }); | 795 | }); |
| 998 | 796 | ||
| 999 | it("does not fold the session's running turn when latestTurn regresses", () => { | 797 | it("does not fold the session's running turn when latestTurn regresses", () => { |
| ... | @@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1054 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ | 852 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ |
| 1055 | "turn-1", | 853 | "turn-1", |
| 1056 | ]); | 854 | ]); |
| 1057 | expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); | 855 | expect(rows.map((row) => row.id)).toContain("running-work-entry"); |
| 1058 | }); | 856 | }); |
| 1059 | 857 | ||
| 1060 | it("only shows assistant metadata on the terminal assistant message", () => { | 858 | it("only shows assistant metadata on the terminal assistant message", () => { |
| ... | @@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 1196 | expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), | 994 | expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), |
| 1197 | }); | 995 | }); |
| 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 | expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ | 998 | expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| 1201 | groupId: "work-group:work-entry-1", | 999 | groupId: "work-group:work-entry-1", |
| 1202 | hiddenCount: 3, | 1000 | hiddenCount: 2, |
| 1203 | expanded: false, | 1001 | expanded: false, |
| 1204 | onlyToolEntries: true, | 1002 | onlyToolEntries: true, |
| 1205 | }); | 1003 | }); |
| 1206 | expect(expandedRows.map((row) => row.id)).toEqual([ | 1004 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 1207 | "work-toggle:work-entry-1", | ||
| 1208 | "work-1", | 1005 | "work-1", |
| 1209 | "work-2", | 1006 | "work-2", |
| 1210 | "work-3", | 1007 | "work-3", |
| 1008 | "work-toggle:work-entry-1", | ||
| 1211 | ]); | 1009 | ]); |
| 1212 | expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ | 1010 | expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| 1213 | expanded: true, | 1011 | expanded: true, |
apps/web/src/components/chat/MessagesTimeline.logic.ts+30-382| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import * as Equal from "effect/Equal"; | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { | 2 | import { |
| 3 | formatDuration, | 3 | formatDuration, |
| 4 | workEntryIndicatesToolFailure, | ||
| 5 | workEntryIndicatesToolNeutralStatus, | 4 | workEntryIndicatesToolNeutralStatus, |
| 6 | workLogEntryIsToolLike, | 5 | workLogEntryIsToolLike, |
| 7 | type TimelineEntry, | 6 | type TimelineEntry, |
| ... | @@ -167,17 +166,6 @@ export type MessagesTimelineRow = | ... | @@ -167,17 +166,6 @@ export type MessagesTimelineRow = |
| 167 | id: string; | 166 | id: string; |
| 168 | createdAt: string; | 167 | createdAt: string; |
| 169 | groupedEntries: WorkLogEntry[]; | 168 | groupedEntries: WorkLogEntry[]; |
| 170 | isExpandedToolGroupEntry: boolean; | ||
| 171 | isLastExpandedToolGroupEntry: boolean; | ||
| 172 | } | ||
| 173 | | { | ||
| 174 | kind: "work-live"; | ||
| 175 | id: string; | ||
| 176 | createdAt: string; | ||
| 177 | entry: WorkLogEntry; | ||
| 178 | groupedEntries: WorkLogEntry[]; | ||
| 179 | groupId: string; | ||
| 180 | expanded: boolean; | ||
| 181 | } | 169 | } |
| 182 | | { | 170 | | { |
| 183 | kind: "work-toggle"; | 171 | kind: "work-toggle"; |
| ... | @@ -187,9 +175,6 @@ export type MessagesTimelineRow = | ... | @@ -187,9 +175,6 @@ export type MessagesTimelineRow = |
| 187 | hiddenCount: number; | 175 | hiddenCount: number; |
| 188 | expanded: boolean; | 176 | expanded: boolean; |
| 189 | onlyToolEntries: boolean; | 177 | onlyToolEntries: boolean; |
| 190 | summary: string | null; | ||
| 191 | summaryKind: ToolGroupAction | "mixed" | null; | ||
| 192 | hasFailure: boolean; | ||
| 193 | } | 178 | } |
| 194 | | { | 179 | | { |
| 195 | kind: "turn-fold"; | 180 | kind: "turn-fold"; |
| ... | @@ -223,12 +208,7 @@ export type MessagesTimelineRow = | ... | @@ -223,12 +208,7 @@ export type MessagesTimelineRow = |
| 223 | createdAt: string; | 208 | createdAt: string; |
| 224 | turnPlan: TurnPlanEntry; | 209 | turnPlan: TurnPlanEntry; |
| 225 | } | 210 | } |
| 226 | | { | 211 | | { kind: "working"; id: string; createdAt: string | null }; |
| 227 | kind: "working"; | ||
| 228 | id: string; | ||
| 229 | createdAt: string | null; | ||
| 230 | showThinking: boolean; | ||
| 231 | }; | ||
| 232 | 212 | ||
| 233 | export interface StableMessagesTimelineRowsState { | 213 | export interface StableMessagesTimelineRowsState { |
| 234 | byId: Map<string, MessagesTimelineRow>; | 214 | byId: Map<string, MessagesTimelineRow>; |
| ... | @@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string { | ... | @@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string { |
| 258 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | 238 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 259 | } | 239 | } |
| 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 | export function resolveAssistantMessageCopyState({ | 241 | export function resolveAssistantMessageCopyState({ |
| 346 | text, | 242 | text, |
| 347 | showCopyButton, | 243 | showCopyButton, |
| ... | @@ -414,34 +310,17 @@ function deriveUnsettledTurnId( | ... | @@ -414,34 +310,17 @@ function deriveUnsettledTurnId( |
| 414 | return isSettled ? null : latestTurn.turnId; | 310 | return isSettled ? null : latestTurn.turnId; |
| 415 | } | 311 | } |
| 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 | * Settled turns fold their commentary and tool activity behind a | 314 | * Settled turns fold their commentary and tool activity behind a |
| 435 | * "Worked for ..." row placed immediately before the next terminal assistant | 315 | * "Worked for ..." row anchored at the turn's first foldable entry; the |
| 436 | * response. A steer can split one visible response across turn ids, so tying | 316 | * terminal assistant message stays visible below the fold. |
| 437 | * the disclosure to the first hidden entry would strand it above the steer. | ||
| 438 | */ | 317 | */ |
| 439 | function deriveTurnFolds(input: { | 318 | function deriveTurnFolds(input: { |
| 440 | timelineEntries: ReadonlyArray<TimelineEntry>; | 319 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 441 | terminalAssistantMessageIds: ReadonlySet<string>; | 320 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 442 | latestTurn: TimelineLatestTurn | null; | 321 | latestTurn: TimelineLatestTurn | null; |
| 443 | unsettledTurnId: TurnId | null; | 322 | unsettledTurnId: TurnId | null; |
| 444 | }): ReadonlyMap<string, ReadonlyArray<TurnFold>> { | 323 | }): ReadonlyMap<string, TurnFold> { |
| 445 | interface TurnGroup { | 324 | interface TurnGroup { |
| 446 | entries: Array<TimelineEntry>; | 325 | entries: Array<TimelineEntry>; |
| 447 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; | 326 | terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null; |
| ... | @@ -496,7 +375,7 @@ function deriveTurnFolds(input: { | ... | @@ -496,7 +375,7 @@ function deriveTurnFolds(input: { |
| 496 | } | 375 | } |
| 497 | } | 376 | } |
| 498 | 377 | ||
| 499 | const foldsByAnchorEntryId = new Map<string, TurnFold[]>(); | 378 | const foldsByAnchorEntryId = new Map<string, TurnFold>(); |
| 500 | for (const [turnId, group] of groupsByTurnId) { | 379 | for (const [turnId, group] of groupsByTurnId) { |
| 501 | if (turnId === input.unsettledTurnId) { | 380 | if (turnId === input.unsettledTurnId) { |
| 502 | continue; | 381 | continue; |
| ... | @@ -526,24 +405,6 @@ function deriveTurnFolds(input: { | ... | @@ -526,24 +405,6 @@ function deriveTurnFolds(input: { |
| 526 | if (!firstEntry || !lastEntry) { | 405 | if (!firstEntry || !lastEntry) { |
| 527 | continue; | 406 | continue; |
| 528 | } | 407 | } |
| 529 | const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => | ||
| 530 | hiddenEntryIds.has(entry.id), | ||
| 531 | ); | ||
| 532 | if (lastHiddenEntryIndex < 0) { | ||
| 533 | continue; | ||
| 534 | } | ||
| 535 | const nextTerminalAssistantEntry = input.timelineEntries | ||
| 536 | .slice(lastHiddenEntryIndex + 1) | ||
| 537 | .find( | ||
| 538 | (entry) => | ||
| 539 | entry.kind === "message" && | ||
| 540 | entry.message.role === "assistant" && | ||
| 541 | input.terminalAssistantMessageIds.has(entry.message.id), | ||
| 542 | ); | ||
| 543 | const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; | ||
| 544 | if (!anchorEntry) { | ||
| 545 | continue; | ||
| 546 | } | ||
| 547 | 408 | ||
| 548 | const isLatestInterruptedTurn = | 409 | const isLatestInterruptedTurn = |
| 549 | input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; | 410 | input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; |
| ... | @@ -570,16 +431,13 @@ function deriveTurnFolds(input: { | ... | @@ -570,16 +431,13 @@ function deriveTurnFolds(input: { |
| 570 | ? `Worked for ${duration}` | 431 | ? `Worked for ${duration}` |
| 571 | : "Worked"; | 432 | : "Worked"; |
| 572 | 433 | ||
| 573 | const fold = { | 434 | foldsByAnchorEntryId.set(firstEntry.id, { |
| 574 | turnId, | 435 | turnId, |
| 575 | anchorEntryId: anchorEntry.id, | 436 | anchorEntryId: firstEntry.id, |
| 576 | createdAt: anchorEntry.createdAt, | 437 | createdAt: firstEntry.createdAt, |
| 577 | hiddenEntryIds, | 438 | hiddenEntryIds, |
| 578 | label, | 439 | label, |
| 579 | }; | 440 | }); |
| 580 | const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); | ||
| 581 | if (anchoredFolds) anchoredFolds.push(fold); | ||
| 582 | else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); | ||
| 583 | } | 441 | } |
| 584 | return foldsByAnchorEntryId; | 442 | return foldsByAnchorEntryId; |
| 585 | } | 443 | } |
| ... | @@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: { |
| 611 | unsettledTurnId, | 469 | unsettledTurnId, |
| 612 | }); | 470 | }); |
| 613 | const collapsedEntryIds = new Set<string>(); | 471 | const collapsedEntryIds = new Set<string>(); |
| 614 | for (const folds of foldsByAnchorEntryId.values()) { | 472 | for (const fold of foldsByAnchorEntryId.values()) { |
| 615 | for (const fold of folds) { | 473 | if (!input.expandedTurnIds?.has(fold.turnId)) { |
| 616 | if (!input.expandedTurnIds?.has(fold.turnId)) { | 474 | for (const entryId of fold.hiddenEntryIds) { |
| 617 | for (const entryId of fold.hiddenEntryIds) { | 475 | collapsedEntryIds.add(entryId); |
| 618 | collapsedEntryIds.add(entryId); | ||
| 619 | } | ||
| 620 | } | 476 | } |
| 621 | } | 477 | } |
| 622 | } | 478 | } |
| 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 | for (let index = 0; index < input.timelineEntries.length; index += 1) { | 480 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 740 | const timelineEntry = input.timelineEntries[index]; | 481 | const timelineEntry = input.timelineEntries[index]; |
| 741 | if (!timelineEntry) { | 482 | if (!timelineEntry) { |
| 742 | continue; | 483 | continue; |
| 743 | } | 484 | } |
| 744 | 485 | ||
| 745 | if (input.isWorking && index === activeTurnHeaderIndex) { | 486 | const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); |
| 487 | if (turnFold) { | ||
| 746 | nextRows.push({ | 488 | nextRows.push({ |
| 747 | kind: "working", | 489 | kind: "turn-fold", |
| 748 | id: "working-indicator-row", | 490 | id: `turn-fold:${turnFold.turnId}`, |
| 749 | createdAt: input.activeTurnStartedAt, | 491 | createdAt: turnFold.createdAt, |
| 750 | showThinking: !activeTurnHasVisibleContent, | 492 | turnId: turnFold.turnId, |
| 493 | label: turnFold.label, | ||
| 494 | expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, | ||
| 751 | }); | 495 | }); |
| 752 | } | 496 | } |
| 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 | if (collapsedEntryIds.has(timelineEntry.id)) { | 498 | if (collapsedEntryIds.has(timelineEntry.id)) { |
| 769 | continue; | 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 | if (timelineEntry.kind === "work") { | 502 | if (timelineEntry.kind === "work") { |
| 793 | const groupedEntries = [timelineEntry.entry]; | 503 | const groupedEntries = [timelineEntry.entry]; |
| 794 | let cursor = index + 1; | 504 | let cursor = index + 1; |
| ... | @@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: { |
| 797 | if ( | 507 | if ( |
| 798 | !nextEntry || | 508 | !nextEntry || |
| 799 | nextEntry.kind !== "work" || | 509 | nextEntry.kind !== "work" || |
| 800 | activeWorkEntryIds.has(nextEntry.id) || | ||
| 801 | collapsedEntryIds.has(nextEntry.id) || | 510 | collapsedEntryIds.has(nextEntry.id) || |
| 802 | foldsByAnchorEntryId.has(nextEntry.id) | 511 | foldsByAnchorEntryId.has(nextEntry.id) |
| 803 | ) { | 512 | ) { |
| ... | @@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: { |
| 810 | (entry) => !workEntryIndicatesToolNeutralStatus(entry), | 519 | (entry) => !workEntryIndicatesToolNeutralStatus(entry), |
| 811 | ); | 520 | ); |
| 812 | if (visibleGroupedEntries.length > 0) { | 521 | if (visibleGroupedEntries.length > 0) { |
| 813 | const onlyToolEntries = visibleGroupedEntries.every( | 522 | if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { |
| 814 | (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, | ||
| 815 | ); | ||
| 816 | if (onlyToolEntries) { | ||
| 817 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | ||
| 818 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | ||
| 819 | const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); | ||
| 820 | nextRows.push({ | ||
| 821 | kind: "work-toggle", | ||
| 822 | id: `work-toggle:${timelineEntry.id}`, | ||
| 823 | createdAt: timelineEntry.createdAt, | ||
| 824 | groupId, | ||
| 825 | hiddenCount: visibleGroupedEntries.length, | ||
| 826 | expanded, | ||
| 827 | onlyToolEntries: true, | ||
| 828 | summary: summarizeToolGroup(visibleGroupedEntries), | ||
| 829 | summaryKind, | ||
| 830 | hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), | ||
| 831 | }); | ||
| 832 | if (expanded) { | ||
| 833 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | ||
| 834 | nextRows.push({ | ||
| 835 | kind: "work", | ||
| 836 | id: workEntry.id, | ||
| 837 | createdAt: workEntry.createdAt, | ||
| 838 | groupedEntries: [workEntry], | ||
| 839 | isExpandedToolGroupEntry: true, | ||
| 840 | isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | ||
| 841 | }); | ||
| 842 | } | ||
| 843 | } | ||
| 844 | } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { | ||
| 845 | nextRows.push({ | 523 | nextRows.push({ |
| 846 | kind: "work", | 524 | kind: "work", |
| 847 | id: timelineEntry.id, | 525 | id: timelineEntry.id, |
| 848 | createdAt: timelineEntry.createdAt, | 526 | createdAt: timelineEntry.createdAt, |
| 849 | groupedEntries: visibleGroupedEntries, | 527 | groupedEntries: visibleGroupedEntries, |
| 850 | isExpandedToolGroupEntry: false, | ||
| 851 | isLastExpandedToolGroupEntry: false, | ||
| 852 | }); | 528 | }); |
| 853 | } else { | 529 | } else { |
| 854 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | 530 | const groupId = `work-group:${timelineEntry.id}`; |
| 855 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 531 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 856 | // Agent-spawn CTA rows are always visible: a running fleet must | 532 | // Agent-spawn CTA rows are always visible: a running fleet must |
| 857 | // never hide behind a "+N tool calls" toggle. Selection is by | 533 | // never hide behind a "+N tool calls" toggle. Selection is by |
| ... | @@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: { |
| 875 | id: workEntry.id, | 551 | id: workEntry.id, |
| 876 | createdAt: workEntry.createdAt, | 552 | createdAt: workEntry.createdAt, |
| 877 | groupedEntries: [workEntry], | 553 | groupedEntries: [workEntry], |
| 878 | isExpandedToolGroupEntry: false, | ||
| 879 | isLastExpandedToolGroupEntry: false, | ||
| 880 | }); | 554 | }); |
| 881 | } | 555 | } |
| 882 | 556 | ||
| ... | @@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: { |
| 888 | groupId, | 562 | groupId, |
| 889 | hiddenCount: hiddenEntries.length, | 563 | hiddenCount: hiddenEntries.length, |
| 890 | expanded, | 564 | expanded, |
| 891 | onlyToolEntries, | 565 | onlyToolEntries: visibleGroupedEntries.every((entry) => |
| 892 | summary: null, | 566 | workLogEntryIsToolLike(entry), |
| 893 | summaryKind: null, | ||
| 894 | hasFailure: visibleGroupedEntries.some((entry) => | ||
| 895 | workEntryIndicatesToolFailure(entry), | ||
| 896 | ), | 567 | ), |
| 897 | }); | 568 | }); |
| 898 | } | 569 | } |
| ... | @@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: { |
| 958 | }); | 629 | }); |
| 959 | } | 630 | } |
| 960 | 631 | ||
| 961 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { | 632 | if (input.isWorking) { |
| 962 | nextRows.push({ | 633 | nextRows.push({ |
| 963 | kind: "working", | 634 | kind: "working", |
| 964 | id: "working-indicator-row", | 635 | id: "working-indicator-row", |
| 965 | createdAt: input.activeTurnStartedAt, | 636 | createdAt: input.activeTurnStartedAt, |
| 966 | showThinking: !activeTurnHasVisibleContent, | ||
| 967 | }); | 637 | }); |
| 968 | } | 638 | } |
| 969 | 639 | ||
| ... | @@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 996 | 666 | ||
| 997 | switch (a.kind) { | 667 | switch (a.kind) { |
| 998 | case "working": | 668 | case "working": |
| 999 | return ( | 669 | return a.createdAt === (b as typeof a).createdAt; |
| 1000 | a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking | ||
| 1001 | ); | ||
| 1002 | 670 | ||
| 1003 | case "turn-fold": { | 671 | case "turn-fold": { |
| 1004 | const bf = b as typeof a; | 672 | const bf = b as typeof a; |
| ... | @@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1015 | return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; | 683 | return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; |
| 1016 | } | 684 | } |
| 1017 | 685 | ||
| 1018 | case "work": { | 686 | case "work": |
| 1019 | const bw = b as typeof a; | 687 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); |
| 1020 | return ( | ||
| 1021 | a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && | ||
| 1022 | a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && | ||
| 1023 | Equal.equals(a.groupedEntries, bw.groupedEntries) | ||
| 1024 | ); | ||
| 1025 | } | ||
| 1026 | |||
| 1027 | case "work-live": { | ||
| 1028 | const bw = b as typeof a; | ||
| 1029 | return ( | ||
| 1030 | a.createdAt === bw.createdAt && | ||
| 1031 | a.groupId === bw.groupId && | ||
| 1032 | a.expanded === bw.expanded && | ||
| 1033 | Equal.equals(a.entry, bw.entry) && | ||
| 1034 | Equal.equals(a.groupedEntries, bw.groupedEntries) | ||
| 1035 | ); | ||
| 1036 | } | ||
| 1037 | 688 | ||
| 1038 | case "work-toggle": { | 689 | case "work-toggle": { |
| 1039 | const bw = b as typeof a; | 690 | const bw = b as typeof a; |
| ... | @@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1042 | a.groupId === bw.groupId && | 693 | a.groupId === bw.groupId && |
| 1043 | a.hiddenCount === bw.hiddenCount && | 694 | a.hiddenCount === bw.hiddenCount && |
| 1044 | a.expanded === bw.expanded && | 695 | a.expanded === bw.expanded && |
| 1045 | a.onlyToolEntries === bw.onlyToolEntries && | 696 | a.onlyToolEntries === bw.onlyToolEntries |
| 1046 | a.summary === bw.summary && | ||
| 1047 | a.summaryKind === bw.summaryKind && | ||
| 1048 | a.hasFailure === bw.hasFailure | ||
| 1049 | ); | 697 | ); |
| 1050 | } | 698 | } |
| 1051 | 699 |
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-44| ... | @@ -554,49 +554,7 @@ describe("MessagesTimeline", () => { | ... | @@ -554,49 +554,7 @@ describe("MessagesTimeline", () => { |
| 554 | expect(markup).toContain("Work Log"); | 554 | expect(markup).toContain("Work Log"); |
| 555 | }); | 555 | }); |
| 556 | 556 | ||
| 557 | it("makes the whole live tool row expandable without adding a chevron", () => { | 557 | it("formats changed file paths from the workspace root", () => { |
| 558 | const turnId = TurnId.make("turn-live-tools"); | ||
| 559 | const markup = renderToStaticMarkup( | ||
| 560 | <MessagesTimeline | ||
| 561 | {...buildProps()} | ||
| 562 | isWorking | ||
| 563 | activeTurnInProgress | ||
| 564 | activeTurnStartedAt="2026-03-17T19:12:28.000Z" | ||
| 565 | latestTurn={{ | ||
| 566 | turnId, | ||
| 567 | state: "running", | ||
| 568 | startedAt: "2026-03-17T19:12:28.000Z", | ||
| 569 | completedAt: null, | ||
| 570 | }} | ||
| 571 | runningTurnId={turnId} | ||
| 572 | timelineEntries={[ | ||
| 573 | { | ||
| 574 | id: "entry-live-tool", | ||
| 575 | kind: "work", | ||
| 576 | createdAt: "2026-03-17T19:12:29.000Z", | ||
| 577 | entry: { | ||
| 578 | id: "work-live-tool", | ||
| 579 | createdAt: "2026-03-17T19:12:29.000Z", | ||
| 580 | turnId, | ||
| 581 | toolCallId: "call-live-tool", | ||
| 582 | label: "Running tests", | ||
| 583 | command: "env -C /tmp sudo -iu postgres psql", | ||
| 584 | tone: "tool", | ||
| 585 | toolLifecycleStatus: "inProgress", | ||
| 586 | }, | ||
| 587 | }, | ||
| 588 | ]} | ||
| 589 | />, | ||
| 590 | ); | ||
| 591 | |||
| 592 | expect(markup).not.toContain('aria-label="Expand current tool calls"'); | ||
| 593 | expect(markup).toContain('aria-expanded="false"'); | ||
| 594 | expect(markup).toContain("Running psql"); | ||
| 595 | expect(markup).not.toContain("lucide-chevron-right"); | ||
| 596 | expect(markup).not.toContain("hover:bg-accent/20"); | ||
| 597 | }); | ||
| 598 | |||
| 599 | it("summarizes completed changed-file activity", () => { | ||
| 600 | const markup = renderToStaticMarkup( | 558 | const markup = renderToStaticMarkup( |
| 601 | <MessagesTimeline | 559 | <MessagesTimeline |
| 602 | {...buildProps()} | 560 | {...buildProps()} |
| ... | @@ -618,7 +576,7 @@ describe("MessagesTimeline", () => { | ... | @@ -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 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); | 580 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); |
| 623 | }); | 581 | }); |
| 624 | 582 |
apps/web/src/components/chat/MessagesTimeline.tsx+155-336| ... | @@ -35,6 +35,7 @@ import { | ... | @@ -35,6 +35,7 @@ import { |
| 35 | deriveTimelineEntries, | 35 | deriveTimelineEntries, |
| 36 | workEntryIndicatesToolFailure, | 36 | workEntryIndicatesToolFailure, |
| 37 | workEntryIndicatesToolNeutralStatus, | 37 | workEntryIndicatesToolNeutralStatus, |
| 38 | workEntryIndicatesToolSuccess, | ||
| 38 | workLogEntryIsToolLike, | 39 | workLogEntryIsToolLike, |
| 39 | } from "../../session-logic"; | 40 | } from "../../session-logic"; |
| 40 | import { type TurnDiffSummary } from "../../types"; | 41 | import { type TurnDiffSummary } from "../../types"; |
| ... | @@ -56,6 +57,7 @@ import { | ... | @@ -56,6 +57,7 @@ import { |
| 56 | MessageCircleIcon, | 57 | MessageCircleIcon, |
| 57 | MousePointerClickIcon, | 58 | MousePointerClickIcon, |
| 58 | PaintbrushIcon, | 59 | PaintbrushIcon, |
| 60 | MinusIcon, | ||
| 59 | SquarePenIcon, | 61 | SquarePenIcon, |
| 60 | TerminalIcon, | 62 | TerminalIcon, |
| 61 | Undo2Icon, | 63 | Undo2Icon, |
| ... | @@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped | ... | @@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped |
| 918 | type TimelineRow = MessagesTimelineRow; | 920 | type TimelineRow = MessagesTimelineRow; |
| 919 | 921 | ||
| 920 | const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { | 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 | return ( | 923 | return ( |
| 928 | <div | 924 | <div |
| 929 | className={cn( | 925 | className={cn( |
| 930 | // Commentary (non-terminal assistant) rows carry no metadata row, so | 926 | // Commentary (non-terminal assistant) rows carry no metadata row, so |
| 931 | // they sit closer to the work that follows them. | 927 | // they sit closer to the work that follows them. |
| 932 | isExpandedToolGroupEntry | 928 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || |
| 933 | ? isLastExpandedToolGroupEntry | 929 | row.kind === "work" || |
| 934 | ? "pb-1" | 930 | row.kind === "work-toggle" || |
| 935 | : "pb-0" | 931 | row.kind === "turn-plan" |
| 936 | : isExpandedToolGroupHeader | 932 | ? "pb-2" |
| 937 | ? "pb-0" | 933 | : "pb-4", |
| 938 | : row.kind === "turn-fold" | ||
| 939 | ? "pb-1.5" | ||
| 940 | : (row.kind === "message" && | ||
| 941 | row.message.role === "assistant" && | ||
| 942 | !row.showAssistantMeta) || | ||
| 943 | row.kind === "work" || | ||
| 944 | row.kind === "work-live" || | ||
| 945 | row.kind === "work-toggle" || | ||
| 946 | row.kind === "turn-plan" | ||
| 947 | ? "pb-2" | ||
| 948 | : "pb-4", | ||
| 949 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, | 934 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| 950 | )} | 935 | )} |
| 951 | data-timeline-row-id={row.id} | 936 | data-timeline-row-id={row.id} |
| ... | @@ -953,13 +938,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -953,13 +938,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 953 | data-message-id={row.kind === "message" ? row.message.id : undefined} | 938 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 954 | data-message-role={row.kind === "message" ? row.message.role : undefined} | 939 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 955 | > | 940 | > |
| 956 | {row.kind === "work" ? ( | 941 | {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} |
| 957 | <WorkGroupSection | ||
| 958 | groupedEntries={row.groupedEntries} | ||
| 959 | isExpandedToolGroupEntry={row.isExpandedToolGroupEntry} | ||
| 960 | /> | ||
| 961 | ) : null} | ||
| 962 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} | ||
| 963 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} | 942 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} |
| 964 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} | 943 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| 965 | {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} | 944 | {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} |
| ... | @@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { | ... | @@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { |
| 1104 | 1083 | ||
| 1105 | function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) { | 1084 | function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) { |
| 1106 | const ctx = use(TimelineRowCtx); | 1085 | const ctx = use(TimelineRowCtx); |
| 1086 | const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; | ||
| 1107 | 1087 | ||
| 1108 | return ( | 1088 | return ( |
| 1109 | <div className="border-b border-border/60 pb-2 pt-1"> | 1089 | <div className="border-b border-border/60 pb-2 pt-1"> |
| ... | @@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- | ... | @@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- |
| 1112 | aria-expanded={row.expanded} | 1092 | aria-expanded={row.expanded} |
| 1113 | data-scroll-anchor-ignore | 1093 | data-scroll-anchor-ignore |
| 1114 | onClick={() => ctx.onToggleTurnFold(row.turnId)} | 1094 | onClick={() => ctx.onToggleTurnFold(row.turnId)} |
| 1115 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | 1095 | className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" |
| 1116 | > | 1096 | > |
| 1117 | <span>{row.label}</span> | 1097 | <span>{row.label}</span> |
| 1118 | <ChevronRightIcon | 1098 | <Icon className="size-3.5" /> |
| 1119 | className={cn("size-3.5 transition-transform", row.expanded && "-rotate-90")} | ||
| 1120 | /> | ||
| 1121 | </button> | 1099 | </button> |
| 1122 | </div> | 1100 | </div> |
| 1123 | ); | 1101 | ); |
| ... | @@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ | ... | @@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ |
| 1300 | }); | 1278 | }); |
| 1301 | 1279 | ||
| 1302 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | 1280 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1281 | const { workingStepLabel } = use(TimelineRowActivityCtx); | ||
| 1303 | return ( | 1282 | return ( |
| 1304 | <div> | 1283 | <div className="py-0.5 pl-1.5"> |
| 1305 | <div className="border-b border-border/60 pb-2 pt-1"> | 1284 | <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums"> |
| 1306 | <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | 1285 | <span className="inline-flex items-center gap-[3px]"> |
| 1286 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> | ||
| 1287 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> | ||
| 1288 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" /> | ||
| 1289 | </span> | ||
| 1290 | <span className="shrink-0"> | ||
| 1307 | {row.createdAt ? ( | 1291 | {row.createdAt ? ( |
| 1308 | <> | 1292 | <> |
| 1309 | Working for <WorkingTimer createdAt={row.createdAt} /> | 1293 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| ... | @@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin | ... | @@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1311 | ) : ( | 1295 | ) : ( |
| 1312 | "Working..." | 1296 | "Working..." |
| 1313 | )} | 1297 | )} |
| 1314 | </div> | 1298 | </span> |
| 1299 | {workingStepLabel ? ( | ||
| 1300 | <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span> | ||
| 1301 | ) : null} | ||
| 1315 | </div> | 1302 | </div> |
| 1316 | {row.showThinking ? ( | ||
| 1317 | <div className="mt-2"> | ||
| 1318 | <ThinkingActivityRow /> | ||
| 1319 | </div> | ||
| 1320 | ) : null} | ||
| 1321 | </div> | 1303 | </div> |
| 1322 | ); | 1304 | ); |
| 1323 | } | 1305 | } |
| ... | @@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { | ... | @@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 1358 | /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ | 1340 | /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ |
| 1359 | const WorkGroupSection = memo(function WorkGroupSection({ | 1341 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 1360 | groupedEntries, | 1342 | groupedEntries, |
| 1361 | isExpandedToolGroupEntry, | ||
| 1362 | }: { | 1343 | }: { |
| 1363 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; | 1344 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 1364 | isExpandedToolGroupEntry: boolean; | ||
| 1365 | }) { | 1345 | }) { |
| 1366 | const { workspaceRoot } = use(TimelineRowCtx); | 1346 | const { workspaceRoot } = use(TimelineRowCtx); |
| 1367 | const nonEmptyEntries = useMemo( | 1347 | const nonEmptyEntries = useMemo( |
| ... | @@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1378 | if (nonEmptyEntries.length === 0) return null; | 1358 | if (nonEmptyEntries.length === 0) return null; |
| 1379 | 1359 | ||
| 1380 | return ( | 1360 | return ( |
| 1381 | <section | 1361 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}> |
| 1382 | className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")} | ||
| 1383 | aria-label={groupLabel} | ||
| 1384 | > | ||
| 1385 | {!onlyToolEntries && ( | 1362 | {!onlyToolEntries && ( |
| 1386 | <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p> | 1363 | <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p> |
| 1387 | )} | 1364 | )} |
| ... | @@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1391 | key={workEntry.id} | 1368 | key={workEntry.id} |
| 1392 | workEntry={workEntry} | 1369 | workEntry={workEntry} |
| 1393 | workspaceRoot={workspaceRoot} | 1370 | workspaceRoot={workspaceRoot} |
| 1394 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | ||
| 1395 | /> | 1371 | /> |
| 1396 | ))} | 1372 | ))} |
| 1397 | </div> | 1373 | </div> |
| ... | @@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1399 | ); | 1375 | ); |
| 1400 | }); | 1376 | }); |
| 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 | function WorkGroupToggleTimelineRow({ | 1378 | function WorkGroupToggleTimelineRow({ |
| 1491 | row, | 1379 | row, |
| 1492 | }: { | 1380 | }: { |
| 1493 | row: Extract<TimelineRow, { kind: "work-toggle" }>; | 1381 | row: Extract<TimelineRow, { kind: "work-toggle" }>; |
| 1494 | }) { | 1382 | }) { |
| 1495 | const ctx = use(TimelineRowCtx); | 1383 | const ctx = use(TimelineRowCtx); |
| 1496 | if (row.onlyToolEntries && row.summary) { | ||
| 1497 | return ( | ||
| 1498 | <button | ||
| 1499 | type="button" | ||
| 1500 | className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | ||
| 1501 | aria-expanded={row.expanded} | ||
| 1502 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | ||
| 1503 | > | ||
| 1504 | <span | ||
| 1505 | className={cn( | ||
| 1506 | "flex size-6 shrink-0 items-center justify-center", | ||
| 1507 | row.hasFailure ? "text-destructive" : "text-icon-muted", | ||
| 1508 | )} | ||
| 1509 | aria-label={row.hasFailure ? "Tool call failed" : undefined} | ||
| 1510 | > | ||
| 1511 | {row.hasFailure ? ( | ||
| 1512 | <XIcon className="size-4 shrink-0 stroke-[1.8]" aria-hidden /> | ||
| 1513 | ) : ( | ||
| 1514 | <WorkEntryIconSvg | ||
| 1515 | name={toolGroupSummaryIconName(row.summaryKind)} | ||
| 1516 | className="size-4 shrink-0 stroke-[1.8] opacity-70" | ||
| 1517 | /> | ||
| 1518 | )} | ||
| 1519 | </span> | ||
| 1520 | <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span> | ||
| 1521 | </button> | ||
| 1522 | ); | ||
| 1523 | } | ||
| 1524 | const labelNoun = row.onlyToolEntries | 1384 | const labelNoun = row.onlyToolEntries |
| 1525 | ? row.hiddenCount === 1 | 1385 | ? row.hiddenCount === 1 |
| 1526 | ? "tool call" | 1386 | ? "tool call" |
| ... | @@ -2159,101 +2019,32 @@ function workEntryPreview( | ... | @@ -2159,101 +2019,32 @@ function workEntryPreview( |
| 2159 | : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; | 2019 | : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; |
| 2160 | } | 2020 | } |
| 2161 | 2021 | ||
| 2162 | type CommandWrapper = "env" | "sudo"; | 2022 | function workEntryRawCommand( |
| 2163 | 2023 | workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">, | |
| 2164 | const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = { | 2024 | ): string | null { |
| 2165 | env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), | 2025 | const rawCommand = workEntry.rawCommand?.trim(); |
| 2166 | sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), | 2026 | if (!rawCommand || !workEntry.command) { |
| 2167 | }; | 2027 | return null; |
| 2168 | |||
| 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"; | ||
| 2237 | } | 2028 | } |
| 2238 | 2029 | return rawCommand === workEntry.command.trim() ? null : rawCommand; | |
| 2239 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | ||
| 2240 | } | 2030 | } |
| 2241 | 2031 | ||
| 2242 | function buildToolCallExpandedBody( | 2032 | function buildToolCallExpandedBody( |
| 2243 | workEntry: TimelineWorkEntry, | 2033 | workEntry: TimelineWorkEntry, |
| 2244 | workspaceRoot: string | undefined, | 2034 | workspaceRoot: string | undefined, |
| 2245 | ): string | null { | 2035 | ): string | null { |
| 2246 | const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); | ||
| 2247 | const blocks: string[] = []; | 2036 | const blocks: string[] = []; |
| 2248 | if (command) { | ||
| 2249 | blocks.push(command); | ||
| 2250 | } | ||
| 2251 | if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { | 2037 | if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { |
| 2252 | blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); | 2038 | blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); |
| 2253 | } | 2039 | } |
| 2254 | const detail = workEntry.detail?.trim(); | 2040 | const raw = workEntryRawCommand(workEntry); |
| 2255 | if (detail && detail !== command) { | 2041 | if (raw?.trim()) { |
| 2256 | blocks.push(detail); | 2042 | blocks.push(raw.trim()); |
| 2043 | } else if (workEntry.command?.trim()) { | ||
| 2044 | blocks.push(workEntry.command.trim()); | ||
| 2045 | } | ||
| 2046 | if (workEntry.detail?.trim()) { | ||
| 2047 | blocks.push(workEntry.detail.trim()); | ||
| 2257 | } | 2048 | } |
| 2258 | const changedFiles = workEntry.changedFiles ?? []; | 2049 | const changedFiles = workEntry.changedFiles ?? []; |
| 2259 | if (changedFiles.length > 0) { | 2050 | if (changedFiles.length > 0) { |
| ... | @@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time | ... | @@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time |
| 2389 | : "working" | 2180 | : "working" |
| 2390 | : failed > 0 | 2181 | : failed > 0 |
| 2391 | ? `${failed} failed` | 2182 | ? `${failed} failed` |
| 2392 | : "Completed"; | 2183 | : "✓ completed"; |
| 2393 | 2184 | ||
| 2394 | return ( | 2185 | return ( |
| 2395 | <div className="-mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"> | 2186 | <button |
| 2396 | <div className="flex items-center justify-between gap-2 rounded-xl px-1"> | 2187 | type="button" |
| 2397 | <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left text-xs leading-4"> | 2188 | onClick={onOpenAgents} |
| 2398 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> | 2189 | className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50" |
| 2399 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | 2190 | > |
| 2400 | <span className="min-w-0 truncate font-medium text-foreground">{lead}</span> | 2191 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> |
| 2401 | {workflowName ? ( | 2192 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> |
| 2402 | <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground md:inline"> | 2193 | <span className="min-w-0 truncate"> |
| 2403 | {workflowName} | 2194 | <span className="font-medium">{lead}</span> |
| 2404 | </span> | 2195 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} |
| 2405 | ) : null} | 2196 | </span> |
| 2406 | <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground sm:flex"> | 2197 | <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground"> |
| 2407 | <span>{status}</span> | 2198 | <span>{status}</span> |
| 2408 | {totalTokens > 0 ? ( | 2199 | {totalTokens > 0 ? ( |
| 2409 | <span className="font-mono tabular-nums"> | 2200 | <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span> |
| 2410 | Σ {formatSubagentTokenCount(totalTokens)} | 2201 | ) : null} |
| 2411 | </span> | 2202 | <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span> |
| 2412 | ) : null} | 2203 | </span> |
| 2413 | </span> | 2204 | </button> |
| 2414 | </div> | ||
| 2415 | <Button | ||
| 2416 | type="button" | ||
| 2417 | size="xs" | ||
| 2418 | variant="outline" | ||
| 2419 | aria-label={live ? "Open agents" : "View agents"} | ||
| 2420 | onClick={onOpenAgents} | ||
| 2421 | > | ||
| 2422 | <EyeIcon className="size-3" /> | ||
| 2423 | <span className="hidden sm:inline">{live ? "Open agents" : "View agents"}</span> | ||
| 2424 | </Button> | ||
| 2425 | </div> | ||
| 2426 | </div> | ||
| 2427 | ); | 2205 | ); |
| 2428 | }); | 2206 | }); |
| 2429 | 2207 | ||
| 2430 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | 2208 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2431 | workEntry: TimelineWorkEntry; | 2209 | workEntry: TimelineWorkEntry; |
| 2432 | workspaceRoot: string | undefined; | 2210 | workspaceRoot: string | undefined; |
| 2433 | isExpandedToolGroupEntry: boolean; | ||
| 2434 | }) { | 2211 | }) { |
| 2435 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | 2212 | const { workEntry, workspaceRoot } = props; |
| 2436 | // Before any hooks: spawn CTA rows render their own component. | 2213 | // Before any hooks: spawn CTA rows render their own component. |
| 2437 | if (workEntry.agentSpawn) { | 2214 | if (workEntry.agentSpawn) { |
| 2438 | return <AgentSpawnCtaRow workEntry={workEntry} />; | 2215 | return <AgentSpawnCtaRow workEntry={workEntry} />; |
| 2439 | } | 2216 | } |
| 2440 | return ( | 2217 | return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />; |
| 2441 | <PlainWorkEntryRow | ||
| 2442 | workEntry={workEntry} | ||
| 2443 | workspaceRoot={workspaceRoot} | ||
| 2444 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | ||
| 2445 | /> | ||
| 2446 | ); | ||
| 2447 | }); | 2218 | }); |
| 2448 | 2219 | ||
| 2449 | const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | 2220 | const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2450 | workEntry: TimelineWorkEntry; | 2221 | workEntry: TimelineWorkEntry; |
| 2451 | workspaceRoot: string | undefined; | 2222 | workspaceRoot: string | undefined; |
| 2452 | isExpandedToolGroupEntry: boolean; | ||
| 2453 | }) { | 2223 | }) { |
| 2454 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | 2224 | const { workEntry, workspaceRoot } = props; |
| 2225 | const activity = use(TimelineRowActivityCtx); | ||
| 2455 | const [expanded, setExpanded] = useState(false); | 2226 | const [expanded, setExpanded] = useState(false); |
| 2456 | const iconConfig = workToneIcon(workEntry.tone); | 2227 | const iconConfig = workToneIcon(workEntry.tone); |
| 2457 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; | 2228 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; |
| 2458 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); | 2229 | const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); |
| 2459 | const entryIconName = | 2230 | const heading = toolWorkEntryHeading(workEntry); |
| 2460 | showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); | 2231 | const rawPreview = workEntryPreview(workEntry, workspaceRoot); |
| 2461 | const isCommandEntry = | 2232 | const preview = |
| 2462 | workEntry.requestKind === "command" || | 2233 | rawPreview && |
| 2463 | workEntry.itemType === "command_execution" || | 2234 | normalizeCompactToolLabel(rawPreview).toLowerCase() === |
| 2464 | Boolean(workEntry.command); | 2235 | normalizeCompactToolLabel(heading).toLowerCase() |
| 2465 | const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | 2236 | ? null |
| 2237 | : rawPreview; | ||
| 2238 | const displayText = preview ? `${heading} - ${preview}` : heading; | ||
| 2466 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); | 2239 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); |
| 2467 | const canExpand = expandedBody !== null; | 2240 | const canExpand = expandedBody !== null; |
| 2241 | const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); | ||
| 2468 | const showDestructiveRowStyle = | 2242 | const showDestructiveRowStyle = |
| 2469 | showFailedIndicator && | 2243 | showFailedIndicator && |
| 2470 | (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); | 2244 | (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); |
| 2471 | const iconWrapperClass = cn( | 2245 | const iconWrapperClass = cn( |
| 2472 | "flex size-6 shrink-0 items-center justify-center", | 2246 | "flex size-5 shrink-0 items-center justify-center", |
| 2473 | showWarningIndicator || showFailedIndicator | 2247 | showWarningIndicator |
| 2474 | ? "text-destructive" | 2248 | ? "text-destructive" |
| 2475 | : showDestructiveRowStyle | 2249 | : showDestructiveRowStyle |
| 2476 | ? "text-destructive" | 2250 | ? "text-destructive" |
| ... | @@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2482 | ? "font-medium text-warning" | 2256 | ? "font-medium text-warning" |
| 2483 | : showDestructiveRowStyle | 2257 | : showDestructiveRowStyle |
| 2484 | ? "font-medium text-destructive" | 2258 | ? "font-medium text-destructive" |
| 2485 | : workLogEntryIsToolLike(workEntry) | 2259 | : "font-medium text-foreground"; |
| 2486 | ? "text-secondary-label" | 2260 | const turnSettled = !activity.activeTurnInProgress; |
| 2487 | : "text-foreground/80"; | 2261 | const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); |
| 2488 | const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; | 2262 | const showSuccessIndicator = |
| 2263 | workEntryIndicatesToolSuccess(workEntry) || | ||
| 2264 | (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); | ||
| 2489 | const rowToggleProps = canExpand | 2265 | const rowToggleProps = canExpand |
| 2490 | ? { | 2266 | ? { |
| 2491 | role: "button" as const, | 2267 | role: "button" as const, |
| 2492 | tabIndex: 0 as const, | 2268 | tabIndex: 0 as const, |
| 2493 | "aria-label": displayText, | 2269 | "aria-label": displayText, |
| 2494 | "aria-expanded": expanded, | ||
| 2495 | onClick: () => setExpanded((v) => !v), | 2270 | onClick: () => setExpanded((v) => !v), |
| 2496 | onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => { | 2271 | onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => { |
| 2497 | if (e.key === "Enter" || e.key === " ") { | 2272 | if (e.key === "Enter" || e.key === " ") { |
| ... | @@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2505 | return ( | 2280 | return ( |
| 2506 | <div | 2281 | <div |
| 2507 | className={cn( | 2282 | className={cn( |
| 2508 | "flex flex-col rounded-md px-0.5 transition-colors", | 2283 | "flex flex-col rounded-md px-0.5 py-0.5 transition-colors", |
| 2509 | isExpandedToolGroupEntry ? "py-0" : "py-0.5", | ||
| 2510 | canExpand && | 2284 | canExpand && |
| 2511 | "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | 2285 | "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", |
| 2512 | )} | 2286 | )} |
| 2513 | {...rowToggleProps} | 2287 | {...rowToggleProps} |
| 2514 | > | 2288 | > |
| 2515 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> | 2289 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> |
| 2516 | {showEntryIcon ? ( | 2290 | <span className={iconWrapperClass}> |
| 2517 | <span | 2291 | <WorkEntryIconSvg |
| 2518 | className={iconWrapperClass} | 2292 | name={entryIconName} |
| 2519 | aria-label={showFailedIndicator ? "Tool call failed" : undefined} | 2293 | className="block size-3.5 shrink-0 stroke-[1.8] opacity-80" |
| 2520 | > | 2294 | /> |
| 2521 | <WorkEntryIconSvg | 2295 | </span> |
| 2522 | name={entryIconName} | ||
| 2523 | className="block size-4 shrink-0 stroke-[1.8] opacity-70" | ||
| 2524 | /> | ||
| 2525 | </span> | ||
| 2526 | ) : null} | ||
| 2527 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 2296 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 2528 | <div className="min-w-0 flex-1 overflow-hidden"> | 2297 | <div className="min-w-0 flex-1 overflow-hidden"> |
| 2529 | <p | 2298 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5"> |
| 2530 | className={cn( | 2299 | <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span> |
| 2531 | "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed", | 2300 | {preview && ( |
| 2532 | isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm", | 2301 | <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span> |
| 2533 | )} | 2302 | )} |
| 2534 | > | ||
| 2535 | <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span> | ||
| 2536 | </p> | 2303 | </p> |
| 2537 | </div> | 2304 | </div> |
| 2305 | <div className="flex shrink-0 items-center gap-px text-icon-muted"> | ||
| 2306 | <span | ||
| 2307 | className="flex size-4 shrink-0 items-center justify-center" | ||
| 2308 | aria-hidden={!canExpand} | ||
| 2309 | > | ||
| 2310 | {canExpand ? ( | ||
| 2311 | <ChevronDownIcon | ||
| 2312 | className={cn( | ||
| 2313 | "size-3 shrink-0 opacity-70 transition-transform duration-200", | ||
| 2314 | expanded && "rotate-180", | ||
| 2315 | )} | ||
| 2316 | aria-hidden | ||
| 2317 | /> | ||
| 2318 | ) : null} | ||
| 2319 | </span> | ||
| 2320 | <span className="flex size-4 shrink-0 items-center justify-center"> | ||
| 2321 | {showFailedIndicator ? ( | ||
| 2322 | <Tooltip> | ||
| 2323 | <TooltipTrigger | ||
| 2324 | render={ | ||
| 2325 | <span | ||
| 2326 | className="flex size-4 items-center justify-center" | ||
| 2327 | aria-label="Tool call failed" | ||
| 2328 | /> | ||
| 2329 | } | ||
| 2330 | > | ||
| 2331 | <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden /> | ||
| 2332 | </TooltipTrigger> | ||
| 2333 | <TooltipPopup>Failed</TooltipPopup> | ||
| 2334 | </Tooltip> | ||
| 2335 | ) : showSuccessIndicator ? ( | ||
| 2336 | <Tooltip> | ||
| 2337 | <TooltipTrigger | ||
| 2338 | render={<span className="flex size-4 items-center justify-center" />} | ||
| 2339 | > | ||
| 2340 | <span className="inline-flex size-4 items-center justify-center"> | ||
| 2341 | <CheckIcon | ||
| 2342 | className="block size-3 shrink-0 stroke-current" | ||
| 2343 | stroke="currentColor" | ||
| 2344 | aria-hidden | ||
| 2345 | /> | ||
| 2346 | </span> | ||
| 2347 | </TooltipTrigger> | ||
| 2348 | <TooltipPopup>Completed</TooltipPopup> | ||
| 2349 | </Tooltip> | ||
| 2350 | ) : showNeutralIndicator ? ( | ||
| 2351 | <Tooltip> | ||
| 2352 | <TooltipTrigger | ||
| 2353 | render={<span className="flex size-4 items-center justify-center" />} | ||
| 2354 | > | ||
| 2355 | <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden /> | ||
| 2356 | </TooltipTrigger> | ||
| 2357 | <TooltipPopup>Empty</TooltipPopup> | ||
| 2358 | </Tooltip> | ||
| 2359 | ) : null} | ||
| 2360 | </span> | ||
| 2361 | </div> | ||
| 2538 | </div> | 2362 | </div> |
| 2539 | </div> | 2363 | </div> |
| 2540 | {expanded && canExpand && expandedBody ? ( | 2364 | {expanded && canExpand && expandedBody ? ( |
| 2541 | <div | 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 | onClick={stopRowToggle} | 2367 | onClick={stopRowToggle} |
| 2544 | onPointerDown={stopRowToggle} | 2368 | onPointerDown={stopRowToggle} |
| 2545 | > | 2369 | > |
| 2546 | <pre | 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"> |
| 2547 | className={cn( | ||
| 2548 | "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[11px] leading-relaxed", | ||
| 2549 | isCommandEntry ? "select-all" : "select-text", | ||
| 2550 | )} | ||
| 2551 | > | ||
| 2552 | {expandedBody} | 2371 | {expandedBody} |
| 2553 | </pre> | 2372 | </pre> |
| 2554 | </div> | 2373 | </div> |
apps/web/src/components/chat/PanelLayoutControls.tsx+5-13| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; | 1 | import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | 3 | ||
| 4 | import { cn } from "../../lib/utils"; | ||
| 5 | import { Toggle } from "../ui/toggle"; | 4 | import { Toggle } from "../ui/toggle"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | 6 | ||
| ... | @@ -13,7 +12,6 @@ interface PanelLayoutControlsProps { | ... | @@ -13,7 +12,6 @@ interface PanelLayoutControlsProps { |
| 13 | rightPanelAvailable: boolean; | 12 | rightPanelAvailable: boolean; |
| 14 | rightPanelOpen: boolean; | 13 | rightPanelOpen: boolean; |
| 15 | rightPanelShortcutLabel: string | null; | 14 | rightPanelShortcutLabel: string | null; |
| 16 | rightPanelUnavailableLabel?: string; | ||
| 17 | /** Running + waiting subagents in this thread; badges the right panel toggle. */ | 15 | /** Running + waiting subagents in this thread; badges the right panel toggle. */ |
| 18 | liveAgentCount: number; | 16 | liveAgentCount: number; |
| 19 | onToggleTerminal: () => void; | 17 | onToggleTerminal: () => void; |
| ... | @@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ | ... | @@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 28 | rightPanelAvailable, | 26 | rightPanelAvailable, |
| 29 | rightPanelOpen, | 27 | rightPanelOpen, |
| 30 | rightPanelShortcutLabel, | 28 | rightPanelShortcutLabel, |
| 31 | rightPanelUnavailableLabel = "Right panel is unavailable", | ||
| 32 | liveAgentCount, | 29 | liveAgentCount, |
| 33 | onToggleTerminal, | 30 | onToggleTerminal, |
| 34 | onToggleRightPanel, | 31 | onToggleRightPanel, |
| ... | @@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ | ... | @@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 43 | <TooltipTrigger | 40 | <TooltipTrigger |
| 44 | render={ | 41 | render={ |
| 45 | <Toggle | 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 | pressed={terminalOpen} | 44 | pressed={terminalOpen} |
| 48 | onPressedChange={onToggleTerminal} | 45 | onPressedChange={onToggleTerminal} |
| 49 | aria-label="Toggle terminal drawer" | 46 | aria-label="Toggle terminal drawer" |
| ... | @@ -66,7 +63,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ | ... | @@ -66,7 +63,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 66 | <TooltipTrigger | 63 | <TooltipTrigger |
| 67 | render={ | 64 | render={ |
| 68 | <Toggle | 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 | pressed={rightPanelOpen} | 67 | pressed={rightPanelOpen} |
| 71 | onPressedChange={onToggleRightPanel} | 68 | onPressedChange={onToggleRightPanel} |
| 72 | aria-label={ | 69 | aria-label={ |
| ... | @@ -78,12 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ | ... | @@ -78,12 +75,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 78 | size="sm" | 75 | size="sm" |
| 79 | disabled={!rightPanelAvailable} | 76 | disabled={!rightPanelAvailable} |
| 80 | > | 77 | > |
| 81 | <PanelRightIcon | 78 | <PanelRightIcon className="size-4" /> |
| 82 | className={cn( | ||
| 83 | "size-4", | ||
| 84 | !rightPanelAvailable && "text-muted-foreground opacity-100", | ||
| 85 | )} | ||
| 86 | /> | ||
| 87 | {liveAgentCount > 0 ? ( | 79 | {liveAgentCount > 0 ? ( |
| 88 | <span | 80 | <span |
| 89 | aria-hidden | 81 | aria-hidden |
| ... | @@ -102,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ | ... | @@ -102,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 102 | ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` | 94 | ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` |
| 103 | : "" | 95 | : "" |
| 104 | }` | 96 | }` |
| 105 | : rightPanelUnavailableLabel} | 97 | : "Right panel is unavailable"} |
| 106 | </TooltipPopup> | 98 | </TooltipPopup> |
| 107 | </Tooltip> | 99 | </Tooltip> |
| 108 | </div> | 100 | </div> |
| ... | @@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl | ... | @@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl |
| 122 | <TooltipTrigger | 114 | <TooltipTrigger |
| 123 | render={ | 115 | render={ |
| 124 | <Toggle | 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 | pressed={maximized} | 118 | pressed={maximized} |
| 127 | onPressedChange={onToggle} | 119 | onPressedChange={onToggle} |
| 128 | aria-label={label} | 120 | aria-label={label} |
apps/web/src/components/composerInlineChip.ts+7-7| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | // composer honors the prompt font-size preference). The chat variant pins the | 2 | // composer honors the prompt font-size preference). The chat variant pins the |
| 3 | // original 12px, where every em value resolves to the same pixels as before. | 3 | // original 12px, where every em value resolves to the same pixels as before. |
| 4 | const INLINE_CHIP_CLASS_NAME = | 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 | export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; | 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,18 +11,18 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[ |
| 11 | export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = | 11 | export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = |
| 12 | "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; | 12 | "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; |
| 13 | 13 | ||
| 14 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = | 14 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; |
| 15 | "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block"; | ||
| 16 | 15 | ||
| 17 | export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; | 16 | export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; |
| 18 | 17 | ||
| 19 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = | 18 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; |
| 20 | "block self-center truncate leading-none 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 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = | 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 | 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>`; | 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,7 +25,6 @@ import { |
| 25 | GitPullRequestDraftIcon, | 25 | GitPullRequestDraftIcon, |
| 26 | GitPullRequestIcon, | 26 | GitPullRequestIcon, |
| 27 | HammerIcon, | 27 | HammerIcon, |
| 28 | LayersIcon, | ||
| 29 | MessageCircleQuestionIcon, | 28 | MessageCircleQuestionIcon, |
| 30 | MessageSquareIcon, | 29 | MessageSquareIcon, |
| 31 | LinkIcon, | 30 | LinkIcon, |
| ... | @@ -51,7 +50,6 @@ import { | ... | @@ -51,7 +50,6 @@ import { |
| 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 50 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 52 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | 51 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 53 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 52 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 54 | import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; | ||
| 55 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; | 53 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 56 | import { cn } from "~/lib/utils"; | 54 | import { cn } from "~/lib/utils"; |
| 57 | import { readLocalApi } from "~/localApi"; | 55 | import { readLocalApi } from "~/localApi"; |
| ... | @@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query"; | ... | @@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query"; |
| 62 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 60 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 63 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 61 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 64 | import { useAtomCommand } from "~/state/use-atom-command"; | 62 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 65 | import { vcsEnvironment } from "~/state/vcs"; | ||
| 66 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 63 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 67 | 64 | ||
| 68 | import { | 65 | import { |
| ... | @@ -77,7 +74,6 @@ import { | ... | @@ -77,7 +74,6 @@ import { |
| 77 | import { Badge } from "../ui/badge"; | 74 | import { Badge } from "../ui/badge"; |
| 78 | import { Button } from "../ui/button"; | 75 | import { Button } from "../ui/button"; |
| 79 | import { Input } from "../ui/input"; | 76 | import { Input } from "../ui/input"; |
| 80 | import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; | ||
| 81 | import { | 77 | import { |
| 82 | Menu, | 78 | Menu, |
| 83 | MenuItem, | 79 | MenuItem, |
| ... | @@ -120,7 +116,6 @@ import { | ... | @@ -120,7 +116,6 @@ import { |
| 120 | } from "./pullRequestProjectAssignment.logic"; | 116 | } from "./pullRequestProjectAssignment.logic"; |
| 121 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | 117 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 122 | import { | 118 | import { |
| 123 | PullRequestActorAvatar, | ||
| 124 | PullRequestActorLabel, | 119 | PullRequestActorLabel, |
| 125 | PullRequestDiffStat, | 120 | PullRequestDiffStat, |
| 126 | PullRequestMetaLine, | 121 | PullRequestMetaLine, |
| ... | @@ -354,6 +349,7 @@ export function PullRequestDetailPanel({ | ... | @@ -354,6 +349,7 @@ export function PullRequestDetailPanel({ |
| 354 | onClose, | 349 | onClose, |
| 355 | onStateChange, | 350 | onStateChange, |
| 356 | context = "page", | 351 | context = "page", |
| 352 | chromeVariant = "full", | ||
| 357 | composerDraftTarget, | 353 | composerDraftTarget, |
| 358 | }: { | 354 | }: { |
| 359 | environmentId: EnvironmentId; | 355 | environmentId: EnvironmentId; |
| ... | @@ -385,6 +381,12 @@ export function PullRequestDetailPanel({ | ... | @@ -385,6 +381,12 @@ export function PullRequestDetailPanel({ |
| 385 | * again is at best a no-op and at worst git refusing a branch two checkouts. | 381 | * again is at best a no-op and at worst git refusing a branch two checkouts. |
| 386 | */ | 382 | */ |
| 387 | context?: "page" | "thread"; | 383 | context?: "page" | "thread"; |
| 384 | /** | ||
| 385 | * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` | ||
| 386 | * folds the whole of it into the top row once the active tab scrolls, and unfolds at the | ||
| 387 | * top — the chrome spends its height on what is being read. | ||
| 388 | */ | ||
| 389 | chromeVariant?: "full" | "collapse"; | ||
| 388 | /** | 390 | /** |
| 389 | * The open thread's composer. Beside the thread whose own pull request this is, hand-offs | 391 | * The open thread's composer. Beside the thread whose own pull request this is, hand-offs |
| 390 | * land here instead of opening a new thread — the branch is already under the reader's feet. | 392 | * land here instead of opening a new thread — the branch is already under the reader's feet. |
| ... | @@ -421,13 +423,26 @@ export function PullRequestDetailPanel({ | ... | @@ -421,13 +423,26 @@ export function PullRequestDetailPanel({ |
| 421 | ); | 423 | ); |
| 422 | }, [tab]); | 424 | }, [tab]); |
| 423 | const [chromeCondensed, setChromeCondensed] = useState(false); | 425 | const [chromeCondensed, setChromeCondensed] = useState(false); |
| 426 | // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll | ||
| 427 | // events, so the capture handler always writes the active tab's entry — and a tab switch | ||
| 428 | // reads the destination's memory instead of inheriting the tab being left. A tab too short | ||
| 429 | // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome | ||
| 430 | // it has no scrollbar to reopen. | ||
| 424 | const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); | 431 | const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); |
| 425 | useEffect(() => { | 432 | useEffect(() => { |
| 426 | setChromeCondensed(chromeStateByTab.current[tab] ?? false); | 433 | setChromeCondensed(chromeStateByTab.current[tab] ?? false); |
| 427 | }, [tab]); | 434 | }, [tab]); |
| 428 | const condensed = chromeCondensed; | 435 | const condensed = chromeVariant === "collapse" && chromeCondensed; |
| 436 | // Collapsing removes the fold's height from the chrome, which would otherwise hand that | ||
| 437 | // height to the scrollport and leap the content up by it mid-scroll. The cure is exact | ||
| 438 | // compensation: collapse only once the reader has scrolled at least the fold's height, | ||
| 439 | // then give that height back to `scrollTop` before the next paint — the content under | ||
| 440 | // their eyes does not move, and the collapse itself is the only thing that changes. | ||
| 429 | const scrollerRef = useRef<HTMLElement | null>(null); | 441 | const scrollerRef = useRef<HTMLElement | null>(null); |
| 430 | const foldRef = useRef<HTMLDivElement | null>(null); | 442 | const foldRef = useRef<HTMLDivElement | null>(null); |
| 443 | // The condensed chrome's second row opens as the fold closes, so the height the scrollport | ||
| 444 | // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` | ||
| 445 | // through a zero track reads its natural height in either state. | ||
| 431 | const condensedRowRef = useRef<HTMLDivElement | null>(null); | 446 | const condensedRowRef = useRef<HTMLDivElement | null>(null); |
| 432 | const compensationRef = useRef<number | null>(null); | 447 | const compensationRef = useRef<number | null>(null); |
| 433 | useLayoutEffect(() => { | 448 | useLayoutEffect(() => { |
| ... | @@ -448,6 +463,7 @@ export function PullRequestDetailPanel({ | ... | @@ -448,6 +463,7 @@ export function PullRequestDetailPanel({ |
| 448 | target: "branch name", | 463 | target: "branch name", |
| 449 | timeout: 1600, | 464 | timeout: 1600, |
| 450 | }); | 465 | }); |
| 466 | |||
| 451 | // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be | 467 | // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be |
| 452 | // clicked, so a reader who does click it lands on a chunk already in the module cache. | 468 | // clicked, so a reader who does click it lands on a chunk already in the module cache. |
| 453 | useEffect(() => { | 469 | useEffect(() => { |
| ... | @@ -486,30 +502,6 @@ export function PullRequestDetailPanel({ | ... | @@ -486,30 +502,6 @@ export function PullRequestDetailPanel({ |
| 486 | }, | 502 | }, |
| 487 | [activity, coreDetail], | 503 | [activity, coreDetail], |
| 488 | ); | 504 | ); |
| 489 | const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); | ||
| 490 | const baseBranchRefQuery = useEnvironmentQuery( | ||
| 491 | detail === null | ||
| 492 | ? null | ||
| 493 | : vcsEnvironment.listRefs({ | ||
| 494 | environmentId, | ||
| 495 | input: { | ||
| 496 | cwd: detail.workspaceRoot, | ||
| 497 | query: detail.baseBranch, | ||
| 498 | includeMatchingRemoteRefs: true, | ||
| 499 | limit: 20, | ||
| 500 | }, | ||
| 501 | }), | ||
| 502 | ); | ||
| 503 | const matchingBaseBranchRefs = | ||
| 504 | detail === null | ||
| 505 | ? [] | ||
| 506 | : (baseBranchRefQuery.data?.refs.filter( | ||
| 507 | (refName) => | ||
| 508 | refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), | ||
| 509 | ) ?? []); | ||
| 510 | const isStackedPullRequest = | ||
| 511 | matchingBaseBranchRefs.length > 0 && | ||
| 512 | !matchingBaseBranchRefs.some((refName) => refName.isDefault); | ||
| 513 | const activityPending = activityQuery.isPending && activity === null; | 505 | const activityPending = activityQuery.isPending && activity === null; |
| 514 | const activityError = activity === null ? activityQuery.error : null; | 506 | const activityError = activity === null ? activityQuery.error : null; |
| 515 | const refreshDetail = useCallback(() => { | 507 | const refreshDetail = useCallback(() => { |
| ... | @@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({ | ... | @@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({ |
| 1027 | const can = (action: PullRequestAction) => | 1019 | const can = (action: PullRequestAction) => |
| 1028 | detail?.capabilities.actions.includes(action) === true && | 1020 | detail?.capabilities.actions.includes(action) === true && |
| 1029 | detail.viewerPermissions.actions.includes(action); | 1021 | detail.viewerPermissions.actions.includes(action); |
| 1030 | // One live action holds the slot. Conflicts take priority because every other completion action | 1022 | // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes |
| 1031 | // depends on resolving them first, even for a reader who cannot merge on the host themselves. | 1023 | // to the thing that would help instead of a Merge button that only ever says no. |
| 1032 | const primaryAction = | 1024 | const primaryAction = |
| 1033 | detail === null || detail.state !== "open" | 1025 | detail === null || detail.state !== "open" |
| 1034 | ? null | 1026 | ? null |
| 1035 | : conflicting | 1027 | : detail.isDraft && can("ready") |
| 1036 | ? "resolve" | 1028 | ? "ready" |
| 1037 | : detail.isDraft && can("ready") | 1029 | : !can("merge") |
| 1038 | ? "ready" | 1030 | ? null |
| 1039 | : !can("merge") | 1031 | : conflicting |
| 1040 | ? null | 1032 | ? "resolve" |
| 1041 | : allowedMergeMethods.length > 0 | 1033 | : allowedMergeMethods.length > 0 |
| 1042 | ? "merge" | 1034 | ? "merge" |
| 1043 | : null; | 1035 | : null; |
| 1044 | // The pull request number carries this state in the overview and the right-panel tab mirrors | 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 | const statePresentation = detail | 1038 | const statePresentation = detail |
| 1047 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) | 1039 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) |
| 1048 | : null; | 1040 | : null; |
| 1049 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; | 1041 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; |
| 1050 | const checksState = detail ? pullRequestChecksState(detail.checks) : null; | 1042 | const checksState = detail ? pullRequestChecksState(detail.checks) : null; |
| 1051 | 1043 | ||
| 1052 | if (detailQuery.isPending && !detail) { | ||
| 1053 | return <PullRequestDetailGhost />; | ||
| 1054 | } | ||
| 1055 | |||
| 1056 | return ( | 1044 | return ( |
| 1057 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> | 1045 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> |
| 1046 | {/* The top row's geometry never changes: both of its states occupy the same stacked | ||
| 1047 | cell and crossfade, so the actions on the right have one home whatever the chrome | ||
| 1048 | is doing below. The fold and this fade share one 200ms clock. */} | ||
| 1058 | <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> | 1049 | <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> |
| 1059 | <div className="ml-4 grid h-7 min-w-0 items-center"> | 1050 | {/* The fixed height lives on the two top-row cells — not the grid, whose later rows |
| 1051 | are the fold — so the actions have one immovable home in both states. */} | ||
| 1052 | <div className="ml-4 grid h-11 min-w-0 items-center"> | ||
| 1060 | <div | 1053 | <div |
| 1061 | aria-hidden={condensed} | 1054 | aria-hidden={condensed} |
| 1062 | inert={condensed} | 1055 | inert={condensed} |
| 1063 | className={cn( | 1056 | className={cn( |
| 1064 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none", | 1057 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none", |
| 1058 | // Sequenced, not simultaneous: the leaving layer clears quickly before the | ||
| 1059 | // arriving one lands, so no frame shows both texts superimposed at half opacity. | ||
| 1065 | condensed | 1060 | condensed |
| 1066 | ? "pointer-events-none -translate-y-1 opacity-0 duration-100" | 1061 | ? "pointer-events-none opacity-0 duration-100" |
| 1067 | : "translate-y-0 opacity-100 delay-50 duration-150", | 1062 | : "opacity-100 delay-75 duration-150", |
| 1068 | )} | 1063 | )} |
| 1069 | > | 1064 | > |
| 1070 | {detail && statePresentation ? ( | 1065 | {detail && statePresentation ? ( |
| 1071 | <> | 1066 | <> |
| 1072 | {repositoryUrl ? ( | 1067 | <span className="min-w-0 truncate" title={detail.repository}> |
| 1073 | <button | 1068 | {detail.repository} |
| 1074 | type="button" | 1069 | </span> |
| 1075 | onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)} | ||
| 1076 | className="min-w-0 truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" | ||
| 1077 | title={`Open ${detail.repository} repository`} | ||
| 1078 | > | ||
| 1079 | {detail.repository} | ||
| 1080 | </button> | ||
| 1081 | ) : ( | ||
| 1082 | <span className="min-w-0 truncate font-medium text-muted-foreground"> | ||
| 1083 | {detail.repository} | ||
| 1084 | </span> | ||
| 1085 | )} | ||
| 1086 | <button | 1070 | <button |
| 1087 | type="button" | 1071 | type="button" |
| 1088 | onClick={() => void readLocalApi()?.shell.openExternal(detail.url)} | 1072 | onClick={() => void readLocalApi()?.shell.openExternal(detail.url)} |
| ... | @@ -1103,10 +1087,10 @@ export function PullRequestDetailPanel({ | ... | @@ -1103,10 +1087,10 @@ export function PullRequestDetailPanel({ |
| 1103 | aria-hidden={!condensed} | 1087 | aria-hidden={!condensed} |
| 1104 | inert={!condensed} | 1088 | inert={!condensed} |
| 1105 | className={cn( | 1089 | className={cn( |
| 1106 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none", | 1090 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none", |
| 1107 | condensed | 1091 | condensed |
| 1108 | ? "translate-y-0 opacity-100 delay-50 duration-150" | 1092 | ? "opacity-100 delay-75 duration-150" |
| 1109 | : "pointer-events-none translate-y-1 opacity-0 duration-100", | 1093 | : "pointer-events-none opacity-0 duration-100", |
| 1110 | )} | 1094 | )} |
| 1111 | > | 1095 | > |
| 1112 | {detail && statePresentation ? ( | 1096 | {detail && statePresentation ? ( |
| ... | @@ -1125,14 +1109,30 @@ export function PullRequestDetailPanel({ | ... | @@ -1125,14 +1109,30 @@ export function PullRequestDetailPanel({ |
| 1125 | > | 1109 | > |
| 1126 | #{detail.number} | 1110 | #{detail.number} |
| 1127 | </button> | 1111 | </button> |
| 1128 | <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}> | 1112 | <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}> |
| 1129 | {detail.title} | 1113 | {detail.title} |
| 1130 | </h1> | 1114 | </span> |
| 1115 | {conflicting ? ( | ||
| 1116 | <Badge | ||
| 1117 | variant="error" | ||
| 1118 | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive" | ||
| 1119 | > | ||
| 1120 | <TriangleAlertIcon className="size-3" /> | ||
| 1121 | Conflicts | ||
| 1122 | </Badge> | ||
| 1123 | ) : checksSummary ? ( | ||
| 1124 | <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground"> | ||
| 1125 | {detail && checksState !== null ? ( | ||
| 1126 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | ||
| 1127 | ) : null} | ||
| 1128 | {checksSummary} | ||
| 1129 | </span> | ||
| 1130 | ) : null} | ||
| 1131 | </> | 1131 | </> |
| 1132 | ) : null} | 1132 | ) : null} |
| 1133 | </div> | 1133 | </div> |
| 1134 | </div> | 1134 | </div> |
| 1135 | <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1"> | 1135 | <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1"> |
| 1136 | {detail ? ( | 1136 | {detail ? ( |
| 1137 | <> | 1137 | <> |
| 1138 | <Menu> | 1138 | <Menu> |
| ... | @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ |
| 1140 | render={ | 1140 | render={ |
| 1141 | <Button | 1141 | <Button |
| 1142 | aria-label="More pull request actions" | 1142 | aria-label="More pull request actions" |
| 1143 | className="order-last size-6" | 1143 | className="size-6" |
| 1144 | size="icon-xs" | 1144 | size="icon-xs" |
| 1145 | variant="ghost-muted" | 1145 | variant="ghost-muted" |
| 1146 | /> | 1146 | /> |
| ... | @@ -1312,15 +1312,16 @@ export function PullRequestDetailPanel({ | ... | @@ -1312,15 +1312,16 @@ export function PullRequestDetailPanel({ |
| 1312 | <MenuTrigger | 1312 | <MenuTrigger |
| 1313 | disabled={handoff !== null} | 1313 | disabled={handoff !== null} |
| 1314 | render={ | 1314 | render={ |
| 1315 | <Button size="xs" variant="outline" className="gap-0 p-0"> | 1315 | <Button size="xs" variant="outline"> |
| 1316 | <span className="inline-flex h-full items-center gap-1.5 ps-[8.5px] pe-2"> | 1316 | {handoff?.startsWith("checkout") ? ( |
| 1317 | <GitBranchIcon aria-hidden className="size-3.5" /> | 1317 | "Checking out..." |
| 1318 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} | 1318 | ) : ( |
| 1319 | </span> | 1319 | <> |
| 1320 | <span aria-hidden className="h-4 w-px shrink-0 bg-input" /> | 1320 | <GitBranchIcon className="size-3" /> |
| 1321 | <span className="inline-flex h-full w-6 shrink-0 items-center justify-center"> | 1321 | Check out |
| 1322 | <ChevronDownIcon aria-hidden className="size-4 text-muted-foreground" /> | 1322 | <ChevronDownIcon className="size-3 text-muted-foreground" /> |
| 1323 | </span> | 1323 | </> |
| 1324 | )} | ||
| 1324 | </Button> | 1325 | </Button> |
| 1325 | } | 1326 | } |
| 1326 | /> | 1327 | /> |
| ... | @@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({ |
| 1366 | Auto-merge | 1367 | Auto-merge |
| 1367 | </Badge> | 1368 | </Badge> |
| 1368 | ) : null} | 1369 | ) : null} |
| 1369 | {primaryAction === "resolve" ? ( | 1370 | {primaryAction === "ready" ? ( |
| 1370 | <Badge | ||
| 1371 | variant="error" | ||
| 1372 | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive hover:bg-destructive/14" | ||
| 1373 | render={ | ||
| 1374 | <button | ||
| 1375 | type="button" | ||
| 1376 | disabled={handoff !== null} | ||
| 1377 | onClick={startResolveConflicts} | ||
| 1378 | /> | ||
| 1379 | } | ||
| 1380 | > | ||
| 1381 | <TriangleAlertIcon className="size-3" /> | ||
| 1382 | {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} | ||
| 1383 | </Badge> | ||
| 1384 | ) : primaryAction === "ready" ? ( | ||
| 1385 | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> | 1371 | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> |
| 1386 | Ready for review | 1372 | Ready for review |
| 1387 | </Button> | 1373 | </Button> |
| ... | @@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({ | ... | @@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({ |
| 1408 | ) : null} | 1394 | ) : null} |
| 1409 | </div> | 1395 | </div> |
| 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 | <div | 1410 | <div |
| 1413 | ref={condensedRowRef} | 1411 | ref={condensedRowRef} |
| 1414 | className={cn( | 1412 | className={cn( |
| 1415 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", | 1413 | "min-h-0 overflow-hidden", |
| 1416 | condensed | 1414 | condensed |
| 1417 | ? "translate-y-0 opacity-100 delay-50" | 1415 | ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none" |
| 1418 | : "translate-y-1 opacity-0 duration-100", | 1416 | : "opacity-0", |
| 1419 | )} | 1417 | )} |
| 1420 | inert={!condensed} | 1418 | inert={!condensed} |
| 1421 | > | 1419 | > |
| 1422 | {detail ? ( | 1420 | {detail ? ( |
| 1423 | <div className="col-span-2 min-w-0 px-4 pb-2 pt-1"> | 1421 | <div className="flex min-w-0 items-center gap-1 px-4 pb-2"> |
| 1424 | <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> | 1422 | <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5"> |
| 1425 | <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground"> | 1423 | {visibleTabs.map((item) => ( |
| 1426 | <PullRequestActorAvatar actor={detail.author} className="shrink-0" /> | 1424 | <button |
| 1427 | <span className="sr-only">{detail.author?.login ?? "ghost"}</span> | 1425 | key={item.value} |
| 1428 | <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span> | 1426 | type="button" |
| 1429 | </span> | 1427 | tabIndex={condensed ? 0 : -1} |
| 1430 | <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" /> | 1428 | aria-pressed={tab === item.value} |
| 1431 | <span | 1429 | onClick={() => setTab(item.value)} |
| 1432 | className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65" | 1430 | className={cn( |
| 1433 | title={`${detail.baseBranch} ← ${detail.headBranch}`} | 1431 | "rounded-md px-2 py-1 text-[11px] transition-colors", |
| 1434 | > | 1432 | tab === item.value |
| 1435 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> | 1433 | ? "bg-accent text-foreground" |
| 1436 | {isStackedPullRequest ? ( | 1434 | : "text-muted-foreground hover:text-foreground", |
| 1437 | <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" /> | 1435 | )} |
| 1438 | ) : null} | ||
| 1439 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | ||
| 1440 | </span> | ||
| 1441 | {freshness ? ( | ||
| 1442 | <PullRequestBaseFreshnessWarning | ||
| 1443 | baseBranch={detail.baseBranch} | ||
| 1444 | freshness={freshness} | ||
| 1445 | pending={actionPending} | ||
| 1446 | onUpdate={(method) => void perform("update-branch", undefined, method)} | ||
| 1447 | iconClassName="size-3" | ||
| 1448 | /> | ||
| 1449 | ) : null} | ||
| 1450 | <ArrowLeftIcon | ||
| 1451 | aria-label="receives changes from" | ||
| 1452 | className="size-3 shrink-0 opacity-60" | ||
| 1453 | /> | ||
| 1454 | <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code> | ||
| 1455 | </span> | ||
| 1456 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]"> | ||
| 1457 | <span | ||
| 1458 | className="inline-flex items-center gap-1 tabular-nums" | ||
| 1459 | aria-label={`${detail.changedFiles.toLocaleString()} changed ${ | ||
| 1460 | detail.changedFiles === 1 ? "file" : "files" | ||
| 1461 | }`} | ||
| 1462 | > | 1436 | > |
| 1463 | <FileDiffIcon aria-hidden className="size-3" /> | 1437 | {item.label} |
| 1464 | {detail.changedFiles.toLocaleString()} | 1438 | </button> |
| 1465 | </span> | 1439 | ))} |
| 1466 | <PullRequestDiffStat | 1440 | </nav> |
| 1467 | additions={detail.additions} | 1441 | <span |
| 1468 | deletions={detail.deletions} | 1442 | className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground" |
| 1469 | className="shrink-0 font-mono text-[11px]" | 1443 | title={`${detail.baseBranch} ← ${detail.headBranch}`} |
| 1444 | > | ||
| 1445 | <span className="truncate">{detail.baseBranch}</span> | ||
| 1446 | {freshness ? ( | ||
| 1447 | <PullRequestBaseFreshnessWarning | ||
| 1448 | baseBranch={detail.baseBranch} | ||
| 1449 | freshness={freshness} | ||
| 1450 | pending={actionPending} | ||
| 1451 | onUpdate={(method) => void perform("update-branch", undefined, method)} | ||
| 1452 | iconClassName="size-3" | ||
| 1470 | /> | 1453 | /> |
| 1454 | ) : null} | ||
| 1455 | <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" /> | ||
| 1456 | <span className="truncate">{detail.headBranch}</span> | ||
| 1457 | </span> | ||
| 1458 | <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground"> | ||
| 1459 | <span className="inline-flex items-center gap-1 tabular-nums"> | ||
| 1460 | <FileDiffIcon className="size-3" /> | ||
| 1461 | {detail.changedFiles.toLocaleString()} | ||
| 1471 | </span> | 1462 | </span> |
| 1472 | </div> | 1463 | <PullRequestDiffStat |
| 1464 | additions={detail.additions} | ||
| 1465 | deletions={detail.deletions} | ||
| 1466 | className="shrink-0 font-mono text-[11px]" | ||
| 1467 | /> | ||
| 1468 | </span> | ||
| 1473 | </div> | 1469 | </div> |
| 1474 | ) : null} | 1470 | ) : null} |
| 1475 | </div> | 1471 | </div> |
| 1476 | </div> | 1472 | </div> |
| 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 | <div | 1489 | <div |
| 1480 | ref={foldRef} | 1490 | ref={foldRef} |
| 1491 | // One-way on purpose: appearing content eases in over ground the instant track | ||
| 1492 | // already reserved; departing content cuts, because its ground is gone in the | ||
| 1493 | // same frame and the scroll compensation reads it as scrolled past. | ||
| 1481 | className={cn( | 1494 | className={cn( |
| 1482 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", | 1495 | "min-h-0 overflow-hidden", |
| 1483 | condensed | 1496 | condensed |
| 1484 | ? "-translate-y-1 opacity-0 duration-100" | 1497 | ? "opacity-0" |
| 1485 | : "translate-y-0 opacity-100 delay-50", | 1498 | : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none", |
| 1486 | )} | 1499 | )} |
| 1487 | inert={condensed} | 1500 | inert={condensed} |
| 1488 | > | 1501 | > |
| 1489 | {detail ? ( | 1502 | {detail ? ( |
| 1490 | <div className="col-span-2 mt-1 min-w-0 px-4 pb-4"> | 1503 | <div className="col-span-2 mt-3 min-w-0 px-4 pb-4"> |
| 1491 | {titleDraft === null ? ( | 1504 | {titleDraft === null ? ( |
| 1492 | <div className="group flex min-w-0 items-start gap-1"> | 1505 | <div className="group flex min-w-0 items-start gap-1"> |
| 1493 | <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug"> | 1506 | <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug"> |
| ... | @@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({ | ... | @@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({ |
| 1554 | </PullRequestMetaLine> | 1567 | </PullRequestMetaLine> |
| 1555 | 1568 | ||
| 1556 | <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> | 1569 | <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1557 | <span | 1570 | <code |
| 1558 | className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70" | 1571 | className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground" |
| 1559 | title={`${detail.baseBranch} ← ${detail.headBranch}`} | 1572 | title={detail.baseBranch} |
| 1560 | > | 1573 | > |
| 1561 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> | 1574 | {detail.baseBranch} |
| 1562 | {isStackedPullRequest ? ( | 1575 | </code> |
| 1563 | <LayersIcon aria-label="Stacked pull request" className="size-3 shrink-0" /> | 1576 | {freshness ? ( |
| 1564 | ) : null} | 1577 | <PullRequestBaseFreshnessWarning |
| 1565 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | 1578 | baseBranch={detail.baseBranch} |
| 1566 | </span> | 1579 | freshness={freshness} |
| 1567 | {freshness ? ( | 1580 | pending={actionPending} |
| 1568 | <PullRequestBaseFreshnessWarning | 1581 | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 1569 | baseBranch={detail.baseBranch} | ||
| 1570 | freshness={freshness} | ||
| 1571 | pending={actionPending} | ||
| 1572 | onUpdate={(method) => void perform("update-branch", undefined, method)} | ||
| 1573 | /> | ||
| 1574 | ) : null} | ||
| 1575 | <ArrowLeftIcon | ||
| 1576 | aria-label="receives changes from" | ||
| 1577 | className="size-3.5 shrink-0 opacity-60" | ||
| 1578 | /> | 1582 | /> |
| 1579 | <button | 1583 | ) : null} |
| 1580 | type="button" | 1584 | <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" /> |
| 1581 | className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | 1585 | <button |
| 1582 | aria-label={ | 1586 | type="button" |
| 1583 | isBranchCopied ? "Branch name copied" : "Copy pull request branch" | 1587 | className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" |
| 1584 | } | 1588 | aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"} |
| 1585 | title={isBranchCopied ? "Copied" : "Copy pull request branch"} | 1589 | title={isBranchCopied ? "Copied" : "Copy pull request branch"} |
| 1586 | onClick={() => copyBranchToClipboard(detail.headBranch)} | 1590 | onClick={() => copyBranchToClipboard(detail.headBranch)} |
| 1591 | > | ||
| 1592 | <code | ||
| 1593 | className={cn( | ||
| 1594 | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", | ||
| 1595 | isBranchCopied ? "opacity-0" : "opacity-100", | ||
| 1596 | )} | ||
| 1597 | title={detail.headBranch} | ||
| 1587 | > | 1598 | > |
| 1588 | <code | 1599 | {detail.headBranch} |
| 1589 | className={cn( | 1600 | </code> |
| 1590 | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", | 1601 | <span |
| 1591 | isBranchCopied ? "opacity-0" : "opacity-100", | 1602 | aria-hidden="true" |
| 1592 | )} | 1603 | className={cn( |
| 1593 | > | 1604 | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", |
| 1594 | {detail.headBranch} | 1605 | isBranchCopied ? "opacity-100" : "opacity-0", |
| 1595 | </code> | 1606 | )} |
| 1596 | <span | 1607 | > |
| 1597 | aria-hidden="true" | 1608 | Copied |
| 1598 | className={cn( | 1609 | </span> |
| 1599 | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", | 1610 | </button> |
| 1600 | isBranchCopied ? "opacity-100" : "opacity-0", | ||
| 1601 | )} | ||
| 1602 | > | ||
| 1603 | Copied | ||
| 1604 | </span> | ||
| 1605 | </button> | ||
| 1606 | </span> | ||
| 1607 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2"> | 1611 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2"> |
| 1608 | <span className="inline-flex items-center gap-1.5 tabular-nums"> | 1612 | <span className="inline-flex items-center gap-1.5 tabular-nums"> |
| 1609 | <FileDiffIcon className="size-3.5" /> | 1613 | <FileDiffIcon className="size-3.5" /> |
| ... | @@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({ | ... | @@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({ |
| 1619 | </div> | 1623 | </div> |
| 1620 | </div> | 1624 | </div> |
| 1621 | ) : null} | 1625 | ) : null} |
| 1622 | </div> | ||
| 1623 | </div> | ||
| 1624 | 1626 | ||
| 1625 | {detail ? ( | 1627 | {detail && conflicting ? ( |
| 1626 | <nav | 1628 | <div className="col-span-2 flex items-center gap-1 px-4 pb-3"> |
| 1627 | className={cn( | 1629 | <Badge |
| 1628 | "col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", | 1630 | variant="error" |
| 1629 | condensed ? "py-1.5" : "py-2", | 1631 | className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive" |
| 1630 | )} | ||
| 1631 | aria-label="Pull request tabs" | ||
| 1632 | > | ||
| 1633 | <SegmentedTabList> | ||
| 1634 | {visibleTabs.map((item) => ( | ||
| 1635 | <SegmentedTab | ||
| 1636 | key={item.value} | ||
| 1637 | selected={tab === item.value} | ||
| 1638 | onClick={() => setTab(item.value)} | ||
| 1639 | > | ||
| 1640 | {item.label} | ||
| 1641 | </SegmentedTab> | ||
| 1642 | ))} | ||
| 1643 | </SegmentedTabList> | ||
| 1644 | {tab === "summary" ? ( | ||
| 1645 | <span | ||
| 1646 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" | ||
| 1647 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} | ||
| 1648 | > | ||
| 1649 | {checksState !== null ? ( | ||
| 1650 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | ||
| 1651 | ) : ( | ||
| 1652 | <CircleDotIcon aria-hidden className="size-3.5" /> | ||
| 1653 | )} | ||
| 1654 | {checksSummary} | ||
| 1655 | </span> | ||
| 1656 | ) : tab === "timeline" ? ( | ||
| 1657 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> | ||
| 1658 | <PullRequestMetaLine | ||
| 1659 | className={cn( | ||
| 1660 | "whitespace-nowrap text-[11px] transition-opacity", | ||
| 1661 | (activityPending || activityError) && "opacity-35", | ||
| 1662 | )} | ||
| 1663 | > | 1632 | > |
| 1664 | <span | 1633 | <TriangleAlertIcon className="size-3.5" /> |
| 1665 | className="inline-flex items-center gap-1" | 1634 | Merge conflicts |
| 1666 | aria-label={ | 1635 | </Badge> |
| 1667 | activityError | ||
| 1668 | ? "Comments unavailable" | ||
| 1669 | : `${detail.commentCount.toLocaleString()} ${ | ||
| 1670 | detail.commentCount === 1 ? "comment" : "comments" | ||
| 1671 | }` | ||
| 1672 | } | ||
| 1673 | > | ||
| 1674 | <MessageSquareIcon aria-hidden className="size-3" /> | ||
| 1675 | {activityError | ||
| 1676 | ? "—" | ||
| 1677 | : activityPending | ||
| 1678 | ? "…" | ||
| 1679 | : detail.commentCount.toLocaleString()} | ||
| 1680 | </span> | ||
| 1681 | <span | ||
| 1682 | className="inline-flex items-center gap-1" | ||
| 1683 | aria-label={ | ||
| 1684 | activityError | ||
| 1685 | ? "Commits unavailable" | ||
| 1686 | : `${detail.commits.length.toLocaleString()} ${ | ||
| 1687 | detail.commits.length === 1 ? "commit" : "commits" | ||
| 1688 | }` | ||
| 1689 | } | ||
| 1690 | > | ||
| 1691 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> | ||
| 1692 | {activityError | ||
| 1693 | ? "—" | ||
| 1694 | : activityPending | ||
| 1695 | ? "…" | ||
| 1696 | : detail.commits.length.toLocaleString()} | ||
| 1697 | </span> | ||
| 1698 | </PullRequestMetaLine> | ||
| 1699 | <Button | 1636 | <Button |
| 1700 | size="xs" | 1637 | size="xs" |
| 1701 | variant="ghost" | 1638 | variant="ghost" |
| 1702 | className="h-7 px-2 text-[10px] text-muted-foreground" | 1639 | className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive" |
| 1703 | aria-label={ | 1640 | disabled={handoff !== null} |
| 1704 | timelineOrder === "newest" | 1641 | onClick={startResolveConflicts} |
| 1705 | ? "Show oldest activity first" | ||
| 1706 | : "Show newest activity first" | ||
| 1707 | } | ||
| 1708 | onClick={() => | ||
| 1709 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) | ||
| 1710 | } | ||
| 1711 | > | 1642 | > |
| 1712 | <ArrowDownUpIcon aria-hidden className="size-3" /> | 1643 | {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve} |
| 1713 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} | 1644 | <ArrowUpRightIcon className="size-3.5 text-destructive" /> |
| 1714 | </Button> | 1645 | </Button> |
| 1715 | </div> | 1646 | </div> |
| 1716 | ) : null} | 1647 | ) : null} |
| 1717 | </nav> | 1648 | |
| 1718 | ) : null} | 1649 | {detail ? ( |
| 1650 | <nav | ||
| 1651 | className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | ||
| 1652 | aria-label="Pull request tabs" | ||
| 1653 | > | ||
| 1654 | {visibleTabs.map((item) => ( | ||
| 1655 | <button | ||
| 1656 | key={item.value} | ||
| 1657 | type="button" | ||
| 1658 | aria-pressed={tab === item.value} | ||
| 1659 | onClick={() => setTab(item.value)} | ||
| 1660 | className={cn( | ||
| 1661 | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors", | ||
| 1662 | tab === item.value | ||
| 1663 | ? "bg-accent text-foreground" | ||
| 1664 | : "text-muted-foreground hover:text-foreground", | ||
| 1665 | )} | ||
| 1666 | > | ||
| 1667 | {item.label} | ||
| 1668 | </button> | ||
| 1669 | ))} | ||
| 1670 | {tab === "summary" ? ( | ||
| 1671 | <span | ||
| 1672 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" | ||
| 1673 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} | ||
| 1674 | > | ||
| 1675 | {/* The rollup icon opens the checks behind the summary; with none reported | ||
| 1676 | there is nothing to open, so the plain glyph stays. */} | ||
| 1677 | {detail && checksState !== null ? ( | ||
| 1678 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> | ||
| 1679 | ) : ( | ||
| 1680 | <CircleDotIcon aria-hidden className="size-3.5" /> | ||
| 1681 | )} | ||
| 1682 | {checksSummary} | ||
| 1683 | </span> | ||
| 1684 | ) : tab === "timeline" ? ( | ||
| 1685 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> | ||
| 1686 | <PullRequestMetaLine | ||
| 1687 | className={cn( | ||
| 1688 | "whitespace-nowrap text-[11px] transition-opacity", | ||
| 1689 | (activityPending || activityError) && "opacity-35", | ||
| 1690 | )} | ||
| 1691 | > | ||
| 1692 | <span | ||
| 1693 | className="inline-flex items-center gap-1" | ||
| 1694 | aria-label={ | ||
| 1695 | activityError | ||
| 1696 | ? "Comments unavailable" | ||
| 1697 | : `${detail.commentCount.toLocaleString()} ${ | ||
| 1698 | detail.commentCount === 1 ? "comment" : "comments" | ||
| 1699 | }` | ||
| 1700 | } | ||
| 1701 | > | ||
| 1702 | <MessageSquareIcon aria-hidden className="size-3" /> | ||
| 1703 | {activityError | ||
| 1704 | ? "—" | ||
| 1705 | : activityPending | ||
| 1706 | ? "…" | ||
| 1707 | : detail.commentCount.toLocaleString()} | ||
| 1708 | </span> | ||
| 1709 | <span | ||
| 1710 | className="inline-flex items-center gap-1" | ||
| 1711 | aria-label={ | ||
| 1712 | activityError | ||
| 1713 | ? "Commits unavailable" | ||
| 1714 | : `${detail.commits.length.toLocaleString()} ${ | ||
| 1715 | detail.commits.length === 1 ? "commit" : "commits" | ||
| 1716 | }` | ||
| 1717 | } | ||
| 1718 | > | ||
| 1719 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> | ||
| 1720 | {activityError | ||
| 1721 | ? "—" | ||
| 1722 | : activityPending | ||
| 1723 | ? "…" | ||
| 1724 | : detail.commits.length.toLocaleString()} | ||
| 1725 | </span> | ||
| 1726 | </PullRequestMetaLine> | ||
| 1727 | <Button | ||
| 1728 | size="xs" | ||
| 1729 | variant="ghost" | ||
| 1730 | className="h-7 px-2 text-[10px] text-muted-foreground" | ||
| 1731 | aria-label={ | ||
| 1732 | timelineOrder === "newest" | ||
| 1733 | ? "Show oldest activity first" | ||
| 1734 | : "Show newest activity first" | ||
| 1735 | } | ||
| 1736 | onClick={() => | ||
| 1737 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) | ||
| 1738 | } | ||
| 1739 | > | ||
| 1740 | <ArrowDownUpIcon aria-hidden className="size-3" /> | ||
| 1741 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} | ||
| 1742 | </Button> | ||
| 1743 | </div> | ||
| 1744 | ) : null} | ||
| 1745 | </nav> | ||
| 1746 | ) : null} | ||
| 1747 | </div> | ||
| 1748 | </div> | ||
| 1719 | </div> | 1749 | </div> |
| 1720 | 1750 | ||
| 1721 | <div | 1751 | <div |
| 1722 | className="relative min-h-0 flex-1 overflow-hidden" | 1752 | className="relative min-h-0 flex-1 overflow-hidden" |
| 1753 | // Scroll does not bubble, but it captures: one listener hears every tab's own scroll | ||
| 1754 | // container. Collapse past two line-heights, expand only back at the very top, so the | ||
| 1755 | // boundary row cannot flap the chrome open and shut. | ||
| 1723 | onScrollCapture={(event) => { | 1756 | onScrollCapture={(event) => { |
| 1757 | if (chromeVariant !== "collapse") return; | ||
| 1724 | const scroller = event.target as HTMLElement; | 1758 | const scroller = event.target as HTMLElement; |
| 1725 | scrollerRef.current = scroller; | 1759 | scrollerRef.current = scroller; |
| 1726 | const top = scroller.scrollTop; | 1760 | const top = scroller.scrollTop; |
| 1727 | setChromeCondensed((previous) => { | 1761 | setChromeCondensed((previous) => { |
| 1728 | let next = previous; | 1762 | let next = previous; |
| 1763 | // `scrollHeight` reads the fold's natural height whichever state the track is in. | ||
| 1729 | const foldHeight = foldRef.current?.scrollHeight ?? 0; | 1764 | const foldHeight = foldRef.current?.scrollHeight ?? 0; |
| 1765 | // The chrome trades the fold for the condensed second row, so the height the | ||
| 1766 | // scrollport actually gains is the difference between the two. | ||
| 1730 | const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); | 1767 | const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); |
| 1731 | if (previous) { | 1768 | if (previous) { |
| 1732 | // The hard top reopens the chrome with no refund: the reader asked for the top, | 1769 | // The hard top reopens the chrome with no refund: the reader asked for the top, |
| ... | @@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({ | ... | @@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({ |
| 1744 | }); | 1781 | }); |
| 1745 | }} | 1782 | }} |
| 1746 | > | 1783 | > |
| 1747 | {detailQuery.error && !detail ? ( | 1784 | {detailQuery.isPending && !detail ? ( |
| 1785 | // The ghost wears the shape of the tab being waited on, so switching tabs mid-load | ||
| 1786 | // does not flash a summary outline under a timeline heading. | ||
| 1787 | tab === "timeline" ? ( | ||
| 1788 | <PullRequestTimelineGhost /> | ||
| 1789 | ) : tab === "code" ? ( | ||
| 1790 | <DiffPanelLoadingState label="Loading pull request diff..." /> | ||
| 1791 | ) : ( | ||
| 1792 | <PullRequestDetailGhost /> | ||
| 1793 | ) | ||
| 1794 | ) : detailQuery.error && !detail ? ( | ||
| 1748 | <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} /> | 1795 | <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} /> |
| 1749 | ) : detail ? ( | 1796 | ) : detail ? ( |
| 1750 | <> | 1797 | <> |
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+24-91| ... | @@ -45,11 +45,13 @@ export function PullRequestListGhost({ | ... | @@ -45,11 +45,13 @@ export function PullRequestListGhost({ |
| 45 | <GhostBar className="size-4 rounded-full" /> | 45 | <GhostBar className="size-4 rounded-full" /> |
| 46 | <div className="min-w-0 space-y-1.5"> | 46 | <div className="min-w-0 space-y-1.5"> |
| 47 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> | 47 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> |
| 48 | <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} /> | 48 | <GhostBar |
| 49 | className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])} | ||
| 50 | /> | ||
| 49 | </div> | 51 | </div> |
| 50 | <div className="flex flex-col items-end gap-1.5"> | 52 | <div className="flex flex-col items-end gap-1.5"> |
| 51 | <GhostBar className="w-12" /> | 53 | <GhostBar className="w-12" /> |
| 52 | <GhostBar className="w-16" /> | 54 | <GhostBar className="w-16 bg-muted-foreground/10" /> |
| 53 | </div> | 55 | </div> |
| 54 | </div> | 56 | </div> |
| 55 | ))} | 57 | ))} |
| ... | @@ -57,101 +59,32 @@ export function PullRequestListGhost({ | ... | @@ -57,101 +59,32 @@ export function PullRequestListGhost({ |
| 57 | ); | 59 | ); |
| 58 | } | 60 | } |
| 59 | 61 | ||
| 60 | /** | 62 | /** The summary's own shape: a title, a byline, the facts rows, the description. */ |
| 61 | * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description | ||
| 62 | * boundaries in the ghost prevents the loaded pull request from replacing one layout with | ||
| 63 | * another a moment later. | ||
| 64 | */ | ||
| 65 | export function PullRequestDetailGhost() { | 63 | export function PullRequestDetailGhost() { |
| 66 | return ( | 64 | return ( |
| 67 | <div | 65 | <div |
| 68 | role="status" | 66 | role="status" |
| 69 | aria-label="Loading pull request" | 67 | aria-label="Loading pull request" |
| 70 | className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background" | 68 | className="animate-ghost-pulse space-y-6 px-4 py-5" |
| 71 | > | 69 | > |
| 72 | <div className="shrink-0 border-b border-border/60"> | 70 | <div className="space-y-2"> |
| 73 | <div className="flex h-7 items-center justify-between gap-3 px-4"> | 71 | <GhostBar className="h-5 w-4/5" /> |
| 74 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 72 | <GhostBar className="w-2/5 bg-muted-foreground/10" /> |
| 75 | <GhostBar className="w-24" /> | ||
| 76 | <GhostBar className="w-9" /> | ||
| 77 | </div> | ||
| 78 | <div className="flex shrink-0 items-center gap-1"> | ||
| 79 | <GhostBar className="h-5 w-16 rounded-md" /> | ||
| 80 | <GhostBar className="size-5 rounded-md" /> | ||
| 81 | </div> | ||
| 82 | </div> | ||
| 83 | |||
| 84 | <div className="px-4 pb-4 pt-1"> | ||
| 85 | <GhostBar className="h-5 w-4/5 max-w-md" /> | ||
| 86 | <div className="mt-2 flex items-center gap-1.5"> | ||
| 87 | <GhostBar className="size-4 rounded-full" /> | ||
| 88 | <GhostBar className="w-24" /> | ||
| 89 | </div> | ||
| 90 | <div className="mt-4 flex min-w-0 items-center gap-2"> | ||
| 91 | <GhostBar className="h-6 w-24 rounded-md" /> | ||
| 92 | <GhostBar className="size-3 rounded-full" /> | ||
| 93 | <GhostBar className="h-6 w-32 rounded-md" /> | ||
| 94 | <div className="ml-auto flex shrink-0 items-center gap-2"> | ||
| 95 | <GhostBar className="w-10" /> | ||
| 96 | <GhostBar className="w-20" /> | ||
| 97 | </div> | ||
| 98 | </div> | ||
| 99 | </div> | ||
| 100 | |||
| 101 | <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2"> | ||
| 102 | <div className="flex items-center gap-1 p-0.5"> | ||
| 103 | <GhostBar className="h-6 w-16 rounded-md" /> | ||
| 104 | <GhostBar className="h-6 w-16 rounded-md" /> | ||
| 105 | <GhostBar className="h-6 w-12 rounded-md" /> | ||
| 106 | </div> | ||
| 107 | <GhostBar className="w-20" /> | ||
| 108 | </div> | ||
| 109 | </div> | 73 | </div> |
| 110 | 74 | <div className="space-y-3"> | |
| 111 | <div className="min-h-0 flex-1 overflow-hidden"> | 75 | {Array.from({ length: 4 }, (_, index) => ( |
| 112 | <section className="px-4 py-3"> | 76 | <div key={index} className="flex items-center gap-3"> |
| 113 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | ||
| 114 | <div className="flex items-center gap-1.5"> | ||
| 115 | <GhostBar className="size-3.5 rounded-full" /> | ||
| 116 | <GhostBar className="w-14" /> | ||
| 117 | </div> | ||
| 118 | <div className="flex items-center gap-1"> | ||
| 119 | <GhostBar className="size-4 rounded-full" /> | ||
| 120 | <GhostBar className="size-4 rounded-full" /> | ||
| 121 | <GhostBar className="ml-1 size-5 rounded-md" /> | ||
| 122 | </div> | ||
| 123 | </div> | ||
| 124 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | ||
| 125 | <div className="flex items-center gap-1.5"> | ||
| 126 | <GhostBar className="size-3.5 rounded-full" /> | ||
| 127 | <GhostBar className="w-10" /> | ||
| 128 | </div> | ||
| 129 | <div className="flex items-center gap-1"> | ||
| 130 | <GhostBar className="h-5 w-24 rounded-full" /> | ||
| 131 | <GhostBar className="h-5 w-20 rounded-full" /> | ||
| 132 | </div> | ||
| 133 | </div> | ||
| 134 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> | ||
| 135 | <div className="flex items-center gap-1.5"> | ||
| 136 | <GhostBar className="size-3.5 rounded-full" /> | ||
| 137 | <GhostBar className="w-14" /> | ||
| 138 | </div> | ||
| 139 | <GhostBar className="w-20" /> | ||
| 140 | </div> | ||
| 141 | </section> | ||
| 142 | |||
| 143 | <section className="border-t border-border/60"> | ||
| 144 | <div className="flex min-h-11 items-center gap-1.5 px-4 py-3"> | ||
| 145 | <GhostBar className="h-4 w-24" /> | ||
| 146 | <GhostBar className="size-3.5 rounded-full" /> | 77 | <GhostBar className="size-3.5 rounded-full" /> |
| 78 | <GhostBar className="w-20 bg-muted-foreground/10" /> | ||
| 79 | <GhostBar className={TITLE_WIDTHS[(index + 1) % TITLE_WIDTHS.length]} /> | ||
| 147 | </div> | 80 | </div> |
| 148 | <div className="space-y-2 px-4 pb-4"> | 81 | ))} |
| 149 | <GhostBar className="w-full" /> | 82 | </div> |
| 150 | <GhostBar className="w-11/12" /> | 83 | <div className="space-y-2 pt-1"> |
| 151 | <GhostBar className="w-4/5" /> | 84 | <GhostBar className="w-full bg-muted-foreground/10" /> |
| 152 | <GhostBar className="w-2/3" /> | 85 | <GhostBar className="w-11/12 bg-muted-foreground/10" /> |
| 153 | </div> | 86 | <GhostBar className="w-4/5 bg-muted-foreground/10" /> |
| 154 | </section> | 87 | <GhostBar className="w-2/3 bg-muted-foreground/10" /> |
| 155 | </div> | 88 | </div> |
| 156 | </div> | 89 | </div> |
| 157 | ); | 90 | ); |
| ... | @@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { | ... | @@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { |
| 180 | <div key={index} className="relative pb-5"> | 113 | <div key={index} className="relative pb-5"> |
| 181 | <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" /> | 114 | <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" /> |
| 182 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> | 115 | <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} /> |
| 183 | <GhostBar className="mt-1.5 w-16" /> | 116 | <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" /> |
| 184 | </div> | 117 | </div> |
| 185 | ))} | 118 | ))} |
| 186 | </div> | 119 | </div> |
| ... | @@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) { | ... | @@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) { |
| 201 | <GhostBar className="size-5 shrink-0 rounded-full" /> | 134 | <GhostBar className="size-5 shrink-0 rounded-full" /> |
| 202 | <div className="flex-1 space-y-1.5"> | 135 | <div className="flex-1 space-y-1.5"> |
| 203 | <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} /> | 136 | <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} /> |
| 204 | <GhostBar className="w-full" /> | 137 | <GhostBar className="w-full bg-muted-foreground/10" /> |
| 205 | <GhostBar className="w-3/4" /> | 138 | <GhostBar className="w-3/4 bg-muted-foreground/10" /> |
| 206 | </div> | 139 | </div> |
| 207 | </div> | 140 | </div> |
| 208 | ))} | 141 | ))} |
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+6-9| ... | @@ -25,7 +25,6 @@ import { cn } from "~/lib/utils"; | ... | @@ -25,7 +25,6 @@ import { cn } from "~/lib/utils"; |
| 25 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; | 25 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 26 | import { ProjectFavicon } from "../ProjectFavicon"; | 26 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; | 27 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; |
| 28 | import { Button } from "../ui/button"; | ||
| 29 | 28 | ||
| 30 | import { | 29 | import { |
| 31 | Menu, | 30 | Menu, |
| ... | @@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({ | ... | @@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({ |
| 262 | return ( | 261 | return ( |
| 263 | <Menu> | 262 | <Menu> |
| 264 | <MenuTrigger | 263 | <MenuTrigger |
| 265 | render={ | 264 | className={cn( |
| 266 | <Button | 265 | // The icon-button size that pairs with a full-height input, so the two read as one strip. |
| 267 | className={cn("relative", filtered && "[--control-icon-color:currentColor]")} | 266 | "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8", |
| 268 | size="icon" | 267 | filtered && "text-foreground", |
| 269 | variant="outline" | 268 | )} |
| 270 | aria-label="Filter pull requests" | 269 | aria-label="Filter pull requests" |
| 271 | /> | ||
| 272 | } | ||
| 273 | > | 270 | > |
| 274 | <ListFilterIcon className="size-4" /> | 271 | <ListFilterIcon className="size-4" /> |
| 275 | {filtered ? ( | 272 | {filtered ? ( |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3| ... | @@ -196,12 +196,12 @@ function MetaRow({ | ... | @@ -196,12 +196,12 @@ function MetaRow({ |
| 196 | children: ReactNode; | 196 | children: ReactNode; |
| 197 | }) { | 197 | }) { |
| 198 | return ( | 198 | return ( |
| 199 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs"> | 199 | <div className="flex items-center gap-2 py-1.5 text-xs"> |
| 200 | <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground"> | 200 | <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground"> |
| 201 | {icon} | 201 | {icon} |
| 202 | {label} | 202 | {label} |
| 203 | </span> | 203 | </span> |
| 204 | <span className="min-w-0 text-foreground">{children}</span> | 204 | <span className="min-w-0 flex-1 text-foreground">{children}</span> |
| 205 | </div> | 205 | </div> |
| 206 | ); | 206 | ); |
| 207 | } | 207 | } |
apps/web/src/components/settings/DiagnosticsSettings.tsx+1-1| ... | @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { |
| 992 | : false; | 992 | : false; |
| 993 | 993 | ||
| 994 | return ( | 994 | return ( |
| 995 | <SettingsPageContainer width="expanded" className="gap-10"> | 995 | <SettingsPageContainer className="max-w-6xl gap-10"> |
| 996 | <ResourceTelemetryDiagnostics /> | 996 | <ResourceTelemetryDiagnostics /> |
| 997 | 997 | ||
| 998 | <SettingsSection | 998 | <SettingsSection |
apps/web/src/components/settings/KeybindingsSettings.tsx+1-1| ... | @@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1195,7 +1195,7 @@ export function KeybindingsSettingsPanel() { |
| 1195 | ); | 1195 | ); |
| 1196 | 1196 | ||
| 1197 | return ( | 1197 | return ( |
| 1198 | <SettingsPageContainer width="wide"> | 1198 | <SettingsPageContainer className="max-w-5xl"> |
| 1199 | <SettingsSection | 1199 | <SettingsSection |
| 1200 | {...searchableSetting("keybindings")} | 1200 | {...searchableSetting("keybindings")} |
| 1201 | headerAction={ | 1201 | headerAction={ |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+22-4| ... | @@ -81,6 +81,8 @@ import { | ... | @@ -81,6 +81,8 @@ import { |
| 81 | type NewProjectScriptInput, | 81 | type NewProjectScriptInput, |
| 82 | type ProjectScriptEditorRequest, | 82 | type ProjectScriptEditorRequest, |
| 83 | } from "../projectScriptEditor"; | 83 | } from "../projectScriptEditor"; |
| 84 | import { cn } from "../../lib/utils"; | ||
| 85 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | ||
| 84 | import { Button } from "../ui/button"; | 86 | import { Button } from "../ui/button"; |
| 85 | import { Input } from "../ui/input"; | 87 | import { Input } from "../ui/input"; |
| 86 | import { | 88 | import { |
| ... | @@ -100,7 +102,6 @@ import { | ... | @@ -100,7 +102,6 @@ import { |
| 100 | WorkspaceBreadcrumbItem, | 102 | WorkspaceBreadcrumbItem, |
| 101 | WorkspaceBreadcrumbSeparator, | 103 | WorkspaceBreadcrumbSeparator, |
| 102 | } from "../WorkspaceBreadcrumb"; | 104 | } from "../WorkspaceBreadcrumb"; |
| 103 | import { WorkspacePageHeader } from "../WorkspacePageContainer"; | ||
| 104 | import { | 105 | import { |
| 105 | SettingResetButton, | 106 | SettingResetButton, |
| 106 | SettingsPageContainer, | 107 | SettingsPageContainer, |
| ... | @@ -173,9 +174,26 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { | ... | @@ -173,9 +174,26 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { |
| 173 | return ( | 174 | return ( |
| 174 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 175 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 175 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 176 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 176 | <WorkspacePageHeader electron={isElectron}> | 177 | {!isElectron && ( |
| 177 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | 178 | <header |
| 178 | </WorkspacePageHeader> | 179 | className={cn( |
| 180 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | ||
| 181 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 182 | )} | ||
| 183 | > | ||
| 184 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | ||
| 185 | </header> | ||
| 186 | )} | ||
| 187 | {isElectron && ( | ||
| 188 | <div | ||
| 189 | className={cn( | ||
| 190 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | ||
| 191 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 192 | )} | ||
| 193 | > | ||
| 194 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | ||
| 195 | </div> | ||
| 196 | )} | ||
| 179 | <ProjectSettingsPanel projectKey={projectKey} /> | 197 | <ProjectSettingsPanel projectKey={projectKey} /> |
| 180 | </div> | 198 | </div> |
| 181 | </SidebarInset> | 199 | </SidebarInset> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+22-3| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | import { | 10 | import { |
| 11 | ArchiveIcon, | 11 | ArchiveIcon, |
| 12 | ArrowLeftIcon, | ||
| 12 | BotIcon, | 13 | BotIcon, |
| 13 | GitBranchIcon, | 14 | GitBranchIcon, |
| 14 | KeyboardIcon, | 15 | KeyboardIcon, |
| ... | @@ -18,7 +19,7 @@ import { | ... | @@ -18,7 +19,7 @@ import { |
| 18 | Settings2Icon, | 19 | Settings2Icon, |
| 19 | XIcon, | 20 | XIcon, |
| 20 | } from "lucide-react"; | 21 | } from "lucide-react"; |
| 21 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 22 | import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; |
| 22 | 23 | ||
| 23 | import { Button } from "../ui/button"; | 24 | import { Button } from "../ui/button"; |
| 24 | import { Input } from "../ui/input"; | 25 | import { Input } from "../ui/input"; |
| ... | @@ -33,7 +34,6 @@ import { | ... | @@ -33,7 +34,6 @@ import { |
| 33 | useSidebar, | 34 | useSidebar, |
| 34 | } from "../ui/sidebar"; | 35 | } from "../ui/sidebar"; |
| 35 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; | 36 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; |
| 36 | import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; | ||
| 37 | import { scrollToSettingsTarget } from "./settingsLayout"; | 37 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 38 | import { | 38 | import { |
| 39 | searchSettings, | 39 | searchSettings, |
| ... | @@ -72,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { | ... | @@ -72,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 72 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { | 72 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 73 | const navigate = useNavigate(); | 73 | const navigate = useNavigate(); |
| 74 | const currentHash = useLocation({ select: (location) => location.hash }); | 74 | const currentHash = useLocation({ select: (location) => location.hash }); |
| 75 | const canGoBack = useCanGoBack(); | ||
| 75 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); | 76 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); |
| 76 | const searchInputRef = useRef<HTMLInputElement>(null); | 77 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 77 | const [query, setQuery] = useState(""); | 78 | const [query, setQuery] = useState(""); |
| ... | @@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 175 | }, | 176 | }, |
| 176 | [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], | 177 | [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], |
| 177 | ); | 178 | ); |
| 179 | const handleBackClick = useCallback(() => { | ||
| 180 | if (isMobile) { | ||
| 181 | setOpenMobile(false); | ||
| 182 | } | ||
| 183 | if (canGoBack) { | ||
| 184 | window.history.back(); | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | void navigate({ to: "/" }); | ||
| 188 | }, [canGoBack, isMobile, navigate, setOpenMobile]); | ||
| 189 | |||
| 178 | return ( | 190 | return ( |
| 179 | <> | 191 | <> |
| 180 | <SidebarContent className="overflow-x-hidden"> | 192 | <SidebarContent className="overflow-x-hidden"> |
| ... | @@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 284 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> | 296 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> |
| 285 | <T3ConnectSidebarSignIn /> | 297 | <T3ConnectSidebarSignIn /> |
| 286 | <div className="flex items-center gap-1"> | 298 | <div className="flex items-center gap-1"> |
| 287 | <SidebarUtilityMenu /> | 299 | <SidebarMenu className="min-w-0 flex-1"> |
| 300 | <SidebarMenuItem> | ||
| 301 | <SidebarMenuButton onClick={handleBackClick}> | ||
| 302 | <ArrowLeftIcon /> | ||
| 303 | <span>Back</span> | ||
| 304 | </SidebarMenuButton> | ||
| 305 | </SidebarMenuItem> | ||
| 306 | </SidebarMenu> | ||
| 288 | <T3ConnectSidebarAvatar /> | 307 | <T3ConnectSidebarAvatar /> |
| 289 | </div> | 308 | </div> |
| 290 | </SidebarFooter> | 309 | </SidebarFooter> |
apps/web/src/components/settings/ThemeSettings.tsx+1-1| ... | @@ -768,7 +768,7 @@ export function ThemeLibrary({ | ... | @@ -768,7 +768,7 @@ export function ThemeLibrary({ |
| 768 | <TooltipProvider> | 768 | <TooltipProvider> |
| 769 | <div | 769 | <div |
| 770 | className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4" | 770 | className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4" |
| 771 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }} | 771 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 1fr))" }} |
| 772 | > | 772 | > |
| 773 | {STANDARD_THEME_CARDS.map((standardTheme) => ( | 773 | {STANDARD_THEME_CARDS.map((standardTheme) => ( |
| 774 | <ThemeLibraryCard | 774 | <ThemeLibraryCard |
apps/web/src/components/settings/settingsLayout.tsx+3-6| ... | @@ -12,7 +12,6 @@ import { | ... | @@ -12,7 +12,6 @@ import { |
| 12 | } from "react"; | 12 | } from "react"; |
| 13 | 13 | ||
| 14 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; | ||
| 16 | import { Button } from "../ui/button"; | 15 | import { Button } from "../ui/button"; |
| 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 18 | 17 | ||
| ... | @@ -234,11 +233,9 @@ export function SettingResetButton({ | ... | @@ -234,11 +233,9 @@ export function SettingResetButton({ |
| 234 | export function SettingsPageContainer({ | 233 | export function SettingsPageContainer({ |
| 235 | children, | 234 | children, |
| 236 | className, | 235 | className, |
| 237 | width = "readable", | ||
| 238 | }: { | 236 | }: { |
| 239 | children: ReactNode; | 237 | children: ReactNode; |
| 240 | className?: string; | 238 | className?: string; |
| 241 | width?: WorkspacePageWidth; | ||
| 242 | }) { | 239 | }) { |
| 243 | const navigate = useNavigate(); | 240 | const navigate = useNavigate(); |
| 244 | const hash = useLocation({ select: (location) => location.hash }); | 241 | const hash = useLocation({ select: (location) => location.hash }); |
| ... | @@ -250,12 +247,12 @@ export function SettingsPageContainer({ | ... | @@ -250,12 +247,12 @@ export function SettingsPageContainer({ |
| 250 | return ( | 247 | return ( |
| 251 | <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}> | 248 | <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}> |
| 252 | <div | 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 | data-settings-page-scroll | 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 | {children} | 254 | {children} |
| 258 | </WorkspacePageContainer> | 255 | </div> |
| 259 | </div> | 256 | </div> |
| 260 | </SettingsSearchTargetProvider> | 257 | </SettingsSearchTargetProvider> |
| 261 | ); | 258 | ); |
apps/web/src/components/sidebar/SidebarChrome.tsx+68-78| ... | @@ -4,9 +4,8 @@ import { | ... | @@ -4,9 +4,8 @@ import { |
| 4 | GitPullRequestIcon, | 4 | GitPullRequestIcon, |
| 5 | SettingsIcon, | 5 | SettingsIcon, |
| 6 | } from "lucide-react"; | 6 | } from "lucide-react"; |
| 7 | import type { ReactNode } from "react"; | ||
| 8 | import { memo, useCallback } from "react"; | 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 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; | 10 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 12 | import { cn } from "../../lib/utils"; | 11 | import { cn } from "../../lib/utils"; |
| ... | @@ -118,44 +117,16 @@ function T3Wordmark() { | ... | @@ -118,44 +117,16 @@ function T3Wordmark() { |
| 118 | ); | 117 | ); |
| 119 | } | 118 | } |
| 120 | 119 | ||
| 121 | function SidebarUtilityItem({ | 120 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 122 | icon, | ||
| 123 | label, | ||
| 124 | onClick, | ||
| 125 | }: { | ||
| 126 | icon: ReactNode; | ||
| 127 | label: string; | ||
| 128 | onClick: () => void; | ||
| 129 | }) { | ||
| 130 | return ( | ||
| 131 | <SidebarMenuItem className="shrink-0"> | ||
| 132 | <Tooltip> | ||
| 133 | <TooltipTrigger | ||
| 134 | render={ | ||
| 135 | <SidebarMenuButton aria-label={label} onClick={onClick} size="icon"> | ||
| 136 | {icon} | ||
| 137 | </SidebarMenuButton> | ||
| 138 | } | ||
| 139 | /> | ||
| 140 | <TooltipPopup side="top">{label}</TooltipPopup> | ||
| 141 | </Tooltip> | ||
| 142 | </SidebarMenuItem> | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | |||
| 146 | export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { | ||
| 147 | const navigate = useNavigate(); | 121 | const navigate = useNavigate(); |
| 148 | const canGoBack = useCanGoBack(); | ||
| 149 | const { isMobile, setOpenMobile } = useSidebar(); | 122 | const { isMobile, setOpenMobile } = useSidebar(); |
| 150 | const currentFooterPage = useLocation({ | 123 | const currentFooterPage = useLocation({ |
| 151 | select: (location) => | 124 | select: (location) => |
| 152 | /^\/settings(?:\/|$)/.test(location.pathname) | 125 | location.pathname === "/usage" |
| 153 | ? "settings" | 126 | ? "usage" |
| 154 | : location.pathname === "/usage" | 127 | : location.pathname === "/pull-requests" |
| 155 | ? "usage" | 128 | ? "pull-requests" |
| 156 | : location.pathname === "/pull-requests" | 129 | : null, |
| 157 | ? "pull-requests" | ||
| 158 | : null, | ||
| 159 | }); | 130 | }); |
| 160 | const { environments } = useEnvironments(); | 131 | const { environments } = useEnvironments(); |
| 161 | // The page reads every connected server, so one of them offering pull requests is enough for | 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,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 186 | 157 | ||
| 187 | const handleBackClick = useCallback(() => { | 158 | const handleBackClick = useCallback(() => { |
| 188 | closeMobileSidebar(); | 159 | closeMobileSidebar(); |
| 189 | if (canGoBack) { | ||
| 190 | window.history.back(); | ||
| 191 | return; | ||
| 192 | } | ||
| 193 | void navigate({ to: "/" }); | 160 | void navigate({ to: "/" }); |
| 194 | }, [canGoBack, closeMobileSidebar, navigate]); | 161 | }, [closeMobileSidebar, navigate]); |
| 195 | |||
| 196 | return ( | ||
| 197 | <SidebarMenu className="flex-row items-center"> | ||
| 198 | {currentFooterPage ? ( | ||
| 199 | <SidebarMenuItem className="min-w-0 flex-1"> | ||
| 200 | <SidebarMenuButton onClick={handleBackClick}> | ||
| 201 | <ArrowLeftIcon /> | ||
| 202 | <span>Back</span> | ||
| 203 | </SidebarMenuButton> | ||
| 204 | </SidebarMenuItem> | ||
| 205 | ) : ( | ||
| 206 | <> | ||
| 207 | <SidebarUtilityItem | ||
| 208 | icon={<SettingsIcon />} | ||
| 209 | label="Settings" | ||
| 210 | onClick={handleSettingsClick} | ||
| 211 | /> | ||
| 212 | {pullRequestsSupported ? ( | ||
| 213 | <SidebarUtilityItem | ||
| 214 | icon={<GitPullRequestIcon />} | ||
| 215 | label="Pull Requests" | ||
| 216 | onClick={handlePullRequestsClick} | ||
| 217 | /> | ||
| 218 | ) : null} | ||
| 219 | <SidebarUtilityItem | ||
| 220 | icon={<ChartNoAxesColumnIcon />} | ||
| 221 | label="Usage" | ||
| 222 | onClick={handleUsageClick} | ||
| 223 | /> | ||
| 224 | </> | ||
| 225 | )} | ||
| 226 | <SidebarUpdatePill /> | ||
| 227 | </SidebarMenu> | ||
| 228 | ); | ||
| 229 | }); | ||
| 230 | 162 | ||
| 231 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { | ||
| 232 | return ( | 163 | return ( |
| 233 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> | 164 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> |
| 234 | <SidebarProviderUpdatePill /> | 165 | <SidebarProviderUpdatePill /> |
| 235 | <SidebarUpdateArchitectureWarning /> | 166 | <SidebarUpdateArchitectureWarning /> |
| 236 | <SidebarUtilityMenu /> | 167 | <SidebarMenu className="flex-row items-center"> |
| 168 | {currentFooterPage ? ( | ||
| 169 | <SidebarMenuItem className="min-w-0 flex-1"> | ||
| 170 | <SidebarMenuButton onClick={handleBackClick}> | ||
| 171 | <ArrowLeftIcon /> | ||
| 172 | <span>Back</span> | ||
| 173 | </SidebarMenuButton> | ||
| 174 | </SidebarMenuItem> | ||
| 175 | ) : ( | ||
| 176 | <> | ||
| 177 | <SidebarMenuItem className="shrink-0"> | ||
| 178 | <Tooltip> | ||
| 179 | <TooltipTrigger | ||
| 180 | render={ | ||
| 181 | <SidebarMenuButton | ||
| 182 | aria-label="Settings" | ||
| 183 | onClick={handleSettingsClick} | ||
| 184 | size="icon" | ||
| 185 | > | ||
| 186 | <SettingsIcon /> | ||
| 187 | </SidebarMenuButton> | ||
| 188 | } | ||
| 189 | /> | ||
| 190 | <TooltipPopup side="top">Settings</TooltipPopup> | ||
| 191 | </Tooltip> | ||
| 192 | </SidebarMenuItem> | ||
| 193 | {pullRequestsSupported ? ( | ||
| 194 | <SidebarMenuItem className="shrink-0"> | ||
| 195 | <Tooltip> | ||
| 196 | <TooltipTrigger | ||
| 197 | render={ | ||
| 198 | <SidebarMenuButton | ||
| 199 | aria-label="Pull Requests" | ||
| 200 | onClick={handlePullRequestsClick} | ||
| 201 | size="icon" | ||
| 202 | > | ||
| 203 | <GitPullRequestIcon /> | ||
| 204 | </SidebarMenuButton> | ||
| 205 | } | ||
| 206 | /> | ||
| 207 | <TooltipPopup side="top">Pull Requests</TooltipPopup> | ||
| 208 | </Tooltip> | ||
| 209 | </SidebarMenuItem> | ||
| 210 | ) : null} | ||
| 211 | <SidebarMenuItem className="shrink-0"> | ||
| 212 | <Tooltip> | ||
| 213 | <TooltipTrigger | ||
| 214 | render={ | ||
| 215 | <SidebarMenuButton aria-label="Usage" onClick={handleUsageClick} size="icon"> | ||
| 216 | <ChartNoAxesColumnIcon /> | ||
| 217 | </SidebarMenuButton> | ||
| 218 | } | ||
| 219 | /> | ||
| 220 | <TooltipPopup side="top">Usage</TooltipPopup> | ||
| 221 | </Tooltip> | ||
| 222 | </SidebarMenuItem> | ||
| 223 | </> | ||
| 224 | )} | ||
| 225 | <SidebarUpdatePill /> | ||
| 226 | </SidebarMenu> | ||
| 237 | </SidebarFooter> | 227 | </SidebarFooter> |
| 238 | ); | 228 | ); |
| 239 | }); | 229 | }); |
apps/web/src/components/threadActionMenu.logic.test.ts+4-10| ... | @@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] { | ... | @@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] { |
| 19 | return buildThreadActionMenuItems(state).map((item) => item.id); | 19 | return buildThreadActionMenuItems(state).map((item) => item.id); |
| 20 | } | 20 | } |
| 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 | describe("buildThreadActionMenuItems", () => { | 22 | describe("buildThreadActionMenuItems", () => { |
| 29 | it("hides lifecycle items when the environment lacks the capabilities", () => { | 23 | it("hides lifecycle items when the environment lacks the capabilities", () => { |
| 30 | expect( | 24 | expect( |
| ... | @@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => { | ... | @@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => { |
| 32 | ...baseState, | 26 | ...baseState, |
| 33 | supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, | 27 | supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, |
| 34 | }), | 28 | }), |
| 35 | ).toEqual(["rename", "mark-unread", "copy", "delete"]); | 29 | ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); |
| 36 | }); | 30 | }); |
| 37 | 31 | ||
| 38 | it("includes branch items only for threads with a branch", () => { | 32 | it("includes branch items only for threads with a branch", () => { |
| 39 | const withBranch = allIds({ ...baseState, branch: "feat/menu" }); | 33 | const withBranch = ids({ ...baseState, branch: "feat/menu" }); |
| 40 | expect(withBranch).toContain("new-thread-on-branch"); | 34 | expect(withBranch).toContain("new-thread-on-branch"); |
| 41 | expect(withBranch).toContain("copy-branch"); | 35 | expect(withBranch).toContain("copy-branch"); |
| 42 | expect(allIds(baseState)).not.toContain("new-thread-on-branch"); | 36 | expect(ids(baseState)).not.toContain("new-thread-on-branch"); |
| 43 | expect(allIds(baseState)).not.toContain("copy-branch"); | 37 | expect(ids(baseState)).not.toContain("copy-branch"); |
| 44 | }); | 38 | }); |
| 45 | 39 | ||
| 46 | it("flips lifecycle labels with thread state", () => { | 40 | it("flips lifecycle labels with thread state", () => { |
apps/web/src/components/threadActionMenu.logic.ts+11-31| ... | @@ -18,7 +18,6 @@ export type ThreadActionMenuId = | ... | @@ -18,7 +18,6 @@ export type ThreadActionMenuId = |
| 18 | | "rename" | 18 | | "rename" |
| 19 | | "regenerate-title" | 19 | | "regenerate-title" |
| 20 | | "mark-unread" | 20 | | "mark-unread" |
| 21 | | "copy" | ||
| 22 | | "copy-path" | 21 | | "copy-path" |
| 23 | | "copy-branch" | 22 | | "copy-branch" |
| 24 | | "copy-thread-id" | 23 | | "copy-thread-id" |
| ... | @@ -54,15 +53,14 @@ export function buildThreadActionMenuItems( | ... | @@ -54,15 +53,14 @@ export function buildThreadActionMenuItems( |
| 54 | { | 53 | { |
| 55 | id: "new-thread-on-branch" as const, | 54 | id: "new-thread-on-branch" as const, |
| 56 | label: `New thread on ${state.branch}`, | 55 | label: `New thread on ${state.branch}`, |
| 57 | icon: "message-square-plus", | ||
| 58 | }, | 56 | }, |
| 59 | ] | 57 | ] |
| 60 | : []), | 58 | : []), |
| 61 | ...(state.supports.pinning | 59 | ...(state.supports.pinning |
| 62 | ? [ | 60 | ? [ |
| 63 | state.isPinned | 61 | state.isPinned |
| 64 | ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } | 62 | ? { id: "unpin" as const, label: "Unpin thread" } |
| 65 | : { id: "pin" as const, label: "Pin thread", icon: "pin" }, | 63 | : { id: "pin" as const, label: "Pin thread" }, |
| 66 | ] | 64 | ] |
| 67 | : []), | 65 | : []), |
| 68 | // Both lifecycle actions stay available on pinned threads: settling | 66 | // Both lifecycle actions stay available on pinned threads: settling |
| ... | @@ -71,18 +69,17 @@ export function buildThreadActionMenuItems( | ... | @@ -71,18 +69,17 @@ export function buildThreadActionMenuItems( |
| 71 | ...(state.supports.settlement | 69 | ...(state.supports.settlement |
| 72 | ? [ | 70 | ? [ |
| 73 | state.isSettled | 71 | state.isSettled |
| 74 | ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } | 72 | ? { id: "unsettle" as const, label: "Un-settle thread" } |
| 75 | : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, | 73 | : { id: "settle" as const, label: "Settle thread" }, |
| 76 | ] | 74 | ] |
| 77 | : []), | 75 | : []), |
| 78 | ...(state.supports.snooze | 76 | ...(state.supports.snooze |
| 79 | ? [ | 77 | ? [ |
| 80 | state.isSnoozed | 78 | state.isSnoozed |
| 81 | ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } | 79 | ? { id: "unsnooze" as const, label: "Wake thread" } |
| 82 | : { | 80 | : { |
| 83 | id: "snooze" as const, | 81 | id: "snooze" as const, |
| 84 | label: "Snooze", | 82 | label: "Snooze", |
| 85 | icon: "clock", | ||
| 86 | disabled: !state.canSnoozeNow, | 83 | disabled: !state.canSnoozeNow, |
| 87 | children: state.snoozePresets.map((preset) => ({ | 84 | children: state.snoozePresets.map((preset) => ({ |
| 88 | id: `snooze:${preset.id}` as const, | 85 | id: `snooze:${preset.id}` as const, |
| ... | @@ -91,37 +88,20 @@ export function buildThreadActionMenuItems( | ... | @@ -91,37 +88,20 @@ export function buildThreadActionMenuItems( |
| 91 | }, | 88 | }, |
| 92 | ] | 89 | ] |
| 93 | : []), | 90 | : []), |
| 94 | { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, | 91 | { id: "rename", label: "Rename thread" }, |
| 95 | ...(state.supports.titleRegeneration | 92 | ...(state.supports.titleRegeneration |
| 96 | ? [ | 93 | ? [ |
| 97 | { | 94 | { |
| 98 | id: "regenerate-title" as const, | 95 | id: "regenerate-title" as const, |
| 99 | label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", | 96 | label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", |
| 100 | icon: "refresh-cw", | ||
| 101 | disabled: state.isRegeneratingTitle, | 97 | disabled: state.isRegeneratingTitle, |
| 102 | }, | 98 | }, |
| 103 | ] | 99 | ] |
| 104 | : []), | 100 | : []), |
| 105 | { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, | 101 | { id: "mark-unread", label: "Mark unread" }, |
| 106 | { | 102 | { id: "copy-path", label: "Copy path", icon: "copy" }, |
| 107 | id: "copy", | 103 | ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), |
| 108 | label: "Copy", | 104 | { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, |
| 109 | icon: "copy", | 105 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, |
| 110 | separatorBefore: true, | ||
| 111 | children: [ | ||
| 112 | { id: "copy-path", label: "Path", icon: "folder" }, | ||
| 113 | ...(state.branch | ||
| 114 | ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] | ||
| 115 | : []), | ||
| 116 | { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, | ||
| 117 | ], | ||
| 118 | }, | ||
| 119 | { | ||
| 120 | id: "delete", | ||
| 121 | label: "Delete", | ||
| 122 | destructive: true, | ||
| 123 | icon: "trash", | ||
| 124 | separatorBefore: true, | ||
| 125 | }, | ||
| 126 | ]; | 106 | ]; |
| 127 | } | 107 | } |
apps/web/src/components/ui/segmented-tabs.tsx deleted-40| ... | @@ -1,40 +0,0 @@ | ||
| 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,10 +18,6 @@ const toggleVariants = cva( |
| 18 | "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", | 18 | "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", |
| 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", | 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", |
| 20 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", | 20 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", |
| 21 | segmented: | ||
| 22 | "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", | ||
| 23 | "segmented-compact": | ||
| 24 | "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", | ||
| 25 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", | 21 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", |
| 26 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", | 22 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", |
| 27 | }, | 23 | }, |
| ... | @@ -31,8 +27,6 @@ const toggleVariants = cva( | ... | @@ -31,8 +27,6 @@ const toggleVariants = cva( |
| 31 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", | 27 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", |
| 32 | outline: | 28 | outline: |
| 33 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", | 29 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", |
| 34 | segmented: | ||
| 35 | "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", | ||
| 36 | }, | 30 | }, |
| 37 | }, | 31 | }, |
| 38 | }, | 32 | }, |
apps/web/src/components/usage/UsagePage.tsx+243-150| ... | @@ -19,22 +19,13 @@ import { | ... | @@ -19,22 +19,13 @@ import { |
| 19 | formatUsd, | 19 | formatUsd, |
| 20 | makeWindow, | 20 | makeWindow, |
| 21 | } from "@t3tools/shared/usageFormat"; | 21 | } from "@t3tools/shared/usageFormat"; |
| 22 | import { Button } from "../ui/button"; | ||
| 23 | import { ScrollArea } from "../ui/scroll-area"; | 22 | import { ScrollArea } from "../ui/scroll-area"; |
| 23 | import { Button } from "../ui/button"; | ||
| 24 | import { SidebarInset } from "../ui/sidebar"; | 24 | import { SidebarInset } from "../ui/sidebar"; |
| 25 | import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; | 25 | import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; |
| 26 | import { | 26 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; |
| 27 | WorkspaceBreadcrumb, | 27 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 28 | WorkspaceBreadcrumbItem, | 28 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 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"; | ||
| 38 | 29 | ||
| 39 | const WINDOW_OPTIONS = [ | 30 | const WINDOW_OPTIONS = [ |
| 40 | { days: 1, label: "Past 24h" }, | 31 | { days: 1, label: "Past 24h" }, |
| ... | @@ -75,6 +66,21 @@ export function UsagePage() { | ... | @@ -75,6 +66,21 @@ export function UsagePage() { |
| 75 | [isPast24Hours, merged.daily, merged.hourly], | 66 | [isPast24Hours, merged.daily, merged.hourly], |
| 76 | ); | 67 | ); |
| 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 | const selectWindow = (days: number) => { | 84 | const selectWindow = (days: number) => { |
| 79 | setWindowSelection({ | 85 | setWindowSelection({ |
| 80 | days, | 86 | days, |
| ... | @@ -94,66 +100,78 @@ export function UsagePage() { | ... | @@ -94,66 +100,78 @@ export function UsagePage() { |
| 94 | setWindowSelection({ days: windowDays, window: nextWindow }); | 100 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 95 | } | 101 | } |
| 96 | }; | 102 | }; |
| 97 | const windowLabel = | ||
| 98 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | ||
| 99 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | ||
| 100 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; | ||
| 101 | const topbarContent = ( | ||
| 102 | <div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2"> | ||
| 103 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0"> | ||
| 104 | <WorkspaceBreadcrumbItem current> | ||
| 105 | <h1>Usage</h1> | ||
| 106 | </WorkspaceBreadcrumbItem> | ||
| 107 | <WorkspaceBreadcrumbSeparator /> | ||
| 108 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | ||
| 109 | <span className="truncate">{windowLabel}</span> | ||
| 110 | </WorkspaceBreadcrumbItem> | ||
| 111 | </WorkspaceBreadcrumb> | ||
| 112 | <div className="ms-auto flex min-w-0 basis-full flex-wrap items-center justify-end gap-2 sm:basis-auto"> | ||
| 113 | <SegmentedTabList aria-label="Usage metric"> | ||
| 114 | {(["cost", "tokens"] as const).map((option) => ( | ||
| 115 | <SegmentedTab | ||
| 116 | key={option} | ||
| 117 | selected={option === metric} | ||
| 118 | onClick={() => setMetric(option)} | ||
| 119 | > | ||
| 120 | {option === "cost" ? "Cost" : "Tokens"} | ||
| 121 | </SegmentedTab> | ||
| 122 | ))} | ||
| 123 | </SegmentedTabList> | ||
| 124 | <SegmentedTabList aria-label="Usage period"> | ||
| 125 | {WINDOW_OPTIONS.map((option) => ( | ||
| 126 | <SegmentedTab | ||
| 127 | key={option.days} | ||
| 128 | selected={option.days === windowDays} | ||
| 129 | onClick={() => selectWindow(option.days)} | ||
| 130 | > | ||
| 131 | {option.label} | ||
| 132 | </SegmentedTab> | ||
| 133 | ))} | ||
| 134 | </SegmentedTabList> | ||
| 135 | <WorkspacePageHeaderEdgeControl> | ||
| 136 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | ||
| 137 | <RefreshCwIcon className="size-3.5" /> | ||
| 138 | </Button> | ||
| 139 | </WorkspacePageHeaderEdgeControl> | ||
| 140 | </div> | ||
| 141 | </div> | ||
| 142 | ); | ||
| 143 | 103 | ||
| 144 | return ( | 104 | return ( |
| 145 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 105 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 146 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 106 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 147 | <WorkspacePageHeader electron={isElectron} className="h-auto py-2"> | 107 | {!isElectron && ( |
| 148 | {topbarContent} | 108 | <header |
| 149 | </WorkspacePageHeader> | 109 | className={cn( |
| 110 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | ||
| 111 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 112 | )} | ||
| 113 | > | ||
| 114 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | ||
| 115 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | ||
| 116 | </WorkspaceBreadcrumb> | ||
| 117 | </header> | ||
| 118 | )} | ||
| 119 | |||
| 120 | {isElectron && ( | ||
| 121 | <div | ||
| 122 | className={cn( | ||
| 123 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | ||
| 124 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 125 | )} | ||
| 126 | > | ||
| 127 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | ||
| 128 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | ||
| 129 | </WorkspaceBreadcrumb> | ||
| 130 | </div> | ||
| 131 | )} | ||
| 150 | 132 | ||
| 151 | <ScrollArea className="min-h-0 flex-1"> | 133 | <ScrollArea className="min-h-0 flex-1"> |
| 152 | <WorkspacePageContainer width="wide"> | 134 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> |
| 135 | <div className="flex flex-wrap items-center justify-between gap-4"> | ||
| 136 | <p className="text-sm text-muted-foreground"> | ||
| 137 | {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | ||
| 138 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | ||
| 139 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} | ||
| 140 | </p> | ||
| 141 | <div className="flex items-center gap-2"> | ||
| 142 | <div className="flex rounded-md border border-border"> | ||
| 143 | {WINDOW_OPTIONS.map((option) => ( | ||
| 144 | <button | ||
| 145 | key={option.days} | ||
| 146 | type="button" | ||
| 147 | aria-pressed={option.days === windowDays} | ||
| 148 | onClick={() => selectWindow(option.days)} | ||
| 149 | className={cn( | ||
| 150 | "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 151 | option.days === windowDays | ||
| 152 | ? "bg-muted text-foreground" | ||
| 153 | : "text-muted-foreground hover:text-foreground", | ||
| 154 | )} | ||
| 155 | > | ||
| 156 | {option.label} | ||
| 157 | </button> | ||
| 158 | ))} | ||
| 159 | </div> | ||
| 160 | <Button | ||
| 161 | size="icon" | ||
| 162 | variant="outline" | ||
| 163 | onClick={refreshWindow} | ||
| 164 | aria-label="Refresh usage" | ||
| 165 | > | ||
| 166 | <RefreshCwIcon className="size-3.5" /> | ||
| 167 | </Button> | ||
| 168 | </div> | ||
| 169 | </div> | ||
| 170 | |||
| 153 | {settling ? ( | 171 | {settling ? ( |
| 154 | <> | 172 | <> |
| 155 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} | 173 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 156 | <UsageSkeleton /> | 174 | <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} /> |
| 157 | </> | 175 | </> |
| 158 | ) : ( | 176 | ) : ( |
| 159 | <> | 177 | <> |
| ... | @@ -163,62 +181,88 @@ export function UsagePage() { | ... | @@ -163,62 +181,88 @@ export function UsagePage() { |
| 163 | staleEnvironments={merged.staleEnvironments} | 181 | staleEnvironments={merged.staleEnvironments} |
| 164 | /> | 182 | /> |
| 165 | 183 | ||
| 166 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> | 184 | {/* Cost first: the financial answer, then the provider split. */} |
| 167 | <div className="flex min-w-0 flex-col gap-5"> | 185 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 186 | {/* The summary follows the chart toggle, so the headline and the | ||
| 187 | series are always reading the same units. */} | ||
| 188 | <div className="flex flex-col gap-5"> | ||
| 168 | <div className="flex flex-col gap-1"> | 189 | <div className="flex flex-col gap-1"> |
| 190 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | ||
| 191 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | ||
| 192 | </span> | ||
| 169 | <span className="text-4xl font-semibold text-foreground tabular-nums"> | 193 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 170 | {metric === "cost" | 194 | {metric === "cost" |
| 171 | ? formatUsd(merged.costUsd) | 195 | ? `${formatUsd(merged.costUsd)}*` |
| 172 | : formatTokens(merged.totalTokens)} | 196 | : formatTokens(merged.totalTokens)} |
| 173 | </span> | 197 | </span> |
| 174 | <span className="text-xs text-muted-foreground"> | 198 | <span className="text-xs text-muted-foreground"> |
| 175 | {metric === "cost" | 199 | {metric === "cost" |
| 176 | ? `${formatCount(merged.sessions)} sessions · API estimate` | 200 | ? "* if billed at full API rate" |
| 177 | : `${formatCount(merged.sessions)} sessions`} | 201 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} |
| 178 | </span> | 202 | </span> |
| 179 | </div> | 203 | </div> |
| 180 | 204 | ||
| 181 | {PROVIDER_ORDER.map((provider) => { | 205 | {orderedProviders.map((provider) => { |
| 182 | const totals = merged.providers.find((entry) => entry.provider === provider); | 206 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; |
| 183 | const share = | ||
| 184 | metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); | ||
| 185 | const providerSessions = totals?.sessions ?? 0; | ||
| 186 | const sessionLabel = `${formatCount(providerSessions)} ${ | ||
| 187 | providerSessions === 1 ? "session" : "sessions" | ||
| 188 | }`; | ||
| 189 | return ( | 207 | return ( |
| 190 | <div key={provider} className="flex flex-col gap-1"> | 208 | <div key={provider.provider} className="flex flex-col gap-1.5"> |
| 191 | <div className="flex items-baseline justify-between gap-4"> | 209 | <div className="flex items-baseline justify-between"> |
| 192 | <span className="flex min-w-0 items-center gap-2 text-sm text-foreground"> | 210 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| 193 | <ProviderMark provider={provider} className="size-4" /> | 211 | <ProviderMark provider={provider.provider} className="size-4" /> |
| 194 | <span className="flex min-w-0 items-baseline gap-1.5"> | 212 | {PROVIDER_LABEL[provider.provider]} |
| 195 | <span className="truncate">{PROVIDER_LABEL[provider]}</span> | ||
| 196 | <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground tabular-nums"> | ||
| 197 | {sessionLabel} | ||
| 198 | </span> | ||
| 199 | </span> | ||
| 200 | </span> | 213 | </span> |
| 201 | <span className="shrink-0 text-sm font-medium text-foreground tabular-nums"> | 214 | <span className="text-sm text-foreground tabular-nums"> |
| 202 | {metric === "cost" | 215 | {metric === "cost" |
| 203 | ? formatUsd(totals?.costUsd ?? 0) | 216 | ? formatUsd(provider.costUsd) |
| 204 | : formatTokens(totals?.totalTokens ?? 0)} | 217 | : formatTokens(provider.totalTokens)} |
| 205 | </span> | 218 | </span> |
| 206 | </div> | 219 | </div> |
| 220 | <div className="h-1 w-full overflow-hidden rounded-full bg-muted"> | ||
| 221 | <div | ||
| 222 | className="h-full" | ||
| 223 | style={{ | ||
| 224 | width: `${(share * 100).toFixed(1)}%`, | ||
| 225 | backgroundColor: PROVIDER_COLOR[provider.provider], | ||
| 226 | }} | ||
| 227 | /> | ||
| 228 | </div> | ||
| 207 | <span className="text-xs text-muted-foreground"> | 229 | <span className="text-xs text-muted-foreground"> |
| 208 | {metric === "cost" | 230 | {metric === "cost" |
| 209 | ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` | 231 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` |
| 210 | : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} | 232 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} |
| 211 | </span> | 233 | </span> |
| 212 | </div> | 234 | </div> |
| 213 | ); | 235 | ); |
| 214 | })} | 236 | })} |
| 215 | </div> | 237 | </div> |
| 216 | 238 | ||
| 217 | <div className="flex min-w-0 flex-col gap-3"> | 239 | <div className="flex flex-col gap-3"> |
| 218 | <h2 className="text-sm font-medium text-foreground"> | 240 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 219 | {isPast24Hours ? "Hourly" : "Daily"}{" "} | 241 | <h2 className="text-sm font-medium text-foreground"> |
| 220 | {metric === "tokens" ? "processed tokens" : "cost"} | 242 | {isPast24Hours ? "Hourly" : "Daily"}{" "} |
| 221 | </h2> | 243 | {metric === "tokens" ? "processed tokens" : "cost"} |
| 244 | </h2> | ||
| 245 | <div className="flex items-center gap-4"> | ||
| 246 | <div className="flex overflow-hidden rounded-md border border-border"> | ||
| 247 | {(["cost", "tokens"] as const).map((option) => ( | ||
| 248 | <button | ||
| 249 | key={option} | ||
| 250 | type="button" | ||
| 251 | onClick={() => setMetric(option)} | ||
| 252 | className={cn( | ||
| 253 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | ||
| 254 | option === metric | ||
| 255 | ? "bg-muted text-foreground" | ||
| 256 | : "text-muted-foreground hover:text-foreground", | ||
| 257 | )} | ||
| 258 | > | ||
| 259 | {option} | ||
| 260 | </button> | ||
| 261 | ))} | ||
| 262 | </div> | ||
| 263 | <UsageChartLegend /> | ||
| 264 | </div> | ||
| 265 | </div> | ||
| 222 | <UsageProviderChart | 266 | <UsageProviderChart |
| 223 | days={days} | 267 | days={days} |
| 224 | daily={merged.daily} | 268 | daily={merged.daily} |
| ... | @@ -232,42 +276,63 @@ export function UsagePage() { | ... | @@ -232,42 +276,63 @@ export function UsagePage() { |
| 232 | </div> | 276 | </div> |
| 233 | </section> | 277 | </section> |
| 234 | 278 | ||
| 235 | <section className="flex flex-col gap-2"> | 279 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> |
| 236 | <h2 className="text-sm font-medium text-foreground">Totals</h2> | 280 | <Metric |
| 237 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | 281 | label="Processed tokens" |
| 238 | <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} /> | 282 | value={formatTokens(merged.totalTokens)} |
| 239 | <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} /> | 283 | detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`} |
| 240 | <Metric | 284 | /> |
| 241 | label="Uncached input" | 285 | <Metric |
| 242 | value={formatTokens(merged.uncachedInputTokens)} | 286 | label="Cached input" |
| 243 | /> | 287 | value={formatTokens(merged.cachedInputTokens)} |
| 244 | <Metric label="Output" value={formatTokens(merged.outputTokens)} /> | 288 | detail={`${formatPercent(cachedShare)} of observed input`} |
| 245 | <Metric | 289 | /> |
| 246 | label="Cache savings" | 290 | <Metric |
| 247 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | 291 | label="Uncached input" |
| 248 | /> | 292 | value={formatTokens(merged.uncachedInputTokens)} |
| 249 | </div> | 293 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} |
| 294 | /> | ||
| 295 | <Metric | ||
| 296 | label="Output" | ||
| 297 | value={formatTokens(merged.outputTokens)} | ||
| 298 | detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} | ||
| 299 | /> | ||
| 300 | <Metric | ||
| 301 | label="Cache savings" | ||
| 302 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | ||
| 303 | detail={ | ||
| 304 | merged.costUsd > 0 | ||
| 305 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` | ||
| 306 | : "vs full input rates" | ||
| 307 | } | ||
| 308 | /> | ||
| 250 | </section> | 309 | </section> |
| 251 | 310 | ||
| 252 | <section className="flex flex-col gap-3"> | 311 | <section className="flex flex-col gap-3"> |
| 253 | <div className="flex items-center justify-between gap-3"> | 312 | <div className="flex items-center justify-between gap-3"> |
| 254 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> | 313 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 255 | <SegmentedTabList aria-label="Usage breakdown"> | 314 | <div className="flex overflow-hidden rounded-md border border-border"> |
| 256 | {( | 315 | {( |
| 257 | [ | 316 | [ |
| 258 | { value: "model", label: "Model" }, | 317 | { value: "model", label: "model" }, |
| 259 | { value: "time", label: isPast24Hours ? "Hour" : "Day" }, | 318 | { value: "time", label: isPast24Hours ? "hour" : "day" }, |
| 260 | ] as const | 319 | ] as const |
| 261 | ).map((option) => ( | 320 | ).map((option) => ( |
| 262 | <SegmentedTab | 321 | <button |
| 263 | key={option.value} | 322 | key={option.value} |
| 264 | selected={option.value === breakdown} | 323 | type="button" |
| 265 | onClick={() => setBreakdown(option.value)} | 324 | onClick={() => setBreakdown(option.value)} |
| 325 | className={cn( | ||
| 326 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | ||
| 327 | option.value === breakdown | ||
| 328 | ? "bg-muted text-foreground" | ||
| 329 | : "text-muted-foreground hover:text-foreground", | ||
| 330 | )} | ||
| 266 | > | 331 | > |
| 267 | {option.label} | 332 | {option.label} |
| 268 | </SegmentedTab> | 333 | </button> |
| 269 | ))} | 334 | ))} |
| 270 | </SegmentedTabList> | 335 | </div> |
| 271 | </div> | 336 | </div> |
| 272 | 337 | ||
| 273 | {breakdown === "model" ? ( | 338 | {breakdown === "model" ? ( |
| ... | @@ -291,7 +356,7 @@ export function UsagePage() { | ... | @@ -291,7 +356,7 @@ export function UsagePage() { |
| 291 | merged.models.map((model) => ( | 356 | merged.models.map((model) => ( |
| 292 | <tr | 357 | <tr |
| 293 | key={`${model.provider}:${model.model}`} | 358 | key={`${model.provider}:${model.model}`} |
| 294 | className="hover:bg-muted/20" | 359 | className="border-b border-border/50" |
| 295 | > | 360 | > |
| 296 | <td className="py-2 text-foreground"> | 361 | <td className="py-2 text-foreground"> |
| 297 | <span className="flex items-center gap-2"> | 362 | <span className="flex items-center gap-2"> |
| ... | @@ -338,7 +403,7 @@ export function UsagePage() { | ... | @@ -338,7 +403,7 @@ export function UsagePage() { |
| 338 | recentPeriods.map((period) => ( | 403 | recentPeriods.map((period) => ( |
| 339 | <tr | 404 | <tr |
| 340 | key={"hourStart" in period ? period.hourStart : period.day} | 405 | key={"hourStart" in period ? period.hourStart : period.day} |
| 341 | className="hover:bg-muted/20" | 406 | className="border-b border-border/50" |
| 342 | > | 407 | > |
| 343 | <td className="py-2 text-foreground"> | 408 | <td className="py-2 text-foreground"> |
| 344 | {"hourStart" in period | 409 | {"hourStart" in period |
| ... | @@ -368,7 +433,7 @@ export function UsagePage() { | ... | @@ -368,7 +433,7 @@ export function UsagePage() { |
| 368 | </section> | 433 | </section> |
| 369 | </> | 434 | </> |
| 370 | )} | 435 | )} |
| 371 | </WorkspacePageContainer> | 436 | </div> |
| 372 | </ScrollArea> | 437 | </ScrollArea> |
| 373 | </div> | 438 | </div> |
| 374 | </SidebarInset> | 439 | </SidebarInset> |
| ... | @@ -387,11 +452,20 @@ function ProviderMark({ | ... | @@ -387,11 +452,20 @@ function ProviderMark({ |
| 387 | return <Mark className={cn("shrink-0", className)} aria-hidden />; | 452 | return <Mark className={cn("shrink-0", className)} aria-hidden />; |
| 388 | } | 453 | } |
| 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 | return ( | 464 | return ( |
| 392 | <div className="flex min-w-0 flex-col gap-0.5"> | 465 | <div className="flex flex-col gap-0.5 bg-background px-4 py-3"> |
| 393 | <span className="text-xs text-muted-foreground">{label}</span> | 466 | <span className="text-xs text-muted-foreground">{label}</span> |
| 394 | <span className="text-base font-medium text-foreground tabular-nums">{value}</span> | 467 | <span className="text-lg text-foreground tabular-nums">{value}</span> |
| 468 | <span className="text-xs text-muted-foreground">{detail}</span> | ||
| 395 | </div> | 469 | </div> |
| 396 | ); | 470 | ); |
| 397 | } | 471 | } |
| ... | @@ -495,51 +569,70 @@ function UsageDeviceStrip({ | ... | @@ -495,51 +569,70 @@ function UsageDeviceStrip({ |
| 495 | ); | 569 | ); |
| 496 | } | 570 | } |
| 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 | 576 | * Static stand-in with the loaded page's shape: headline, provider split, |
| 500 | * exactly once when the last device answers. | 577 | * chart and metrics strip. No shimmer; blocks fill in exactly once when the |
| 578 | * last device answers. | ||
| 501 | */ | 579 | */ |
| 502 | function UsageSkeleton() { | 580 | function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { |
| 503 | return ( | 581 | return ( |
| 504 | <> | 582 | <> |
| 505 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> | 583 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> |
| 506 | <div className="flex flex-col gap-5"> | 584 | <div className="flex flex-col gap-5"> |
| 507 | <div className="flex flex-col gap-1"> | 585 | <div className="flex flex-col gap-1"> |
| 508 | <div className="h-8 w-36 rounded-sm bg-muted" /> | 586 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> |
| 509 | <div className="h-3 w-32 rounded-sm bg-muted" /> | 587 | Raw token cost |
| 588 | </span> | ||
| 589 | <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" /> | ||
| 590 | <div className="h-3 w-28 rounded-sm bg-muted" /> | ||
| 510 | </div> | 591 | </div> |
| 592 | |||
| 511 | {PROVIDER_ORDER.map((provider) => ( | 593 | {PROVIDER_ORDER.map((provider) => ( |
| 512 | <div key={provider} className="flex flex-col gap-1"> | 594 | <div key={provider} className="flex flex-col gap-1.5"> |
| 513 | <div className="flex items-center justify-between gap-4"> | 595 | <div className="flex items-center justify-between"> |
| 514 | <span className="flex items-center gap-2"> | 596 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| 515 | <ProviderMark provider={provider} className="size-4" /> | 597 | <ProviderMark provider={provider} className="size-4" /> |
| 516 | <div className="h-3.5 w-20 rounded-sm bg-muted" /> | 598 | {PROVIDER_LABEL[provider]} |
| 517 | </span> | 599 | </span> |
| 518 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> | 600 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 519 | </div> | 601 | </div> |
| 602 | <div className="h-1 w-full rounded-full bg-muted" /> | ||
| 520 | <div className="h-3 w-36 rounded-sm bg-muted" /> | 603 | <div className="h-3 w-36 rounded-sm bg-muted" /> |
| 521 | </div> | 604 | </div> |
| 522 | ))} | 605 | ))} |
| 523 | </div> | 606 | </div> |
| 524 | 607 | ||
| 525 | <div className="flex flex-col gap-3"> | 608 | <div className="flex flex-col gap-3"> |
| 526 | <div className="h-4 w-24 rounded-sm bg-muted" /> | 609 | <h2 className="py-1 text-sm font-medium text-foreground"> |
| 527 | <div className="h-56 rounded-sm bg-muted/35" /> | 610 | {resolution === "hour" ? "Hourly" : "Daily"} cost |
| 611 | </h2> | ||
| 612 | {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a | ||
| 613 | relayout when the real chart swaps in. */} | ||
| 614 | <div className="flex h-56 items-end gap-1 pl-16"> | ||
| 615 | {SKELETON_BAR_HEIGHTS.map((height) => ( | ||
| 616 | <div | ||
| 617 | key={height} | ||
| 618 | className="flex-1 rounded-sm bg-muted" | ||
| 619 | style={{ height: `${height}%` }} | ||
| 620 | /> | ||
| 621 | ))} | ||
| 622 | </div> | ||
| 528 | </div> | 623 | </div> |
| 529 | </section> | 624 | </section> |
| 530 | 625 | ||
| 531 | <section className="flex flex-col gap-2"> | 626 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> |
| 532 | <h2 className="text-sm font-medium text-foreground">Totals</h2> | 627 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( |
| 533 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | 628 | (label) => ( |
| 534 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | 629 | <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3"> |
| 535 | (label) => ( | 630 | <span className="text-xs text-muted-foreground">{label}</span> |
| 536 | <div key={label} className="flex flex-col gap-0.5"> | 631 | <div className="my-1 h-5 w-16 rounded-sm bg-muted" /> |
| 537 | <span className="text-xs text-muted-foreground">{label}</span> | 632 | <div className="h-3 w-24 rounded-sm bg-muted" /> |
| 538 | <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" /> | 633 | </div> |
| 539 | </div> | 634 | ), |
| 540 | ), | 635 | )} |
| 541 | )} | ||
| 542 | </div> | ||
| 543 | </section> | 636 | </section> |
| 544 | </> | 637 | </> |
| 545 | ); | 638 | ); |
apps/web/src/components/usage/UsageProviderChart.tsx+65-72| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 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 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; | 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | import { | 5 | import { |
| ... | @@ -68,7 +68,13 @@ function buildPeriodColumns( | ... | @@ -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 | function monotoneTangents(points: readonly Point[]): readonly number[] { | 78 | function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 73 | const count = points.length; | 79 | const count = points.length; |
| 74 | if (count < 2) return [0]; | 80 | if (count < 2) return [0]; |
| ... | @@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { | ... | @@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 109 | return tangents; | 115 | return tangents; |
| 110 | } | 116 | } |
| 111 | 117 | ||
| 118 | /** One cubic segment of a smoothed boundary. */ | ||
| 112 | interface CurveSegment { | 119 | interface CurveSegment { |
| 113 | readonly from: Point; | 120 | readonly from: Point; |
| 114 | readonly c1: Point; | 121 | readonly c1: Point; |
| ... | @@ -116,6 +123,7 @@ interface CurveSegment { | ... | @@ -116,6 +123,7 @@ interface CurveSegment { |
| 116 | readonly to: Point; | 123 | readonly to: Point; |
| 117 | } | 124 | } |
| 118 | 125 | ||
| 126 | /** Smoothed polyline through `points`, as explicit cubic control points. */ | ||
| 119 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { | 127 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 120 | if (points.length < 2) return []; | 128 | if (points.length < 2) return []; |
| 121 | const tangents = monotoneTangents(points); | 129 | const tangents = monotoneTangents(points); |
| ... | @@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { | ... | @@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 136 | return segments; | 144 | return segments; |
| 137 | } | 145 | } |
| 138 | 146 | ||
| 139 | function curvePath(segments: readonly CurveSegment[]): string { | 147 | function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { |
| 140 | const first = segments[0]; | 148 | const first = segments[0]; |
| 141 | if (first === undefined) return ""; | 149 | if (first === undefined) return ""; |
| 142 | let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; | 150 | let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; |
| 143 | for (const segment of segments) { | 151 | for (const segment of segments) { |
| 144 | path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; | 152 | path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; |
| 145 | } | 153 | } |
| ... | @@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re | ... | @@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re |
| 171 | /** | 179 | /** |
| 172 | * Turns the merged daily totals into one column per day. | 180 | * Turns the merged daily totals into one column per day. |
| 173 | * | 181 | * |
| 174 | * Values are absolute, not cumulative: each provider is drawn from the same | 182 | * Values are absolute, not cumulative: the series are layered from a shared |
| 175 | * zero baseline so the chart never implies that one provider is always larger. | 183 | * zero baseline rather than stacked. A stacked chart puts whichever provider is |
| 184 | * drawn last permanently above the other, which reads as "that one is bigger" | ||
| 185 | * even on days where it is not. | ||
| 176 | * | 186 | * |
| 177 | * The chart paths and the hover readout both consume this, so the number under | 187 | * The chart paths and the hover readout both consume this, so the number under |
| 178 | * the cursor is by construction the number that was plotted rather than a | 188 | * the cursor is by construction the number that was plotted rather than a |
| ... | @@ -206,39 +216,42 @@ export function UsageProviderChart({ | ... | @@ -206,39 +216,42 @@ export function UsageProviderChart({ |
| 206 | ); | 216 | ); |
| 207 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); | 217 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); |
| 208 | const plotRef = useRef<HTMLDivElement | null>(null); | 218 | const plotRef = useRef<HTMLDivElement | null>(null); |
| 209 | const tooltipRef = useRef<HTMLDivElement | null>(null); | ||
| 210 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); | ||
| 211 | 219 | ||
| 212 | const { paths, series, stepX, ticks, toY } = useMemo(() => { | 220 | const { paths, ticks, stepX, toY, series } = useMemo(() => { |
| 213 | if (periods.length === 0) { | 221 | if (periods.length === 0) { |
| 214 | return { | 222 | return { |
| 215 | paths: [], | 223 | paths: [], |
| 216 | series: [] as readonly DayColumn[], | ||
| 217 | stepX: 0, | ||
| 218 | ticks: [0] as readonly number[], | 224 | ticks: [0] as readonly number[], |
| 225 | stepX: 0, | ||
| 219 | toY: () => VIEW_HEIGHT, | 226 | toY: () => VIEW_HEIGHT, |
| 227 | series: [] as readonly DayColumn[], | ||
| 220 | }; | 228 | }; |
| 221 | } | 229 | } |
| 222 | 230 | ||
| 223 | const columns = buildPeriodColumns(periods, byPeriod, metric); | 231 | const columns = buildPeriodColumns(periods, byPeriod, metric); |
| 232 | |||
| 233 | // The scale tops out at the largest single provider-day, not the largest | ||
| 234 | // sum: layered series each measure from zero, so a combined peak would | ||
| 235 | // leave the plot permanently half empty. | ||
| 224 | const peak = columns.reduce( | 236 | const peak = columns.reduce( |
| 225 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), | 237 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), |
| 226 | 0, | 238 | 0, |
| 227 | ); | 239 | ); |
| 228 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); | 240 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 229 | const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); | 241 | const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); |
| 242 | // Reserve a sliver above the top gridline so the series stroke, which is | ||
| 243 | // drawn at constant screen width, is not shaved off at a peak. | ||
| 230 | const toY = (value: number) => | 244 | const toY = (value: number) => |
| 231 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); | 245 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 232 | 246 | ||
| 233 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { | 247 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 234 | const line = curvePath( | 248 | const curve = smoothCurve( |
| 235 | smoothCurve( | 249 | columns.map((column, dayIndex) => ({ |
| 236 | columns.map((column, periodIndex) => ({ | 250 | x: dayIndex * step, |
| 237 | x: periodIndex * step, | 251 | y: toY(column.bands[providerIndex]?.value ?? 0), |
| 238 | y: toY(column.bands[providerIndex]?.value ?? 0), | 252 | })), |
| 239 | })), | ||
| 240 | ), | ||
| 241 | ); | 253 | ); |
| 254 | const line = curvePath(curve, "M"); | ||
| 242 | return { | 255 | return { |
| 243 | provider, | 256 | provider, |
| 244 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), | 257 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| ... | @@ -247,65 +260,30 @@ export function UsageProviderChart({ | ... | @@ -247,65 +260,30 @@ export function UsageProviderChart({ |
| 247 | }; | 260 | }; |
| 248 | }); | 261 | }); |
| 249 | 262 | ||
| 250 | return { | 263 | // Paint the heavier series first so the lighter one is never buried under |
| 251 | paths: built.toSorted((a, b) => b.total - a.total), | 264 | // it. The fills are faint enough that the order barely shows, but the |
| 252 | series: columns, | 265 | // strokes are drawn in a second pass regardless, so neither can be hidden. |
| 253 | stepX: step, | 266 | const ordered = [...built].sort((a, b) => b.total - a.total); |
| 254 | ticks: tickValues, | 267 | |
| 255 | toY, | 268 | return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; |
| 256 | }; | ||
| 257 | }, [byPeriod, metric, periods]); | 269 | }, [byPeriod, metric, periods]); |
| 258 | 270 | ||
| 259 | const format = metric === "tokens" ? formatTokens : formatUsd; | 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 | const handleMove = useCallback( | 273 | const handleMove = useCallback( |
| 291 | (event: React.MouseEvent<HTMLDivElement>) => { | 274 | (event: React.MouseEvent<HTMLDivElement>) => { |
| 292 | const plot = plotRef.current; | 275 | const bounds = plotRef.current?.getBoundingClientRect(); |
| 293 | if (plot === null || periods.length === 0) return; | 276 | if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; |
| 294 | const bounds = plot.getBoundingClientRect(); | 277 | const fraction = (event.clientX - bounds.left) / bounds.width; |
| 295 | if (bounds.width === 0) return; | ||
| 296 | const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); | ||
| 297 | const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); | ||
| 298 | const fraction = localX / bounds.width; | ||
| 299 | const index = Math.round(fraction * (periods.length - 1)); | 278 | const index = Math.round(fraction * (periods.length - 1)); |
| 300 | hoverPositionRef.current = { x: localX, y: localY }; | ||
| 301 | positionTooltip(); | ||
| 302 | setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); | 279 | setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); |
| 303 | }, | 280 | }, |
| 304 | [periods.length, positionTooltip], | 281 | [periods.length], |
| 305 | ); | 282 | ); |
| 306 | 283 | ||
| 307 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; | 284 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; |
| 308 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; | 285 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; |
| 286 | const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; | ||
| 309 | const formatPeriod = (period: string) => | 287 | const formatPeriod = (period: string) => |
| 310 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); | 288 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); |
| 311 | const formatTooltipPeriod = (period: string) => | 289 | const formatTooltipPeriod = (period: string) => |
| ... | @@ -333,10 +311,7 @@ export function UsageProviderChart({ | ... | @@ -333,10 +311,7 @@ export function UsageProviderChart({ |
| 333 | ref={plotRef} | 311 | ref={plotRef} |
| 334 | className="relative h-56 flex-1" | 312 | className="relative h-56 flex-1" |
| 335 | onMouseMove={handleMove} | 313 | onMouseMove={handleMove} |
| 336 | onMouseLeave={() => { | 314 | onMouseLeave={() => setHoverIndex(null)} |
| 337 | hoverPositionRef.current = null; | ||
| 338 | setHoverIndex(null); | ||
| 339 | }} | ||
| 340 | > | 315 | > |
| 341 | <svg | 316 | <svg |
| 342 | className="h-full w-full" | 317 | className="h-full w-full" |
| ... | @@ -362,6 +337,7 @@ export function UsageProviderChart({ | ... | @@ -362,6 +337,7 @@ export function UsageProviderChart({ |
| 362 | ); | 337 | ); |
| 363 | })} | 338 | })} |
| 364 | 339 | ||
| 340 | {/* Fills first, then every stroke, so no series covers another's line. */} | ||
| 365 | {paths.map(({ provider, area }) => ( | 341 | {paths.map(({ provider, area }) => ( |
| 366 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> | 342 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> |
| 367 | ))} | 343 | ))} |
| ... | @@ -392,11 +368,10 @@ export function UsageProviderChart({ | ... | @@ -392,11 +368,10 @@ export function UsageProviderChart({ |
| 392 | 368 | ||
| 393 | {hoveredPeriod === undefined ? null : ( | 369 | {hoveredPeriod === undefined ? null : ( |
| 394 | <div | 370 | <div |
| 395 | ref={tooltipRef} | 371 | className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs" |
| 396 | className="pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 bg-background/65 px-2.5 py-2 text-xs shadow-lg backdrop-blur-xl backdrop-saturate-150" | ||
| 397 | style={{ | 372 | style={{ |
| 398 | left: "var(--usage-tooltip-left, 0px)", | 373 | left: `${hoverLeft}%`, |
| 399 | top: "var(--usage-tooltip-top, 0px)", | 374 | transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)", |
| 400 | }} | 375 | }} |
| 401 | > | 376 | > |
| 402 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> | 377 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| ... | @@ -443,3 +418,21 @@ export function UsageProviderChart({ | ... | @@ -443,3 +418,21 @@ export function UsageProviderChart({ |
| 443 | </div> | 418 | </div> |
| 444 | ); | 419 | ); |
| 445 | } | 420 | } |
| 421 | |||
| 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,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; |
| 3 | import { ClaudeAI, type Icon, OpenAI } from "../Icons"; | 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 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; | 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,15 +4,6 @@ const SVG_NS = "http://www.w3.org/2000/svg"; |
| 4 | 4 | ||
| 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). | 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). |
| 6 | const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = { | 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 | pencil: [ | 7 | pencil: [ |
| 17 | { | 8 | { |
| 18 | tag: "path", | 9 | tag: "path", |
| ... | @@ -26,71 +17,6 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri | ... | @@ -26,71 +17,6 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 26 | { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, | 17 | { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, |
| 27 | { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } }, | 18 | { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } }, |
| 28 | ], | 19 | ], |
| 29 | folder: [ | ||
| 30 | { | ||
| 31 | tag: "path", | ||
| 32 | attrs: { | ||
| 33 | d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | ], | ||
| 37 | "git-branch": [ | ||
| 38 | { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } }, | ||
| 39 | { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } }, | ||
| 40 | { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } }, | ||
| 41 | { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } }, | ||
| 42 | ], | ||
| 43 | hash: [ | ||
| 44 | { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } }, | ||
| 45 | { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } }, | ||
| 46 | { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } }, | ||
| 47 | { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } }, | ||
| 48 | ], | ||
| 49 | "mail-open": [ | ||
| 50 | { | ||
| 51 | tag: "path", | ||
| 52 | attrs: { | ||
| 53 | d: "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z", | ||
| 54 | }, | ||
| 55 | }, | ||
| 56 | { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } }, | ||
| 57 | ], | ||
| 58 | "message-square-plus": [ | ||
| 59 | { | ||
| 60 | tag: "path", | ||
| 61 | attrs: { | ||
| 62 | d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z", | ||
| 63 | }, | ||
| 64 | }, | ||
| 65 | { tag: "path", attrs: { d: "M12 8v6" } }, | ||
| 66 | { tag: "path", attrs: { d: "M9 11h6" } }, | ||
| 67 | ], | ||
| 68 | pin: [ | ||
| 69 | { tag: "path", attrs: { d: "M12 17v5" } }, | ||
| 70 | { | ||
| 71 | tag: "path", | ||
| 72 | attrs: { | ||
| 73 | d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z", | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | ], | ||
| 77 | "pin-off": [ | ||
| 78 | { tag: "path", attrs: { d: "M12 17v5" } }, | ||
| 79 | { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } }, | ||
| 80 | { tag: "path", attrs: { d: "m2 2 20 20" } }, | ||
| 81 | { | ||
| 82 | tag: "path", | ||
| 83 | attrs: { | ||
| 84 | d: "M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11", | ||
| 85 | }, | ||
| 86 | }, | ||
| 87 | ], | ||
| 88 | "refresh-cw": [ | ||
| 89 | { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } }, | ||
| 90 | { tag: "path", attrs: { d: "M21 3v5h-5" } }, | ||
| 91 | { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } }, | ||
| 92 | { tag: "path", attrs: { d: "M8 16H3v5" } }, | ||
| 93 | ], | ||
| 94 | "folder-tree": [ | 20 | "folder-tree": [ |
| 95 | { | 21 | { |
| 96 | tag: "path", | 22 | tag: "path", |
| ... | @@ -118,7 +44,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri | ... | @@ -118,7 +44,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 118 | 44 | ||
| 119 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { | 45 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { |
| 120 | const paths = ICON_PATHS[name]; | 46 | const paths = ICON_PATHS[name]; |
| 121 | if (!paths || typeof document.createElementNS !== "function") { | 47 | if (!paths) { |
| 122 | return null; | 48 | return null; |
| 123 | } | 49 | } |
| 124 | const svg = document.createElementNS(SVG_NS, "svg"); | 50 | const svg = document.createElementNS(SVG_NS, "svg"); |
| ... | @@ -131,7 +57,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV | ... | @@ -131,7 +57,7 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV |
| 131 | svg.setAttribute("stroke-linejoin", "round"); | 57 | svg.setAttribute("stroke-linejoin", "round"); |
| 132 | svg.setAttribute( | 58 | svg.setAttribute( |
| 133 | "class", | 59 | "class", |
| 134 | tone === "destructive" ? "size-4 shrink-0" : "size-4 shrink-0 text-muted-foreground", | 60 | tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground", |
| 135 | ); | 61 | ); |
| 136 | for (const node of paths) { | 62 | for (const node of paths) { |
| 137 | const child = document.createElementNS(SVG_NS, node.tag); | 63 | const child = document.createElementNS(SVG_NS, node.tag); |
| ... | @@ -274,16 +200,6 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -274,16 +200,6 @@ export function showContextMenuFallback<T extends string>( |
| 274 | "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; | 200 | "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; |
| 275 | 201 | ||
| 276 | for (const item of entries) { | 202 | for (const item of entries) { |
| 277 | if (item.separatorBefore === true && inner.childElementCount > 0) { | ||
| 278 | const separator = document.createElement("div"); | ||
| 279 | separator.className = "my-1 h-px bg-border/70"; | ||
| 280 | separator.style.cssText = | ||
| 281 | "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; | ||
| 282 | separator.dataset.contextMenuSeparator = "true"; | ||
| 283 | separator.setAttribute("role", "separator"); | ||
| 284 | inner.appendChild(separator); | ||
| 285 | } | ||
| 286 | |||
| 287 | if (item.header === true) { | 203 | if (item.header === true) { |
| 288 | const header = document.createElement("div"); | 204 | const header = document.createElement("div"); |
| 289 | header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; | 205 | header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; |
| ... | @@ -331,12 +247,10 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -331,12 +247,10 @@ export function showContextMenuFallback<T extends string>( |
| 331 | button.appendChild(label); | 247 | button.appendChild(label); |
| 332 | 248 | ||
| 333 | if (hasChildren) { | 249 | if (hasChildren) { |
| 334 | const chevron = createIconElement("chevron-right", "neutral"); | 250 | const chevron = document.createElement("span"); |
| 335 | if (chevron) { | 251 | chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; |
| 336 | chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); | 252 | chevron.textContent = ">"; |
| 337 | chevron.dataset.contextMenuChevron = "true"; | 253 | button.appendChild(chevron); |
| 338 | button.appendChild(chevron); | ||
| 339 | } | ||
| 340 | } | 254 | } |
| 341 | 255 | ||
| 342 | if (!isDisabled) { | 256 | if (!isDisabled) { |
apps/web/src/index.css+5-78| ... | @@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil | ... | @@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 241 | opacity: 1; | 241 | opacity: 1; |
| 242 | } | 242 | } |
| 243 | } | 243 | } |
| 244 | @keyframes live-activity-focus { | ||
| 245 | 0% { | ||
| 246 | transform: translateX(0); | ||
| 247 | } | ||
| 248 | 100% { | ||
| 249 | transform: translateX(100%); | ||
| 250 | } | ||
| 251 | } | ||
| 252 | @keyframes live-activity-focus-counter { | ||
| 253 | 0% { | ||
| 254 | transform: translateX(0); | ||
| 255 | } | ||
| 256 | 100% { | ||
| 257 | transform: translateX(-100%); | ||
| 258 | } | ||
| 259 | } | ||
| 260 | @keyframes status-ping { | 244 | @keyframes status-ping { |
| 261 | /* Burst first (immediate feedback for click ripples), then hold | 245 | /* Burst first (immediate feedback for click ripples), then hold |
| 262 | invisible for the rest of the cycle. Mirrors animate-ping's | 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,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 | @layer base { | 434 | @layer base { |
| 507 | :root { | 435 | :root { |
| 508 | /* Keep the original T3 Code artwork palettes as the defaults. Built-in | 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,16 +1349,15 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { |
| 1421 | 1349 | ||
| 1422 | /* The panel layout toggles stay ghost: they render both inside the header and | 1350 | /* The panel layout toggles stay ghost: they render both inside the header and |
| 1423 | in the titlebar strip, so filling them would make them change appearance as | 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 | 1352 | the panel opens. They only take the themed foreground; hover and pressed |
| 1425 | action text; hover and pressed keep the base ghost accent. The tooltip | 1353 | keep the base ghost accent. The tooltip trigger's data-slot wins over the |
| 1426 | trigger's data-slot wins over the toggle's when it renders the toggle, so | 1354 | toggle's when the trigger renders the toggle, so match both. */ |
| 1427 | match both. */ | ||
| 1428 | html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], | 1355 | html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], |
| 1429 | html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], | 1356 | html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], |
| 1430 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], | 1357 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], |
| 1431 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { | 1358 | html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { |
| 1432 | --control-icon-color: var(--toolbar-control-foreground); | 1359 | --control-icon-color: var(--toolbar-foreground); |
| 1433 | color: var(--toolbar-control-foreground); | 1360 | color: var(--toolbar-foreground); |
| 1434 | } | 1361 | } |
| 1435 | 1362 | ||
| 1436 | html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, | 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,17 +118,6 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu |
| 118 | return null; | 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 | function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { | 121 | function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { |
| 133 | const repository = match?.[1]; | 122 | const repository = match?.[1]; |
| 134 | const number = Number(match?.[2]); | 123 | const number = Number(match?.[2]); |
apps/web/src/routes/-chatIndexTitlebar.test.ts+3-1| ... | @@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => { | ... | @@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => { |
| 15 | 15 | ||
| 16 | const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); | 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 | expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); | 21 | expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); |
| 20 | }); | 22 | }); |
| 21 | }); | 23 | }); |
apps/web/src/routes/_chat.index.tsx+9-3| ... | @@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; | ... | @@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; |
| 8 | import { Button } from "../components/ui/button"; | 8 | import { Button } from "../components/ui/button"; |
| 9 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; | 9 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; |
| 10 | import { SidebarInset } from "../components/ui/sidebar"; | 10 | import { SidebarInset } from "../components/ui/sidebar"; |
| 11 | import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; | ||
| 12 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 11 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 13 | import { | 12 | import { |
| 14 | useAllEnvironmentShellsBootstrapped, | 13 | useAllEnvironmentShellsBootstrapped, |
| ... | @@ -18,6 +17,8 @@ import { | ... | @@ -18,6 +17,8 @@ import { |
| 18 | import { useEnvironments } from "../state/environments"; | 17 | import { useEnvironments } from "../state/environments"; |
| 19 | import { APP_DISPLAY_NAME } from "~/branding"; | 18 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 20 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 19 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 20 | import { cn } from "~/lib/utils"; | ||
| 21 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | ||
| 21 | 22 | ||
| 22 | function ChatIndexRouteView() { | 23 | function ChatIndexRouteView() { |
| 23 | const { authGateState } = Route.useRouteContext(); | 24 | const { authGateState } = Route.useRouteContext(); |
| ... | @@ -142,13 +143,18 @@ function HostedStaticOnboardingState() { | ... | @@ -142,13 +143,18 @@ function HostedStaticOnboardingState() { |
| 142 | return ( | 143 | return ( |
| 143 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | 144 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 144 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | 145 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 145 | <WorkspacePageHeader className="border-b border-border"> | 146 | <header |
| 147 | className={cn( | ||
| 148 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | ||
| 149 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 150 | )} | ||
| 151 | > | ||
| 146 | <div className="flex items-center gap-2"> | 152 | <div className="flex items-center gap-2"> |
| 147 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> | 153 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| 148 | {APP_DISPLAY_NAME} | 154 | {APP_DISPLAY_NAME} |
| 149 | </span> | 155 | </span> |
| 150 | </div> | 156 | </div> |
| 151 | </WorkspacePageHeader> | 157 | </header> |
| 152 | 158 | ||
| 153 | <Empty className="flex-1"> | 159 | <Empty className="flex-1"> |
| 154 | <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5"> | 160 | <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5"> |
apps/web/src/routes/_chat.pull-requests.tsx+37-66| ... | @@ -74,12 +74,6 @@ import { | ... | @@ -74,12 +74,6 @@ import { |
| 74 | WorkspaceBreadcrumbItem, | 74 | WorkspaceBreadcrumbItem, |
| 75 | WorkspaceBreadcrumbSeparator, | 75 | WorkspaceBreadcrumbSeparator, |
| 76 | } from "../components/WorkspaceBreadcrumb"; | 76 | } from "../components/WorkspaceBreadcrumb"; |
| 77 | import { | ||
| 78 | WorkspacePageContainer, | ||
| 79 | WorkspacePageHeader, | ||
| 80 | WorkspacePageHeaderEdgeControl, | ||
| 81 | } from "../components/WorkspacePageContainer"; | ||
| 82 | import { isElectron } from "../env"; | ||
| 83 | import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; | 77 | import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; |
| 84 | import { Button } from "../components/ui/button"; | 78 | import { Button } from "../components/ui/button"; |
| 85 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; | 79 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; |
| ... | @@ -105,6 +99,7 @@ import { | ... | @@ -105,6 +99,7 @@ import { |
| 105 | import { useAtomCommand } from "../state/use-atom-command"; | 99 | import { useAtomCommand } from "../state/use-atom-command"; |
| 106 | import { cn } from "~/lib/utils"; | 100 | import { cn } from "~/lib/utils"; |
| 107 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; | 101 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 102 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | ||
| 108 | 103 | ||
| 109 | export interface PullRequestsSearch { | 104 | export interface PullRequestsSearch { |
| 110 | readonly involvement: PullRequestInvolvement; | 105 | readonly involvement: PullRequestInvolvement; |
| ... | @@ -1186,17 +1181,6 @@ function PullRequestsRouteView() { | ... | @@ -1186,17 +1181,6 @@ function PullRequestsRouteView() { |
| 1186 | : null, | 1181 | : null, |
| 1187 | [search.number, search.repository, selectedProject], | 1182 | [search.number, search.repository, selectedProject], |
| 1188 | ); | 1183 | ); |
| 1189 | const linkedSelectionMatchesSurface = | ||
| 1190 | linkedSelection !== null && | ||
| 1191 | selectedPullRequestSurface !== null && | ||
| 1192 | linkedSelection.environmentId === selectedPullRequestSurface.environmentId && | ||
| 1193 | linkedSelection.projectId === selectedPullRequestSurface.projectId && | ||
| 1194 | linkedSelection.repository === selectedPullRequestSurface.repository && | ||
| 1195 | linkedSelection.number === selectedPullRequestSurface.number; | ||
| 1196 | // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are | ||
| 1197 | // history, though, not a current selection: without this check they leave the toggle looking | ||
| 1198 | // available after the selected pull request has been cleared. | ||
| 1199 | const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; | ||
| 1200 | useEffect(() => { | 1184 | useEffect(() => { |
| 1201 | if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; | 1185 | if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; |
| 1202 | useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); | 1186 | useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); |
| ... | @@ -1310,10 +1294,9 @@ function PullRequestsRouteView() { | ... | @@ -1310,10 +1294,9 @@ function PullRequestsRouteView() { |
| 1310 | terminalAvailable={false} | 1294 | terminalAvailable={false} |
| 1311 | terminalOpen={false} | 1295 | terminalOpen={false} |
| 1312 | terminalShortcutLabel={null} | 1296 | terminalShortcutLabel={null} |
| 1313 | rightPanelAvailable={rightPanelAvailable} | 1297 | rightPanelAvailable={rightPanelState.surfaces.length > 0} |
| 1314 | rightPanelOpen={rightPanelState.isOpen} | 1298 | rightPanelOpen={rightPanelState.isOpen} |
| 1315 | rightPanelShortcutLabel={null} | 1299 | rightPanelShortcutLabel={null} |
| 1316 | rightPanelUnavailableLabel="Select a pull request first" | ||
| 1317 | liveAgentCount={0} | 1300 | liveAgentCount={0} |
| 1318 | onToggleTerminal={() => undefined} | 1301 | onToggleTerminal={() => undefined} |
| 1319 | onToggleRightPanel={toggleRightPanel} | 1302 | onToggleRightPanel={toggleRightPanel} |
| ... | @@ -1620,6 +1603,7 @@ function PullRequestsRouteView() { | ... | @@ -1620,6 +1603,7 @@ function PullRequestsRouteView() { |
| 1620 | reviewingQuery.refresh(); | 1603 | reviewingQuery.refresh(); |
| 1621 | }} | 1604 | }} |
| 1622 | onStateChange={handlePullRequestTabStatusChange} | 1605 | onStateChange={handlePullRequestTabStatusChange} |
| 1606 | chromeVariant="collapse" | ||
| 1623 | /> | 1607 | /> |
| 1624 | </RightPanelTabs> | 1608 | </RightPanelTabs> |
| 1625 | ) : null} | 1609 | ) : null} |
| ... | @@ -1843,10 +1827,18 @@ function PullRequestsColumn({ | ... | @@ -1843,10 +1827,18 @@ function PullRequestsColumn({ |
| 1843 | // Painted flat like the chat column: the inset underneath carries the chrome grain, and a | 1827 | // Painted flat like the chat column: the inset underneath carries the chrome grain, and a |
| 1844 | // content surface that lets it show reads as a different background than every thread. | 1828 | // content surface that lets it show reads as a different background than every thread. |
| 1845 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> | 1829 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> |
| 1846 | {/* A closed right panel leaves this column full-width, so the shared header | 1830 | <header |
| 1847 | reserves native window controls. While the panel is open, the column ends | 1831 | className={cn( |
| 1848 | at the panel and the absolute controls strip owns the top-right corner. */} | 1832 | "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1.5 px-3 sm:px-5", |
| 1849 | <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}> | 1833 | // A closed right panel leaves this column full-width, so its header runs |
| 1834 | // underneath the native window controls on Windows; reserve the inset the | ||
| 1835 | // way Settings and the chat view do. While the panel is open the column | ||
| 1836 | // ends at the panel's left edge and the absolute controls strip (already | ||
| 1837 | // WCO-aware) owns the top-right corner. | ||
| 1838 | !rightPanelOpen && "wco:pr-[var(--workspace-native-controls-inset)]", | ||
| 1839 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 1840 | )} | ||
| 1841 | > | ||
| 1850 | {condensed ? ( | 1842 | {condensed ? ( |
| 1851 | <WorkspaceBreadcrumb ariaLabel="Pull request scope"> | 1843 | <WorkspaceBreadcrumb ariaLabel="Pull request scope"> |
| 1852 | {/* The page name remains the foreground anchor in both states; the live filters are | 1844 | {/* The page name remains the foreground anchor in both states; the live filters are |
| ... | @@ -1888,24 +1880,27 @@ function PullRequestsColumn({ | ... | @@ -1888,24 +1880,27 @@ function PullRequestsColumn({ |
| 1888 | )} | 1880 | )} |
| 1889 | <div className="min-w-0 flex-1" /> | 1881 | <div className="min-w-0 flex-1" /> |
| 1890 | {condensed ? ( | 1882 | {condensed ? ( |
| 1891 | <div className="flex shrink-0 items-center gap-1.5"> | 1883 | <ExpandableSearch |
| 1892 | <ExpandableSearch | 1884 | searchInput={searchInput} |
| 1893 | searchInput={searchInput} | 1885 | searchValue={searchValue} |
| 1894 | searchValue={searchValue} | 1886 | open={searchOpen} |
| 1895 | open={searchOpen} | 1887 | onOpenChange={setSearchOpen} |
| 1896 | onOpenChange={setSearchOpen} | 1888 | focusToken={searchFocusToken} |
| 1897 | focusToken={searchFocusToken} | 1889 | onFocusWithin={(focused) => { |
| 1898 | onFocusWithin={(focused) => { | 1890 | topbarSearchFocusedRef.current = focused; |
| 1899 | topbarSearchFocusedRef.current = focused; | 1891 | }} |
| 1900 | }} | 1892 | /> |
| 1901 | /> | ||
| 1902 | <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} /> | ||
| 1903 | </div> | ||
| 1904 | ) : null} | ||
| 1905 | {rightPanelControl ? ( | ||
| 1906 | <WorkspacePageHeaderEdgeControl>{rightPanelControl}</WorkspacePageHeaderEdgeControl> | ||
| 1907 | ) : null} | 1893 | ) : null} |
| 1908 | </WorkspacePageHeader> | 1894 | <Button |
| 1895 | size="icon-sm" | ||
| 1896 | variant="ghost" | ||
| 1897 | aria-label="Refresh pull requests" | ||
| 1898 | onClick={onRefresh} | ||
| 1899 | > | ||
| 1900 | <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} /> | ||
| 1901 | </Button> | ||
| 1902 | {rightPanelControl} | ||
| 1903 | </header> | ||
| 1909 | 1904 | ||
| 1910 | <div | 1905 | <div |
| 1911 | ref={scrollRef} | 1906 | ref={scrollRef} |
| ... | @@ -1914,43 +1909,19 @@ function PullRequestsColumn({ | ... | @@ -1914,43 +1909,19 @@ function PullRequestsColumn({ |
| 1914 | {/* The top padding is the fade band's own height (1.5rem here), the same pairing the | 1909 | {/* The top padding is the fade band's own height (1.5rem here), the same pairing the |
| 1915 | settings page makes: at rest the controls sit fully below the mask, and only | 1910 | settings page makes: at rest the controls sit fully below the mask, and only |
| 1916 | content actually passing under the chrome fades. */} | 1911 | content actually passing under the chrome fades. */} |
| 1917 | <WorkspacePageContainer className="gap-4"> | 1912 | <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12"> |
| 1918 | <div className="flex flex-col gap-3"> | 1913 | <div className="flex flex-col gap-3"> |
| 1919 | <div ref={inFlowSearchRef} className="flex items-center gap-2"> | 1914 | <div ref={inFlowSearchRef} className="flex items-center gap-2"> |
| 1920 | {searchInput} | 1915 | {searchInput} |
| 1921 | {filtersMenu} | 1916 | {filtersMenu} |
| 1922 | {!condensed ? ( | ||
| 1923 | <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} /> | ||
| 1924 | ) : null} | ||
| 1925 | </div> | 1917 | </div> |
| 1926 | {/* Scrolled past this marker, the controls are gone and the title takes over. */} | 1918 | {/* Scrolled past this marker, the controls are gone and the title takes over. */} |
| 1927 | <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" /> | 1919 | <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" /> |
| 1928 | </div> | 1920 | </div> |
| 1929 | 1921 | ||
| 1930 | {listBody} | 1922 | {listBody} |
| 1931 | </WorkspacePageContainer> | 1923 | </div> |
| 1932 | </div> | 1924 | </div> |
| 1933 | </div> | 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,8 +13,9 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 13 | import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; | 13 | import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; |
| 14 | import { Button } from "../components/ui/button"; | 14 | import { Button } from "../components/ui/button"; |
| 15 | import { SidebarInset } from "../components/ui/sidebar"; | 15 | import { SidebarInset } from "../components/ui/sidebar"; |
| 16 | import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; | ||
| 17 | import { isElectron } from "../env"; | 16 | import { isElectron } from "../env"; |
| 17 | import { cn } from "~/lib/utils"; | ||
| 18 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | ||
| 18 | 19 | ||
| 19 | function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { | 20 | function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { |
| 20 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); | 21 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); |
| ... | @@ -71,16 +72,41 @@ function SettingsContentLayout() { | ... | @@ -71,16 +72,41 @@ function SettingsContentLayout() { |
| 71 | return ( | 72 | return ( |
| 72 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 73 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 73 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 74 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 74 | <WorkspacePageHeader electron={isElectron}> | 75 | {!isElectron && ( |
| 75 | <div className="flex w-full items-center gap-3"> | 76 | <header |
| 76 | <SettingsBreadcrumb pathname={location.pathname} /> | 77 | className={cn( |
| 77 | {showRestoreDefaults ? ( | 78 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", |
| 78 | <div className="ms-auto flex items-center gap-2"> | 79 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 79 | <RestoreDefaultsButton onRestored={handleRestored} /> | 80 | )} |
| 80 | </div> | 81 | > |
| 81 | ) : null} | 82 | <div className="flex w-full items-center gap-2"> |
| 83 | <SettingsBreadcrumb pathname={location.pathname} /> | ||
| 84 | {showRestoreDefaults ? ( | ||
| 85 | <div className="ms-auto flex items-center gap-2"> | ||
| 86 | <RestoreDefaultsButton onRestored={handleRestored} /> | ||
| 87 | </div> | ||
| 88 | ) : null} | ||
| 89 | </div> | ||
| 90 | </header> | ||
| 91 | )} | ||
| 92 | |||
| 93 | {isElectron && ( | ||
| 94 | <div | ||
| 95 | className={cn( | ||
| 96 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | ||
| 97 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 98 | )} | ||
| 99 | > | ||
| 100 | <div className="flex w-full items-center gap-2"> | ||
| 101 | <SettingsBreadcrumb pathname={location.pathname} /> | ||
| 102 | {showRestoreDefaults ? ( | ||
| 103 | <div className="ms-auto flex items-center gap-2"> | ||
| 104 | <RestoreDefaultsButton onRestored={handleRestored} /> | ||
| 105 | </div> | ||
| 106 | ) : null} | ||
| 107 | </div> | ||
| 82 | </div> | 108 | </div> |
| 83 | </WorkspacePageHeader> | 109 | )} |
| 84 | 110 | ||
| 85 | <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col"> | 111 | <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col"> |
| 86 | <Outlet /> | 112 | <Outlet /> |
apps/web/src/session-logic.test.ts+6-127| ... | @@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => { | ... | @@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => { |
| 722 | }); | 722 | }); |
| 723 | 723 | ||
| 724 | describe("deriveWorkLogEntries", () => { | 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 | const activities: OrchestrationThreadActivity[] = [ | 726 | const activities: OrchestrationThreadActivity[] = [ |
| 727 | makeActivity({ | ||
| 728 | id: "tool-start", | ||
| 729 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 730 | summary: "Command run started", | ||
| 731 | kind: "tool.started", | ||
| 732 | payload: { | ||
| 733 | itemType: "command_execution", | ||
| 734 | toolCallId: "call-1", | ||
| 735 | status: "inProgress", | ||
| 736 | title: "Command run", | ||
| 737 | detail: "Bash: vp test run", | ||
| 738 | data: { | ||
| 739 | toolName: "Bash", | ||
| 740 | input: { command: "vp test run" }, | ||
| 741 | }, | ||
| 742 | }, | ||
| 743 | }), | ||
| 744 | ]; | ||
| 745 | |||
| 746 | const [entry] = deriveWorkLogEntries(activities); | ||
| 747 | expect(entry).toMatchObject({ | ||
| 748 | id: "tool-start", | ||
| 749 | command: "vp test run", | ||
| 750 | toolCallId: "call-1", | ||
| 751 | toolLifecycleStatus: "inProgress", | ||
| 752 | sourceActivityKind: "tool.started", | ||
| 753 | }); | ||
| 754 | }); | ||
| 755 | |||
| 756 | it("retains the start command when the matching completion omits it", () => { | ||
| 757 | const activities: OrchestrationThreadActivity[] = [ | ||
| 758 | makeActivity({ | ||
| 759 | id: "tool-start", | ||
| 760 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 761 | summary: "Command run started", | ||
| 762 | kind: "tool.started", | ||
| 763 | payload: { | ||
| 764 | itemType: "command_execution", | ||
| 765 | toolCallId: "call-1", | ||
| 766 | status: "inProgress", | ||
| 767 | title: "Command run", | ||
| 768 | data: { input: { command: "vp test run" } }, | ||
| 769 | }, | ||
| 770 | }), | ||
| 771 | makeActivity({ | ||
| 772 | id: "other-tool-start", | ||
| 773 | createdAt: "2026-02-23T00:00:02.500Z", | ||
| 774 | summary: "Other command started", | ||
| 775 | kind: "tool.started", | ||
| 776 | payload: { | ||
| 777 | itemType: "command_execution", | ||
| 778 | toolCallId: "call-2", | ||
| 779 | status: "inProgress", | ||
| 780 | title: "Other command", | ||
| 781 | data: { input: { command: "vp lint" } }, | ||
| 782 | }, | ||
| 783 | }), | ||
| 784 | makeActivity({ | 727 | makeActivity({ |
| 785 | id: "tool-complete", | 728 | id: "tool-complete", |
| 786 | createdAt: "2026-02-23T00:00:03.000Z", | 729 | createdAt: "2026-02-23T00:00:03.000Z", |
| 787 | summary: "Command run", | 730 | summary: "Tool call complete", |
| 788 | kind: "tool.completed", | ||
| 789 | payload: { | ||
| 790 | itemType: "command_execution", | ||
| 791 | toolCallId: "call-1", | ||
| 792 | status: "completed", | ||
| 793 | title: "Command run", | ||
| 794 | }, | ||
| 795 | }), | ||
| 796 | makeActivity({ | ||
| 797 | id: "other-tool-complete", | ||
| 798 | createdAt: "2026-02-23T00:00:04.000Z", | ||
| 799 | summary: "Other command", | ||
| 800 | kind: "tool.completed", | 731 | kind: "tool.completed", |
| 801 | payload: { | ||
| 802 | itemType: "command_execution", | ||
| 803 | toolCallId: "call-2", | ||
| 804 | status: "completed", | ||
| 805 | title: "Other command", | ||
| 806 | }, | ||
| 807 | }), | 732 | }), |
| 808 | ]; | ||
| 809 | |||
| 810 | const entries = deriveWorkLogEntries(activities); | ||
| 811 | expect(entries).toHaveLength(2); | ||
| 812 | expect(entries[0]).toMatchObject({ | ||
| 813 | id: "tool-complete", | ||
| 814 | command: "vp test run", | ||
| 815 | toolCallId: "call-1", | ||
| 816 | toolLifecycleStatus: "completed", | ||
| 817 | sourceActivityKind: "tool.completed", | ||
| 818 | }); | ||
| 819 | expect(entries[1]).toMatchObject({ | ||
| 820 | id: "other-tool-complete", | ||
| 821 | command: "vp lint", | ||
| 822 | toolCallId: "call-2", | ||
| 823 | toolLifecycleStatus: "completed", | ||
| 824 | sourceActivityKind: "tool.completed", | ||
| 825 | }); | ||
| 826 | }); | ||
| 827 | |||
| 828 | it("does not merge non-adjacent tool starts without stable call ids", () => { | ||
| 829 | const activities: OrchestrationThreadActivity[] = [ | ||
| 830 | makeActivity({ | 733 | makeActivity({ |
| 831 | id: "unkeyed-start-1", | 734 | id: "tool-start", |
| 832 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 833 | summary: "Search started", | ||
| 834 | kind: "tool.started", | ||
| 835 | payload: { itemType: "search", title: "Search", status: "inProgress" }, | ||
| 836 | }), | ||
| 837 | makeActivity({ | ||
| 838 | id: "keyed-start", | ||
| 839 | createdAt: "2026-02-23T00:00:02.000Z", | 735 | createdAt: "2026-02-23T00:00:02.000Z", |
| 840 | summary: "Command started", | 736 | summary: "Tool call", |
| 841 | kind: "tool.started", | ||
| 842 | payload: { | ||
| 843 | itemType: "command_execution", | ||
| 844 | toolCallId: "call-between", | ||
| 845 | title: "Command", | ||
| 846 | status: "inProgress", | ||
| 847 | }, | ||
| 848 | }), | ||
| 849 | makeActivity({ | ||
| 850 | id: "unkeyed-start-2", | ||
| 851 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 852 | summary: "Search started", | ||
| 853 | kind: "tool.started", | 737 | kind: "tool.started", |
| 854 | payload: { itemType: "search", title: "Search", status: "inProgress" }, | ||
| 855 | }), | 738 | }), |
| 856 | ]; | 739 | ]; |
| 857 | 740 | ||
| 858 | expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ | 741 | const entries = deriveWorkLogEntries(activities); |
| 859 | "unkeyed-start-1", | 742 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 860 | "keyed-start", | ||
| 861 | "unkeyed-start-2", | ||
| 862 | ]); | ||
| 863 | }); | 743 | }); |
| 864 | 744 | ||
| 865 | it("omits task.started but shows task.progress and task.completed", () => { | 745 | it("omits task.started but shows task.progress and task.completed", () => { |
| ... | @@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => { |
| 1359 | expect(entries).toHaveLength(1); | 1239 | expect(entries).toHaveLength(1); |
| 1360 | expect(entries[0]).toMatchObject({ | 1240 | expect(entries[0]).toMatchObject({ |
| 1361 | id: "grep-complete", | 1241 | id: "grep-complete", |
| 1362 | toolCallId: "tool-grep-1", | ||
| 1363 | toolTitle: "grep", | 1242 | toolTitle: "grep", |
| 1364 | detail: "19 files", | 1243 | detail: "19 files", |
| 1365 | itemType: "web_search", | 1244 | itemType: "web_search", |
apps/web/src/session-logic.ts+7-98| ... | @@ -65,8 +65,6 @@ export interface WorkLogEntry { | ... | @@ -65,8 +65,6 @@ export interface WorkLogEntry { |
| 65 | id: string; | 65 | id: string; |
| 66 | createdAt: string; | 66 | createdAt: string; |
| 67 | turnId?: TurnId | null; | 67 | turnId?: TurnId | null; |
| 68 | /** Stable provider identity across in-progress and completed lifecycle updates. */ | ||
| 69 | toolCallId?: string; | ||
| 70 | label: string; | 68 | label: string; |
| 71 | detail?: string; | 69 | detail?: string; |
| 72 | command?: string; | 70 | command?: string; |
| ... | @@ -750,6 +748,7 @@ export function deriveWorkLogEntries( | ... | @@ -750,6 +748,7 @@ export function deriveWorkLogEntries( |
| 750 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 748 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 751 | const entries: DerivedWorkLogEntry[] = []; | 749 | const entries: DerivedWorkLogEntry[] = []; |
| 752 | for (const activity of ordered) { | 750 | for (const activity of ordered) { |
| 751 | if (activity.kind === "tool.started") continue; | ||
| 753 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, | 752 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| 754 | // which is the batch key (completions of background subagents arrive | 753 | // which is the batch key (completions of background subagents arrive |
| 755 | // under later synthetic turns and must not start new batches). They | 754 | // under later synthetic turns and must not start new batches). They |
| ... | @@ -758,13 +757,8 @@ export function deriveWorkLogEntries( | ... | @@ -758,13 +757,8 @@ export function deriveWorkLogEntries( |
| 758 | if (activity.kind === "task.updated") continue; | 757 | if (activity.kind === "task.updated") continue; |
| 759 | if (activity.kind === "tool.progress") continue; | 758 | if (activity.kind === "tool.progress") continue; |
| 760 | if (activity.kind === "context-window.updated") continue; | 759 | if (activity.kind === "context-window.updated") continue; |
| 761 | // Plan updates have a dedicated task row. Keeping the raw activity here | ||
| 762 | // duplicates it as a legacy "Work Log / Plan updated" row when history | ||
| 763 | // is expanded. | ||
| 764 | if (activity.kind === "turn.plan.updated") continue; | ||
| 765 | if (activity.summary === "Checkpoint captured") continue; | 760 | if (activity.summary === "Checkpoint captured") continue; |
| 766 | if (isPlanBoundaryToolActivity(activity)) continue; | 761 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 767 | if (isCodexTerminalInteractionActivity(activity)) continue; | ||
| 768 | if (isAgentInternalActivity(activity)) continue; | 762 | if (isAgentInternalActivity(activity)) continue; |
| 769 | entries.push(toDerivedWorkLogEntry(activity)); | 763 | entries.push(toDerivedWorkLogEntry(activity)); |
| 770 | } | 764 | } |
| ... | @@ -775,11 +769,7 @@ export function deriveWorkLogEntries( | ... | @@ -775,11 +769,7 @@ export function deriveWorkLogEntries( |
| 775 | } | 769 | } |
| 776 | 770 | ||
| 777 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { | 771 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { |
| 778 | if ( | 772 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { |
| 779 | activity.kind !== "tool.started" && | ||
| 780 | activity.kind !== "tool.updated" && | ||
| 781 | activity.kind !== "tool.completed" | ||
| 782 | ) { | ||
| 783 | return false; | 773 | return false; |
| 784 | } | 774 | } |
| 785 | 775 | ||
| ... | @@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool | ... | @@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool |
| 790 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | 780 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); |
| 791 | } | 781 | } |
| 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 | function extractWorkLogToolLifecycleStatus( | 783 | function extractWorkLogToolLifecycleStatus( |
| 816 | payload: Record<string, unknown> | null, | 784 | payload: Record<string, unknown> | null, |
| 817 | ): WorkLogToolLifecycleStatus | undefined { | 785 | ): WorkLogToolLifecycleStatus | undefined { |
| ... | @@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 910 | entry.toolCallId = toolCallId; | 878 | entry.toolCallId = toolCallId; |
| 911 | } | 879 | } |
| 912 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); | 880 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); |
| 913 | if (!toolLifecycleStatus && activity.kind === "tool.started") { | ||
| 914 | toolLifecycleStatus = "inProgress"; | ||
| 915 | } | ||
| 916 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { | 881 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { |
| 917 | toolLifecycleStatus = "completed"; | 882 | toolLifecycleStatus = "completed"; |
| 918 | } | 883 | } |
| ... | @@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { | ... | @@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { |
| 968 | return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; | 933 | return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; |
| 969 | } | 934 | } |
| 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 | function collapseDerivedWorkLogEntries( | 936 | function collapseDerivedWorkLogEntries( |
| 983 | entries: ReadonlyArray<DerivedWorkLogEntry>, | 937 | entries: ReadonlyArray<DerivedWorkLogEntry>, |
| 984 | ): DerivedWorkLogEntry[] { | 938 | ): DerivedWorkLogEntry[] { |
| ... | @@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries( | ... | @@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries( |
| 995 | // own turn splintered one batch into a stream of "Kicked off N subagents" | 949 | // own turn splintered one batch into a stream of "Kicked off N subagents" |
| 996 | // rows (live-test finding, thread 7ac7ef05). | 950 | // rows (live-test finding, thread 7ac7ef05). |
| 997 | const groupKeyByTaskId = new Map<string, string>(); | 951 | const groupKeyByTaskId = new Map<string, string>(); |
| 998 | const toolLifecycleRowIndex = new Map<string, number>(); | ||
| 999 | for (const entry of entries) { | 952 | for (const entry of entries) { |
| 1000 | const isTaskRow = | 953 | const isTaskRow = |
| 1001 | entry.taskId !== undefined && | 954 | entry.taskId !== undefined && |
| ... | @@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries( | ... | @@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries( |
| 1040 | }); | 993 | }); |
| 1041 | continue; | 994 | continue; |
| 1042 | } | 995 | } |
| 1043 | const lifecycleKey = toolLifecycleCollapseMapKey(entry); | ||
| 1044 | if (lifecycleKey !== undefined) { | ||
| 1045 | const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); | ||
| 1046 | if (matchingLifecycleIndex !== undefined) { | ||
| 1047 | const matchingEntry = collapsed[matchingLifecycleIndex]; | ||
| 1048 | if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { | ||
| 1049 | toolLifecycleRowIndex.delete(lifecycleKey); | ||
| 1050 | const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); | ||
| 1051 | collapsed[matchingLifecycleIndex] = merged; | ||
| 1052 | if (merged.activityKind !== "tool.completed") { | ||
| 1053 | toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); | ||
| 1054 | } | ||
| 1055 | continue; | ||
| 1056 | } | ||
| 1057 | toolLifecycleRowIndex.delete(lifecycleKey); | ||
| 1058 | } | ||
| 1059 | } | ||
| 1060 | const previous = collapsed.at(-1); | 996 | const previous = collapsed.at(-1); |
| 1061 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | 997 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { |
| 1062 | const previousIndex = collapsed.length - 1; | 998 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); |
| 1063 | const previousKey = toolLifecycleCollapseMapKey(previous); | ||
| 1064 | if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); | ||
| 1065 | const merged = mergeDerivedWorkLogEntries(previous, entry); | ||
| 1066 | collapsed[previousIndex] = merged; | ||
| 1067 | const mergedKey = toolLifecycleCollapseMapKey(merged); | ||
| 1068 | if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { | ||
| 1069 | toolLifecycleRowIndex.set(mergedKey, previousIndex); | ||
| 1070 | } | ||
| 1071 | continue; | 999 | continue; |
| 1072 | } | 1000 | } |
| 1073 | collapsed.push(entry); | 1001 | collapsed.push(entry); |
| 1074 | if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { | ||
| 1075 | toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); | ||
| 1076 | } | ||
| 1077 | } | 1002 | } |
| 1078 | return collapsed; | 1003 | return collapsed; |
| 1079 | } | 1004 | } |
| ... | @@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries( | ... | @@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries( |
| 1082 | previous: DerivedWorkLogEntry, | 1007 | previous: DerivedWorkLogEntry, |
| 1083 | next: DerivedWorkLogEntry, | 1008 | next: DerivedWorkLogEntry, |
| 1084 | ): boolean { | 1009 | ): boolean { |
| 1085 | if ( | 1010 | if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { |
| 1086 | previous.activityKind !== "tool.started" && | ||
| 1087 | previous.activityKind !== "tool.updated" && | ||
| 1088 | previous.activityKind !== "tool.completed" | ||
| 1089 | ) { | ||
| 1090 | return false; | 1011 | return false; |
| 1091 | } | 1012 | } |
| 1092 | if ( | 1013 | if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { |
| 1093 | next.activityKind !== "tool.started" && | ||
| 1094 | next.activityKind !== "tool.updated" && | ||
| 1095 | next.activityKind !== "tool.completed" | ||
| 1096 | ) { | ||
| 1097 | return false; | 1014 | return false; |
| 1098 | } | 1015 | } |
| 1099 | if (previous.activityKind === "tool.completed") { | 1016 | if (previous.activityKind === "tool.completed") { |
| ... | @@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un | ... | @@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un |
| 1163 | ) { | 1080 | ) { |
| 1164 | return `task${entry.taskId}`; | 1081 | return `task${entry.taskId}`; |
| 1165 | } | 1082 | } |
| 1166 | if ( | 1083 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { |
| 1167 | entry.activityKind !== "tool.started" && | ||
| 1168 | entry.activityKind !== "tool.updated" && | ||
| 1169 | entry.activityKind !== "tool.completed" | ||
| 1170 | ) { | ||
| 1171 | return undefined; | 1084 | return undefined; |
| 1172 | } | 1085 | } |
| 1173 | if (entry.toolCallId) { | 1086 | if (entry.toolCallId) { |
| ... | @@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): { | ... | @@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): { |
| 1370 | const item = asRecord(data?.item); | 1283 | const item = asRecord(data?.item); |
| 1371 | const itemResult = asRecord(item?.result); | 1284 | const itemResult = asRecord(item?.result); |
| 1372 | const itemInput = asRecord(item?.input); | 1285 | const itemInput = asRecord(item?.input); |
| 1373 | const dataInput = asRecord(data?.input); | ||
| 1374 | const stateInput = asRecord(asRecord(data?.state)?.input); | ||
| 1375 | const itemType = asTrimmedString(payload?.itemType); | 1286 | const itemType = asTrimmedString(payload?.itemType); |
| 1376 | const detail = asTrimmedString(payload?.detail); | 1287 | const detail = asTrimmedString(payload?.detail); |
| 1377 | const candidates: unknown[] = [ | 1288 | const candidates: unknown[] = [ |
| ... | @@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): { | ... | @@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): { |
| 1379 | itemInput?.command, | 1290 | itemInput?.command, |
| 1380 | itemResult?.command, | 1291 | itemResult?.command, |
| 1381 | data?.command, | 1292 | data?.command, |
| 1382 | dataInput?.command, | ||
| 1383 | stateInput?.command, | ||
| 1384 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, | 1293 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, |
| 1385 | ]; | 1294 | ]; |
| 1386 | 1295 | ||
| ... | @@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul | ... | @@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul |
| 1407 | 1316 | ||
| 1408 | function extractToolCallId(payload: Record<string, unknown> | null): string | null { | 1317 | function extractToolCallId(payload: Record<string, unknown> | null): string | null { |
| 1409 | const data = asRecord(payload?.data); | 1318 | const data = asRecord(payload?.data); |
| 1410 | return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); | 1319 | return asTrimmedString(data?.toolCallId); |
| 1411 | } | 1320 | } |
| 1412 | 1321 | ||
| 1413 | function normalizeInlinePreview(value: string): string { | 1322 | function normalizeInlinePreview(value: string): string { |
apps/web/src/terminalUiStateStore.test.ts-8| ... | @@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => { |
| 18 | useTerminalUiStateStore.persist.clearStorage(); | 18 | useTerminalUiStateStore.persist.clearStorage(); |
| 19 | useTerminalUiStateStore.setState({ | 19 | useTerminalUiStateStore.setState({ |
| 20 | terminalUiStateByThreadKey: {}, | 20 | terminalUiStateByThreadKey: {}, |
| 21 | terminalCustomLabelsByThreadKey: {}, | ||
| 22 | suppressedTerminalIdsByThreadKey: {}, | 21 | suppressedTerminalIdsByThreadKey: {}, |
| 23 | }); | 22 | }); |
| 24 | }); | 23 | }); |
| ... | @@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => { |
| 249 | it("reconciles terminal ids from an external ordered list", () => { | 248 | it("reconciles terminal ids from an external ordered list", () => { |
| 250 | const store = useTerminalUiStateStore.getState(); | 249 | const store = useTerminalUiStateStore.getState(); |
| 251 | store.setTerminalOpen(THREAD_REF, true); | 250 | store.setTerminalOpen(THREAD_REF, true); |
| 252 | store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); | ||
| 253 | store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); | ||
| 254 | store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); | 251 | store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); |
| 255 | 252 | ||
| 256 | const terminalUiState = selectThreadTerminalUiState( | 253 | const terminalUiState = selectThreadTerminalUiState( |
| ... | @@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => { |
| 263 | { id: "group-term-a", terminalIds: ["term-a"] }, | 260 | { id: "group-term-a", terminalIds: ["term-a"] }, |
| 264 | { id: "group-term-b", terminalIds: ["term-b"] }, | 261 | { id: "group-term-b", terminalIds: ["term-b"] }, |
| 265 | ]); | 262 | ]); |
| 266 | expect( | ||
| 267 | useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ | ||
| 268 | scopedThreadKey(THREAD_REF) | ||
| 269 | ], | ||
| 270 | ).toEqual({ "term-a": "API server" }); | ||
| 271 | }); | 263 | }); |
| 272 | 264 | ||
| 273 | it("does not import a closed panel terminal from stale metadata", () => { | 265 | it("does not import a closed panel terminal from stale metadata", () => { |
apps/web/src/terminalUiStateStore.ts+16-154| ... | @@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | ... | @@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 32 | interface PersistedTerminalUiStateStoreState { | 32 | interface PersistedTerminalUiStateStoreState { |
| 33 | terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; | 33 | terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 34 | terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; | 34 | terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 35 | terminalCustomLabelsByThreadKey?: Record<string, Record<string, string>>; | ||
| 36 | } | 35 | } |
| 37 | 36 | ||
| 38 | const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly<Record<string, string>> = Object.freeze({}); | ||
| 39 | |||
| 40 | export function migratePersistedTerminalUiStateStoreState( | 37 | export function migratePersistedTerminalUiStateStoreState( |
| 41 | persistedState: unknown, | 38 | persistedState: unknown, |
| 42 | _version: number, | 39 | _version: number, |
| ... | @@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState( | ... | @@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState( |
| 53 | parseScopedThreadKey(threadKey), | 50 | parseScopedThreadKey(threadKey), |
| 54 | ), | 51 | ), |
| 55 | ); | 52 | ); |
| 56 | const terminalCustomLabelsByThreadKey = Object.fromEntries( | ||
| 57 | Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( | ||
| 58 | ([threadKey, labels]) => { | ||
| 59 | if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; | ||
| 60 | const normalizedLabels = Object.fromEntries( | ||
| 61 | Object.entries(labels).flatMap(([terminalId, label]) => { | ||
| 62 | const normalizedTerminalId = terminalId.trim(); | ||
| 63 | const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; | ||
| 64 | return normalizedTerminalId && normalizedLabel | ||
| 65 | ? [[normalizedTerminalId, normalizedLabel] as const] | ||
| 66 | : []; | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | return Object.keys(normalizedLabels).length > 0 | ||
| 70 | ? [[threadKey, normalizedLabels] as const] | ||
| 71 | : []; | ||
| 72 | }, | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | 53 | ||
| 76 | return { | 54 | return { terminalUiStateByThreadKey }; |
| 77 | terminalUiStateByThreadKey, | ||
| 78 | ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 | ||
| 79 | ? { terminalCustomLabelsByThreadKey } | ||
| 80 | : {}), | ||
| 81 | }; | ||
| 82 | } | 55 | } |
| 83 | 56 | ||
| 84 | function createTerminalUiStateStorage() { | 57 | function createTerminalUiStateStorage() { |
| ... | @@ -516,18 +489,6 @@ export function selectThreadTerminalUiState( | ... | @@ -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 | function updateTerminalUiStateByThreadKey( | 492 | function updateTerminalUiStateByThreadKey( |
| 532 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, | 493 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 533 | threadRef: ScopedThreadRef, | 494 | threadRef: ScopedThreadRef, |
| ... | @@ -601,7 +562,6 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st | ... | @@ -601,7 +562,6 @@ function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<st |
| 601 | 562 | ||
| 602 | interface TerminalUiStateStoreState { | 563 | interface TerminalUiStateStoreState { |
| 603 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; | 564 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; |
| 604 | terminalCustomLabelsByThreadKey: Record<string, Record<string, string>>; | ||
| 605 | /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ | 565 | /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ |
| 606 | suppressedTerminalIdsByThreadKey: Record<string, string[]>; | 566 | suppressedTerminalIdsByThreadKey: Record<string, string[]>; |
| 607 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | 567 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| ... | @@ -615,11 +575,6 @@ interface TerminalUiStateStoreState { | ... | @@ -615,11 +575,6 @@ interface TerminalUiStateStoreState { |
| 615 | options?: { open?: boolean; active?: boolean }, | 575 | options?: { open?: boolean; active?: boolean }, |
| 616 | ) => void; | 576 | ) => void; |
| 617 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 577 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 618 | setTerminalCustomLabel: ( | ||
| 619 | threadRef: ScopedThreadRef, | ||
| 620 | terminalId: string, | ||
| 621 | label: string | null, | ||
| 622 | ) => void; | ||
| 623 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 578 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 624 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; | 579 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; |
| 625 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; | 580 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| ... | @@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 636 | state: ThreadTerminalUiState, | 591 | state: ThreadTerminalUiState, |
| 637 | suppressedTerminalIds: readonly string[], | 592 | suppressedTerminalIds: readonly string[], |
| 638 | ) => ThreadTerminalUiState, | 593 | ) => ThreadTerminalUiState, |
| 639 | suppression?: { | 594 | suppression?: { terminalId: string; suppressed: boolean }, |
| 640 | terminalId: string; | ||
| 641 | suppressed: boolean; | ||
| 642 | clearCustomLabel?: boolean; | ||
| 643 | }, | ||
| 644 | pruneCustomLabels = false, | ||
| 645 | ) => { | 595 | ) => { |
| 646 | set((state) => { | 596 | set((state) => { |
| 647 | const threadKey = terminalThreadKey(threadRef); | 597 | const threadKey = terminalThreadKey(threadRef); |
| ... | @@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 659 | suppression.suppressed, | 609 | suppression.suppressed, |
| 660 | ) | 610 | ) |
| 661 | : state.suppressedTerminalIdsByThreadKey; | 611 | : state.suppressedTerminalIdsByThreadKey; |
| 662 | const terminalIdToClear = suppression?.clearCustomLabel | ||
| 663 | ? suppression.terminalId.trim() | ||
| 664 | : ""; | ||
| 665 | const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; | ||
| 666 | let nextTerminalCustomLabelsByThreadKey = | ||
| 667 | terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined | ||
| 668 | ? Object.keys(currentLabels).length === 1 | ||
| 669 | ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) | ||
| 670 | : { | ||
| 671 | ...state.terminalCustomLabelsByThreadKey, | ||
| 672 | [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), | ||
| 673 | } | ||
| 674 | : state.terminalCustomLabelsByThreadKey; | ||
| 675 | if (pruneCustomLabels) { | ||
| 676 | const survivingIds = new Set( | ||
| 677 | selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, | ||
| 678 | ); | ||
| 679 | const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; | ||
| 680 | const survivingLabels = Object.fromEntries( | ||
| 681 | Object.entries(labelsForThread).filter(([terminalId]) => | ||
| 682 | survivingIds.has(terminalId), | ||
| 683 | ), | ||
| 684 | ); | ||
| 685 | if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { | ||
| 686 | nextTerminalCustomLabelsByThreadKey = | ||
| 687 | Object.keys(survivingLabels).length > 0 | ||
| 688 | ? { | ||
| 689 | ...nextTerminalCustomLabelsByThreadKey, | ||
| 690 | [threadKey]: survivingLabels, | ||
| 691 | } | ||
| 692 | : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); | ||
| 693 | } | ||
| 694 | } | ||
| 695 | if ( | 612 | if ( |
| 696 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && | 613 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 697 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && | 614 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey |
| 698 | nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey | ||
| 699 | ) { | 615 | ) { |
| 700 | return state; | 616 | return state; |
| 701 | } | 617 | } |
| 702 | return { | 618 | return { |
| 703 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 619 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 704 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, | 620 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 705 | terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, | ||
| 706 | }; | 621 | }; |
| 707 | }); | 622 | }); |
| 708 | }; | 623 | }; |
| 709 | 624 | ||
| 710 | return { | 625 | return { |
| 711 | terminalUiStateByThreadKey: {}, | 626 | terminalUiStateByThreadKey: {}, |
| 712 | terminalCustomLabelsByThreadKey: {}, | ||
| 713 | suppressedTerminalIdsByThreadKey: {}, | 627 | suppressedTerminalIdsByThreadKey: {}, |
| 714 | setTerminalOpen: (threadRef, open) => { | 628 | setTerminalOpen: (threadRef, open) => { |
| 715 | const terminalState = selectThreadTerminalUiState( | 629 | const terminalState = selectThreadTerminalUiState( |
| ... | @@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 768 | ), | 682 | ), |
| 769 | setActiveTerminal: (threadRef, terminalId) => | 683 | setActiveTerminal: (threadRef, terminalId) => |
| 770 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | 684 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 771 | setTerminalCustomLabel: (threadRef, terminalId, label) => | ||
| 772 | set((state) => { | ||
| 773 | const normalizedTerminalId = terminalId.trim(); | ||
| 774 | if (normalizedTerminalId.length === 0) return state; | ||
| 775 | const threadKey = terminalThreadKey(threadRef); | ||
| 776 | const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; | ||
| 777 | const normalizedLabel = label?.trim().slice(0, 80) ?? ""; | ||
| 778 | if (normalizedLabel.length > 0) { | ||
| 779 | if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; | ||
| 780 | return { | ||
| 781 | terminalCustomLabelsByThreadKey: { | ||
| 782 | ...state.terminalCustomLabelsByThreadKey, | ||
| 783 | [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, | ||
| 784 | }, | ||
| 785 | }; | ||
| 786 | } | ||
| 787 | if (currentLabels[normalizedTerminalId] === undefined) return state; | ||
| 788 | const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; | ||
| 789 | return { | ||
| 790 | terminalCustomLabelsByThreadKey: | ||
| 791 | Object.keys(remainingLabels).length > 0 | ||
| 792 | ? { | ||
| 793 | ...state.terminalCustomLabelsByThreadKey, | ||
| 794 | [threadKey]: remainingLabels, | ||
| 795 | } | ||
| 796 | : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), | ||
| 797 | }; | ||
| 798 | }), | ||
| 799 | closeTerminal: (threadRef, terminalId) => | 685 | closeTerminal: (threadRef, terminalId) => |
| 800 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { | 686 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { |
| 801 | terminalId, | 687 | terminalId, |
| 802 | suppressed: true, | 688 | suppressed: true, |
| 803 | clearCustomLabel: true, | ||
| 804 | }), | 689 | }), |
| 805 | reconcileTerminalIds: (threadRef, nextIds) => | 690 | reconcileTerminalIds: (threadRef, nextIds) => |
| 806 | updateTerminal( | 691 | updateTerminal(threadRef, (state, suppressedTerminalIds) => { |
| 807 | threadRef, | 692 | if (suppressedTerminalIds.length === 0) { |
| 808 | (state, suppressedTerminalIds) => { | 693 | return reconcileThreadTerminalSessionIds(state, nextIds); |
| 809 | if (suppressedTerminalIds.length === 0) { | 694 | } |
| 810 | return reconcileThreadTerminalSessionIds(state, nextIds); | 695 | const suppressedIds = new Set(suppressedTerminalIds); |
| 811 | } | 696 | return reconcileThreadTerminalSessionIds( |
| 812 | const suppressedIds = new Set(suppressedTerminalIds); | 697 | state, |
| 813 | return reconcileThreadTerminalSessionIds( | 698 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), |
| 814 | state, | 699 | ); |
| 815 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), | 700 | }), |
| 816 | ); | ||
| 817 | }, | ||
| 818 | undefined, | ||
| 819 | true, | ||
| 820 | ), | ||
| 821 | clearTerminalUiState: (threadRef) => | 701 | clearTerminalUiState: (threadRef) => |
| 822 | set((state) => { | 702 | set((state) => { |
| 823 | const threadKey = terminalThreadKey(threadRef); | 703 | const threadKey = terminalThreadKey(threadRef); |
| ... | @@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 828 | ); | 708 | ); |
| 829 | const hadSuppressedTerminalIds = | 709 | const hadSuppressedTerminalIds = |
| 830 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; | 710 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 831 | const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; | ||
| 832 | if ( | 711 | if ( |
| 833 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && | 712 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 834 | !hadSuppressedTerminalIds && | 713 | !hadSuppressedTerminalIds |
| 835 | !hadCustomLabels | ||
| 836 | ) { | 714 | ) { |
| 837 | return state; | 715 | return state; |
| 838 | } | 716 | } |
| 839 | return { | 717 | return { |
| 840 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 718 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 841 | terminalCustomLabelsByThreadKey: removeRecordEntry( | ||
| 842 | state.terminalCustomLabelsByThreadKey, | ||
| 843 | threadKey, | ||
| 844 | ), | ||
| 845 | suppressedTerminalIdsByThreadKey: removeRecordEntry( | 719 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 846 | state.suppressedTerminalIdsByThreadKey, | 720 | state.suppressedTerminalIdsByThreadKey, |
| 847 | threadKey, | 721 | threadKey, |
| ... | @@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 854 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; | 728 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; |
| 855 | const hadSuppressedTerminalIds = | 729 | const hadSuppressedTerminalIds = |
| 856 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; | 730 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 857 | const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; | 731 | if (!hadTerminalUiState && !hadSuppressedTerminalIds) { |
| 858 | if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { | ||
| 859 | return state; | 732 | return state; |
| 860 | } | 733 | } |
| 861 | return { | 734 | return { |
| ... | @@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 863 | state.terminalUiStateByThreadKey, | 736 | state.terminalUiStateByThreadKey, |
| 864 | threadKey, | 737 | threadKey, |
| 865 | ), | 738 | ), |
| 866 | terminalCustomLabelsByThreadKey: removeRecordEntry( | ||
| 867 | state.terminalCustomLabelsByThreadKey, | ||
| 868 | threadKey, | ||
| 869 | ), | ||
| 870 | suppressedTerminalIdsByThreadKey: removeRecordEntry( | 739 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 871 | state.suppressedTerminalIdsByThreadKey, | 740 | state.suppressedTerminalIdsByThreadKey, |
| 872 | threadKey, | 741 | threadKey, |
| ... | @@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 878 | const orphanedIds = new Set( | 747 | const orphanedIds = new Set( |
| 879 | [ | 748 | [ |
| 880 | ...Object.keys(state.terminalUiStateByThreadKey), | 749 | ...Object.keys(state.terminalUiStateByThreadKey), |
| 881 | ...Object.keys(state.terminalCustomLabelsByThreadKey), | ||
| 882 | ...Object.keys(state.suppressedTerminalIdsByThreadKey), | 750 | ...Object.keys(state.suppressedTerminalIdsByThreadKey), |
| 883 | ].filter((key) => !activeThreadKeys.has(key)), | 751 | ].filter((key) => !activeThreadKeys.has(key)), |
| 884 | ); | 752 | ); |
| ... | @@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 889 | const nextSuppressedTerminalIdsByThreadKey = { | 757 | const nextSuppressedTerminalIdsByThreadKey = { |
| 890 | ...state.suppressedTerminalIdsByThreadKey, | 758 | ...state.suppressedTerminalIdsByThreadKey, |
| 891 | }; | 759 | }; |
| 892 | const nextTerminalCustomLabelsByThreadKey = { | ||
| 893 | ...state.terminalCustomLabelsByThreadKey, | ||
| 894 | }; | ||
| 895 | for (const id of orphanedIds) { | 760 | for (const id of orphanedIds) { |
| 896 | delete nextTerminalUiStateByThreadKey[id]; | 761 | delete nextTerminalUiStateByThreadKey[id]; |
| 897 | delete nextTerminalCustomLabelsByThreadKey[id]; | ||
| 898 | delete nextSuppressedTerminalIdsByThreadKey[id]; | 762 | delete nextSuppressedTerminalIdsByThreadKey[id]; |
| 899 | } | 763 | } |
| 900 | return { | 764 | return { |
| 901 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 765 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 902 | terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, | ||
| 903 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, | 766 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 904 | }; | 767 | }; |
| 905 | }), | 768 | }), |
| ... | @@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 907 | }, | 770 | }, |
| 908 | { | 771 | { |
| 909 | name: TERMINAL_UI_STATE_STORAGE_KEY, | 772 | name: TERMINAL_UI_STATE_STORAGE_KEY, |
| 910 | version: 5, | 773 | version: 4, |
| 911 | storage: createJSONStorage(createTerminalUiStateStorage), | 774 | storage: createJSONStorage(createTerminalUiStateStorage), |
| 912 | migrate: migratePersistedTerminalUiStateStoreState, | 775 | migrate: migratePersistedTerminalUiStateStoreState, |
| 913 | partialize: (state) => ({ | 776 | partialize: (state) => ({ |
| 914 | terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, | 777 | terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, |
| 915 | terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, | ||
| 916 | }), | 778 | }), |
| 917 | }, | 779 | }, |
| 918 | ), | 780 | ), |
packages/contracts/src/ipc.ts-4| ... | @@ -111,8 +111,6 @@ export interface ContextMenuItem<T extends string = string> { | ... | @@ -111,8 +111,6 @@ export interface ContextMenuItem<T extends string = string> { |
| 111 | header?: boolean; | 111 | header?: boolean; |
| 112 | /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ | 112 | /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ |
| 113 | icon?: string; | 113 | icon?: string; |
| 114 | /** Inserts a visual section divider immediately before this item. */ | ||
| 115 | separatorBefore?: boolean; | ||
| 116 | children?: readonly ContextMenuItem<T>[]; | 114 | children?: readonly ContextMenuItem<T>[]; |
| 117 | } | 115 | } |
| 118 | 116 | ||
| ... | @@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType { | ... | @@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType { |
| 123 | readonly disabled?: boolean; | 121 | readonly disabled?: boolean; |
| 124 | readonly header?: boolean; | 122 | readonly header?: boolean; |
| 125 | readonly icon?: string; | 123 | readonly icon?: string; |
| 126 | readonly separatorBefore?: boolean; | ||
| 127 | readonly children?: readonly ContextMenuItemSchemaType[]; | 124 | readonly children?: readonly ContextMenuItemSchemaType[]; |
| 128 | } | 125 | } |
| 129 | 126 | ||
| ... | @@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc | ... | @@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc |
| 134 | disabled: Schema.optionalKey(Schema.Boolean), | 131 | disabled: Schema.optionalKey(Schema.Boolean), |
| 135 | header: Schema.optionalKey(Schema.Boolean), | 132 | header: Schema.optionalKey(Schema.Boolean), |
| 136 | icon: Schema.optionalKey(Schema.String), | 133 | icon: Schema.optionalKey(Schema.String), |
| 137 | separatorBefore: Schema.optionalKey(Schema.Boolean), | ||
| 138 | children: Schema.optionalKey( | 134 | children: Schema.optionalKey( |
| 139 | Schema.Array( | 135 | Schema.Array( |
| 140 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), | 136 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), |
packages/shared/src/usageMerge.test.ts-1| ... | @@ -248,7 +248,6 @@ describe("mergeUsage", () => { | ... | @@ -248,7 +248,6 @@ describe("mergeUsage", () => { |
| 248 | ); | 248 | ); |
| 249 | 249 | ||
| 250 | expect(merged.sessions).toBe(1); | 250 | expect(merged.sessions).toBe(1); |
| 251 | expect(merged.providers[0]?.sessions).toBe(1); | ||
| 252 | }); | 251 | }); |
| 253 | 252 | ||
| 254 | it("returns empty totals with no environments", () => { | 253 | it("returns empty totals with no environments", () => { |
packages/shared/src/usageMerge.ts+11-29| ... | @@ -25,7 +25,6 @@ export interface ProviderTotals { | ... | @@ -25,7 +25,6 @@ export interface ProviderTotals { |
| 25 | readonly costUsd: number; | 25 | readonly costUsd: number; |
| 26 | readonly totalTokens: number; | 26 | readonly totalTokens: number; |
| 27 | readonly records: number; | 27 | readonly records: number; |
| 28 | readonly sessions: number; | ||
| 29 | readonly costShare: number; | 28 | readonly costShare: number; |
| 30 | readonly tokenShare: number; | 29 | readonly tokenShare: number; |
| 31 | } | 30 | } |
| ... | @@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): { | ... | @@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): { |
| 136 | function ownedContribution( | 135 | function ownedContribution( |
| 137 | environment: EnvironmentUsage, | 136 | environment: EnvironmentUsage, |
| 138 | ownerByFingerprint: ReadonlyMap<string, EnvironmentId>, | 137 | ownerByFingerprint: ReadonlyMap<string, EnvironmentId>, |
| 139 | ): { | 138 | ): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { |
| 140 | readonly buckets: readonly UsageBucket[]; | ||
| 141 | readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>; | ||
| 142 | } { | ||
| 143 | const ownedProviders = new Set<UsageProviderKind>(); | 139 | const ownedProviders = new Set<UsageProviderKind>(); |
| 144 | const sessionsByProvider = new Map<UsageProviderKind, number>(); | 140 | let sessions = 0; |
| 145 | for (const source of environment.summary.sources) { | 141 | for (const source of environment.summary.sources) { |
| 146 | if (source.status === "missing") continue; | 142 | if (source.status === "missing") continue; |
| 147 | const key = fingerprintKey(source.fingerprint); | 143 | const key = fingerprintKey(source.fingerprint); |
| 148 | if (ownerByFingerprint.get(key) === environment.environmentId) { | 144 | if (ownerByFingerprint.get(key) === environment.environmentId) { |
| 149 | const provider = source.fingerprint.provider; | 145 | ownedProviders.add(source.fingerprint.provider); |
| 150 | ownedProviders.add(provider); | ||
| 151 | // Distinct within a directory. Summing per-bucket session counts instead | 146 | // Distinct within a directory. Summing per-bucket session counts instead |
| 152 | // would count a session once per day and model it spans. | 147 | // would count a session once per day and model it spans. |
| 153 | sessionsByProvider.set( | 148 | sessions += source.distinctSessions; |
| 154 | provider, | ||
| 155 | (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, | ||
| 156 | ); | ||
| 157 | } | 149 | } |
| 158 | } | 150 | } |
| 159 | return { | 151 | return { |
| 160 | buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), | 152 | buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), |
| 161 | sessionsByProvider, | 153 | sessions, |
| 162 | }; | 154 | }; |
| 163 | } | 155 | } |
| 164 | 156 | ||
| ... | @@ -236,7 +228,7 @@ export function mergeUsage( | ... | @@ -236,7 +228,7 @@ export function mergeUsage( |
| 236 | 228 | ||
| 237 | const providerAccumulator = new Map< | 229 | const providerAccumulator = new Map< |
| 238 | UsageProviderKind, | 230 | UsageProviderKind, |
| 239 | { costUsd: number; totalTokens: number; records: number; sessions: number } | 231 | { costUsd: number; totalTokens: number; records: number } |
| 240 | >(); | 232 | >(); |
| 241 | const modelAccumulator = new Map< | 233 | const modelAccumulator = new Map< |
| 242 | string, | 234 | string, |
| ... | @@ -263,20 +255,12 @@ export function mergeUsage( | ... | @@ -263,20 +255,12 @@ export function mergeUsage( |
| 263 | const contributingEnvironments: EnvironmentId[] = []; | 255 | const contributingEnvironments: EnvironmentId[] = []; |
| 264 | 256 | ||
| 265 | for (const environment of current) { | 257 | for (const environment of current) { |
| 266 | const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); | 258 | const { buckets, sessions: environmentSessions } = ownedContribution( |
| 259 | environment, | ||
| 260 | ownerByFingerprint, | ||
| 261 | ); | ||
| 267 | if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); | 262 | if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); |
| 268 | 263 | sessions += environmentSessions; | |
| 269 | for (const [providerKind, providerSessions] of sessionsByProvider) { | ||
| 270 | sessions += providerSessions; | ||
| 271 | const provider = providerAccumulator.get(providerKind) ?? { | ||
| 272 | costUsd: 0, | ||
| 273 | totalTokens: 0, | ||
| 274 | records: 0, | ||
| 275 | sessions: 0, | ||
| 276 | }; | ||
| 277 | provider.sessions += providerSessions; | ||
| 278 | providerAccumulator.set(providerKind, provider); | ||
| 279 | } | ||
| 280 | 264 | ||
| 281 | for (const bucket of buckets) { | 265 | for (const bucket of buckets) { |
| 282 | const tokens = bucketTokens(bucket); | 266 | const tokens = bucketTokens(bucket); |
| ... | @@ -296,7 +280,6 @@ export function mergeUsage( | ... | @@ -296,7 +280,6 @@ export function mergeUsage( |
| 296 | costUsd: 0, | 280 | costUsd: 0, |
| 297 | totalTokens: 0, | 281 | totalTokens: 0, |
| 298 | records: 0, | 282 | records: 0, |
| 299 | sessions: 0, | ||
| 300 | }; | 283 | }; |
| 301 | provider.costUsd += bucket.costUsd; | 284 | provider.costUsd += bucket.costUsd; |
| 302 | provider.totalTokens += tokens; | 285 | provider.totalTokens += tokens; |
| ... | @@ -358,7 +341,6 @@ export function mergeUsage( | ... | @@ -358,7 +341,6 @@ export function mergeUsage( |
| 358 | costUsd: totals.costUsd, | 341 | costUsd: totals.costUsd, |
| 359 | totalTokens: totals.totalTokens, | 342 | totalTokens: totals.totalTokens, |
| 360 | records: totals.records, | 343 | records: totals.records, |
| 361 | sessions: totals.sessions, | ||
| 362 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, | 344 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 363 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, | 345 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, |
| 364 | })) | 346 | })) |