diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1db2b26ae40ac11eb2659e5a31f4acf..e3c5d5dd6431bdc184087406e8a1bd7d1775ebdc 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,7 +98,10 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [{ id: "copy", label: "Copy" }], + items: [ + { id: "copy", label: "Copy" }, + { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, + ], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -110,6 +113,12 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); + assert.deepEqual( + buildFromTemplateMock.mock.calls[0]?.[0].map( + (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, + ), + ["Copy", "separator", "Delete"], + ); }).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c24160d9c0937a7cd09ea84f20274dda9..ca8cc246e8957566c3a194ba8ed701fea62ad756 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, + ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { @@ -141,10 +142,17 @@ export const make = Effect.gen(function* () { ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; + const appendSeparator = () => { + if (template.length === 0 || template.at(-1)?.type === "separator") return; + template.push({ type: "separator" }); + }; for (const item of entries) { + if (item.separatorBefore) { + appendSeparator(); + } if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); + appendSeparator(); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b622683fc478e6a6c2616a7c76607d4541..047e40ccf49021423d8d91ef7b18ca9941a6f99b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload agent-field survival", () => { +describe("projectActivityPayload", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "claude-call-1", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + result: { content: "x".repeat(5_000) }, + }, + }), + ); + const openCode = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "opencode-call-1", + data: { + tool: "bash", + state: { + status: "running", + input: { command: "vp lint" }, + output: "x".repeat(5_000), + }, + }, + }), + ); + + expect(claude.payload).toMatchObject({ + toolCallId: "claude-call-1", + data: { command: "vp test run" }, + }); + expect(openCode.payload).toMatchObject({ + toolCallId: "opencode-call-1", + data: { command: "vp lint" }, + }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9b94b52a8bcf8daef9db6963e13f56..659760c049a47d1519c37331e4239551e4c665fb 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,6 +104,24 @@ function projectCommandData(data: Record): Record 0 ? projectedItem : undefined; } +function projectCommandValue(data: Record): unknown { + if (data.command !== undefined) { + return data.command; + } + + const input = asRecord(data.input); + if (input?.command !== undefined) { + return input.command; + } + + const stateInput = asRecord(asRecord(data.state)?.input); + if (stateInput?.command !== undefined) { + return stateInput.command; + } + + return undefined; +} + function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -287,8 +305,9 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; + const command = projectCommandValue(data); + if (command !== undefined) { + projectedData.command = command; } const changedFiles: string[] = []; @@ -368,10 +387,10 @@ function dropStaleContextWindowActivities( /** * Identity both clients use to fold a tool lifecycle row into the call it * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter - * emits one, otherwise the itemType/title/detail triple. Returns null for rows - * with no identity at all — those never collapse on the client either, so they - * must not be dropped here. + * mobile's `threadActivity`): the runtime item id ingestion stamps as + * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. + * Returns null for rows with no identity at all — those never collapse on the + * client either, so they must not be dropped here. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = + asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6f4833282a96ac4c4b9ebff9f73d9..b5feda5052d8cfdadc7f20d0bdd4d724eed3f2bf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), + itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "in_progress", - title: "Read file", - detail: "/tmp/file.ts", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, }, }); @@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - expect( - thread.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", - ), - ).toBe(true); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", + ); + const payload = activity?.payload as Record | undefined; + expect(payload).toMatchObject({ + itemType: "command_execution", + toolCallId: "tool-call-9", + status: "inProgress", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242eca3061dcd60db5167470ed280640..1eb7e54b3b3660af5bf376d292c26bc6c6f1277b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,6 +794,7 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -821,6 +822,8 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -847,7 +850,10 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c91be92458518361ee5606ff50c6fd..e7193a7d0ffd54ef92baa658732b021f82ee715f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,7 +167,6 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -220,7 +219,11 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + selectThreadTerminalCustomLabels, + selectThreadTerminalUiState, + useTerminalUiStateStore, +} from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, @@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; + onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, + onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} + onHide={onHide} splitShortcutLabel={visible ? splitShortcutLabel : undefined} splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} @@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); + const activeTerminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), + ); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + activeTerminalCustomLabels[session.target.terminalId] ?? + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } + for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { + if (!labels.has(terminalId)) labels.set(terminalId, label); + } return labels; - }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => - activeThreadEnvironmentId && activeThreadId - ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) - : null, - [activeThreadEnvironmentId, activeThreadId], - ); + }, [activeTerminalCustomLabels, activeThreadKnownSessions]); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); + const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } - chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} -
{!rightPanelOpen ? panelLayoutControls : null} -
+ ))} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 82dddd8f41e0e45b2390031b6c3661f959f9f7e7..cfc40f93638b9f304513fd3a95d5cbec136cd19f 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,26 +1,15 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; export function NoActiveThreadState() { return (
-
+ {isElectron ? ( - - No active thread - + No active thread ) : (
@@ -28,7 +17,7 @@ export function NoActiveThreadState() {
)} -
+
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df09da7f7470de63c816165d9de52fd4..f43bd5ea629b86d162cdbb7d457194a5b8af22ed 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, + hasProjectScope = false, ): boolean { - return shiftKey || projectGroupCount <= 1; + return hasProjectScope || shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a22140559c6883c9f7c15a4e58a1f3b0aff..010571b915df1e14424053c3bff9187a0be04a8a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -366,26 +367,20 @@ function SnoozePopoverButton(props: { ); return ( - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - 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" - /> - } - /> - } - > - - - Snooze thread - + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + } + > + + {presets.map((preset) => ( ) : ( @@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } + + {props.isPinned ? ( + props.pinningSupported ? ( + - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) + + ) : ( - threadTimeLabel(thread) + + + + ) + ) : null} + {/* Only the visible state owns this slot's width: the pin stays + directly beside the idle status and beside the first action + when the hover controls replace it. */} + - {props.settlementSupported || showSnoozeButton ? ( + > + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - + + {topStatus.label} + + ) : ( + + {topStatus.icon === "working" ? ( + - } + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + + ) : null} +
@@ -3218,17 +3211,25 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. + // A selected project scope owns creation: users should not have to choose + // the same project twice. "All projects" keeps the picker in multi-project + // setups, while Shift+click retains the direct-create shortcut. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - // One project: nothing to pick, create immediately. Shift+click creates - // directly in the current project even with several projects, skipping - // the palette picker. - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + projectGroups.length, + scopedProjectGroup !== null, + ) + ) { if (isMobile) setOpenMobile(false); + if (scopedProjectGroup) { + void newThreadContext.handleNewThread( + scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), + ); + return; + } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3240,20 +3241,19 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], ); - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. In multi-project setups the label is only - // the picker's shortcut: falling back to chat.newLocal would advertise the - // same shortcut for both the picker and direct create. In single-project - // setups both commands create directly, so chat.newLocal is a valid - // fallback. The second tooltip line (multi-project only) advertises - // shift+click and its keyboard twin chat.newLocal for direct create. + // With no explicit scope the button mirrors chat.new. A scoped button has + // intentionally more specific behavior, so it does not advertise the + // broader command's shortcut. const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + scopedProjectGroup === null + ? (shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined)) + : undefined; const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,7 +3332,9 @@ export default function Sidebar() { /> - {projectGroups.length > 1 ? ( + {scopedProjectGroup ? ( + `New thread in ${scopedProjectGroup.displayName}` + ) : projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e949ea790cc8846b96bb063b4537daa..deec13ec3bdaaf9b672d9126aa64d0de487ee067 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { + PanelBottomCloseIcon, Plus, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, - Trash2, XIcon, } from "lucide-react"; import { @@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, - type ReactNode, type SetStateAction, useCallback, useEffect, @@ -30,9 +29,9 @@ import { useRef, useState, } from "react"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -60,6 +59,7 @@ import { import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -72,10 +72,15 @@ import { resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../appearanceFonts"; +import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; +const TERMINAL_SIDEBAR_MIN_WIDTH = 144; +const TERMINAL_SIDEBAR_MAX_WIDTH = 320; +const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +export function shouldShowTerminalSidebar(terminalCount: number): boolean { + return terminalCount > 1; +} + export function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number }; @@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; + onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } -interface TerminalActionButtonProps { - label: string; - className: string; - onClick: () => void; - children: ReactNode; -} - -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { - return ( - - } - > - {children} - - - {label} - - - ); -} - export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, + onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = + useResizableWidth({ + storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, + defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, + minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, + maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, + edge: "left", + }); + const terminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), + ); + const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); + const [renamingTerminalId, setRenamingTerminalId] = useState(null); + const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); + const cancelTerminalRenameRef = useRef(false); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); const isSplitView = visibleTerminalIds.length > 1; - const showGroupHeaders = - resolvedTerminalGroups.length > 1 || - resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const terminalLabelById = useMemo(() => { + const automaticTerminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const terminalLabelById = useMemo(() => { + const next = new Map(); + for (const terminalId of normalizedTerminalIds) { + next.set( + terminalId, + terminalCustomLabels[terminalId]?.trim() || + automaticTerminalLabelById.get(terminalId) || + getTerminalLabel(terminalId), + ); + } + return next; + }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({ }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; @@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - const onNewTerminalAction = useCallback(() => { - onNewTerminal(); - }, [onNewTerminal]); + const startTerminalRename = useCallback( + (terminalId: string) => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(terminalId); + setTerminalRenameDraft( + terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", + ); + }, + [terminalCustomLabels, terminalLabelById], + ); + const finishTerminalRename = useCallback(() => { + if (!renamingTerminalId) return; + const nextLabel = terminalRenameDraft.trim(); + const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; + setTerminalCustomLabel( + threadRef, + renamingTerminalId, + nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, + ); + setRenamingTerminalId(null); + }, [ + automaticTerminalLabelById, + renamingTerminalId, + setTerminalCustomLabel, + terminalRenameDraft, + threadRef, + ]); + const cancelTerminalRename = useCallback(() => { + cancelTerminalRenameRef.current = true; + setRenamingTerminalId(null); + }, []); + + useEffect(() => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(null); + setTerminalRenameDraft(""); + }, [threadRef.environmentId, threadRef.threadId]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - + const compactTerminalToolbar = ( + <> + + + + + {!isPanel && onHide ? ( + <> + + + + ) : null} + + ); return (
); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4613dd465b1c04033ce3e8b0a1e183d20e165cc5 --- /dev/null +++ b/apps/web/src/components/WorkspacePageContainer.tsx @@ -0,0 +1,62 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; + +export type WorkspacePageWidth = "readable" | "wide" | "expanded"; + +const WIDTH_CLASS: Record = { + readable: "max-w-4xl", + wide: "max-w-5xl", + expanded: "max-w-6xl", +}; + +/** Shared full-page frame for workspace routes beneath their top bar. */ +export function WorkspacePageContainer({ + width = "readable", + className, + ...props +}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { + return ( +
+ ); +} + +/** Shared top-bar geometry for every full-width workspace surface. */ +export function WorkspacePageHeader({ + electron = false, + reserveNativeControls = electron, + className, + ...props +}: ComponentPropsWithoutRef<"header"> & { + readonly electron?: boolean; + readonly reserveNativeControls?: boolean; +}) { + return ( +
+ ); +} + +/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */ +export function WorkspacePageHeaderEdgeControl({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return
; +} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index e9fa1895bf99f4ad85107ab32c347948642deafd..bc3c4fa80dfc17d3a307cc474fb32d8837d6a0e8 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,13 +23,9 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain( - 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', - ); + expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); - expect(markup).toContain("@[24rem]/changed-files:inline"); - expect(markup).not.toContain("sm:inline"); + expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); + expect(markup).not.toContain("Hide files"); + expect(markup).not.toContain("ml-auto"); }); - it("renders a scope and representative-file preview for a large latest change", () => { + it("renders a clean representative-file preview for a large latest change", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-changed-files-state="preview"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps"); - expect(markup).toContain("2 files"); - expect(markup).toContain("packages"); - expect(markup).toContain("root"); + expect(markup).toContain("apps/web/src/"); + expect(markup).toContain("packages/shared/src/"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("basis-0"); + expect(markup).not.toContain("+1 more"); + expect(markup).not.toContain("Show files"); + expect(markup).toContain('aria-label="120 additions, 20 deletions"'); expect(markup).not.toContain("App.test.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index d29d8b7f2f44d2f0ceac0c113f9355b76d1a9fba..a8bb461c0e12ad2d44c4f775a7be3831d117146f 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - changedFileName, - selectChangedFilePreview, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; +import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); - const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); const compactPreviewVisible = showCompactPreview && !expanded; return (
onExpandedChange(!expanded)} > )} - - {expanded ? "Hide files" : "Show files"} -
{expanded ? ( @@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} /> ) : compactPreviewVisible ? ( -
-

- {scopeSummary.map((scope, index) => ( - - {index > 0 ? : null} - {scope.label} - - {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} - - - ))} -

-
+
+
{previewFiles.map((file) => ( ))} -
) : null} @@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1cae3c94aeeae8afbe8b7187191318e..82338dec2a8948ca4fbd607ce2629e3957da87fa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", - "turn-fold:turn-1", "assistant-thought-entry", - "work-entry-1", + "work-toggle:work-entry-1", + "turn-fold:turn-1", "assistant-final-entry", ]); expect( @@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); + it("keeps a superseded turn fold beside the final response after a steer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "initial-user" as never, + role: "user", + text: "Start the work", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "superseded-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:10Z", + entry: { + id: "superseded-work", + createdAt: "2026-01-01T00:00:10Z", + turnId: "turn-1" as never, + label: "Ran command", + tone: "tool", + }, + }, + { + id: "steer-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "steer-user" as never, + role: "user", + text: "Change the approach", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Implemented locally, uncommitted.", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:20Z", + updatedAt: "2026-01-01T00:00:21Z", + streaming: false, + }, + }, + ], + latestTurn: { + turnId: "turn-2" as never, + state: "completed", + startedAt: "2026-01-01T00:00:12Z", + completedAt: "2026-01-01T00:00:21Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "steer-user-entry", + "turn-fold:turn-1", + "assistant-final-entry", + ]); + }); + it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", "assistant-thought-entry", - "work-entry-1", + "work-live:work-entry-1", + ]); + }); + + it("keeps the current tool batch expandable while live entries append", () => { + const timelineEntries = [ + { + id: "work-entry-1", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-1", + label: "Read file", + tone: "tool" as const, + }, + }, + { + id: "work-entry-2", + kind: "work" as const, + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + toolCallId: "call-2", + label: "Run command", + command: "vp test run", + tone: "tool" as const, + }, + }, + ]; + const baseInput = { + timelineEntries, + latestTurn: { + turnId: "turn-1" as never, + state: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + const collapsedRows = deriveMessagesTimelineRows(baseInput); + const expandedRows = deriveMessagesTimelineRows({ + ...baseInput, + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(collapsedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + ]); + expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: false, + groupedEntries: [{ id: "work-1" }, { id: "work-2" }], + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + ]); + expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: true, + }); + + const appendedRows = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "work-entry-3", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "work-3", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + toolCallId: "call-3", + label: "Changed file", + tone: "tool" as const, + }, + }, + ], + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(appendedRows.map((row) => row.id)).toEqual([ "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + "work-3", ]); + + const rowsWithLaterPlan = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "plan:thread-1:turn:turn-1", + kind: "proposed-plan" as const, + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan:thread-1:turn:turn-1", + turnId: "turn-1" as never, + planMarkdown: "# Next steps", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + }); + expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); + expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("running-work-entry"); + expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 2, + hiddenCount: 3, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ + "work-toggle:work-entry-1", "work-1", "work-2", "work-3", - "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203c355149d22ede43223f64a90bb5f9..8d7fc52fdca6c14b3f977ac12c926fb03ecccbba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import * as Equal from "effect/Equal"; import { formatDuration, + workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -166,6 +167,17 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + isExpandedToolGroupEntry: boolean; + isLastExpandedToolGroupEntry: boolean; + } + | { + kind: "work-live"; + id: string; + createdAt: string; + entry: WorkLogEntry; + groupedEntries: WorkLogEntry[]; + groupId: string; + expanded: boolean; } | { kind: "work-toggle"; @@ -175,6 +187,9 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; + summary: string | null; + summaryKind: ToolGroupAction | "mixed" | null; + hasFailure: boolean; } | { kind: "turn-fold"; @@ -208,7 +223,12 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { kind: "working"; id: string; createdAt: string | null }; + | { + kind: "working"; + id: string; + createdAt: string | null; + showThinking: boolean; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; + +function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { + if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read"; + if ( + entry.requestKind === "file-change" || + entry.itemType === "file_change" || + (entry.changedFiles?.length ?? 0) > 0 + ) { + return "edit"; + } + if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (entry.itemType === "web_search") return "search"; + return "other"; +} + +function toolGroupActionCount( + action: ToolGroupAction, + entries: ReadonlyArray, +): number { + if (action !== "edit") return entries.length; + + const changedFiles = new Set(); + let editsWithoutFileDetails = 0; + for (const entry of entries) { + if (!entry.changedFiles || entry.changedFiles.length === 0) { + editsWithoutFileDetails += 1; + continue; + } + for (const file of entry.changedFiles) changedFiles.add(file); + } + return changedFiles.size + editsWithoutFileDetails; +} + +function toolGroupActionLabel(action: ToolGroupAction, count: number): string { + switch (action) { + case "read": + return `Read ${count} ${count === 1 ? "file" : "files"}`; + case "edit": + return `Changed ${count} ${count === 1 ? "file" : "files"}`; + case "command": + return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "search": + return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; + case "other": + return `Used ${count} ${count === 1 ? "tool" : "tools"}`; + } +} + +/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ +export function summarizeToolGroup(entries: ReadonlyArray): string { + const groupedEntries = new Map(); + for (const entry of entries) { + const action = toolGroupAction(entry); + const group = groupedEntries.get(action); + if (group) group.push(entry); + else groupedEntries.set(action, [entry]); + } + const labels = [...groupedEntries].map(([action, actionEntries]) => + toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), + ); + const sentenceLabels = labels.map((label, index) => + index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), + ); + if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; + if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); + return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; +} + +function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupAction | "mixed" { + const actions = new Set(entries.map(toolGroupAction)); + return actions.size === 1 ? actions.values().next().value! : "mixed"; +} + +function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { + return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; +} + +function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { + return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -310,17 +414,34 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } +function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { + return timelineEntries.findLastIndex( + (entry) => entry.kind === "message" && entry.message.role === "user", + ); +} + +function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") { + return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; + } + if (entry.kind === "turn-plan") { + return entry.turnPlan.turnId; + } + return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; +} + /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row anchored at the turn's first foldable entry; the - * terminal assistant message stays visible below the fold. + * "Worked for ..." row placed immediately before the next terminal assistant + * response. A steer can split one visible response across turn ids, so tying + * the disclosure to the first hidden entry would strand it above the steer. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap { +}): ReadonlyMap> { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -375,7 +496,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -405,6 +526,24 @@ function deriveTurnFolds(input: { if (!firstEntry || !lastEntry) { continue; } + const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => + hiddenEntryIds.has(entry.id), + ); + if (lastHiddenEntryIndex < 0) { + continue; + } + const nextTerminalAssistantEntry = input.timelineEntries + .slice(lastHiddenEntryIndex + 1) + .find( + (entry) => + entry.kind === "message" && + entry.message.role === "assistant" && + input.terminalAssistantMessageIds.has(entry.message.id), + ); + const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; + if (!anchorEntry) { + continue; + } const isLatestInterruptedTurn = input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; @@ -431,13 +570,16 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstEntry.id, { + const fold = { turnId, - anchorEntryId: firstEntry.id, - createdAt: firstEntry.createdAt, + anchorEntryId: anchorEntry.id, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, - }); + }; + const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); + if (anchoredFolds) anchoredFolds.push(fold); + else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); } return foldsByAnchorEntryId; } @@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); + for (const folds of foldsByAnchorEntryId.values()) { + for (const fold of folds) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } } } } + let activeTurnHeaderIndex = input.timelineEntries.length; + if (input.isWorking) { + const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); + const firstOwnedAfterUser = + unsettledTurnId === null + ? -1 + : input.timelineEntries.findIndex( + (entry, index) => + index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, + ); + activeTurnHeaderIndex = + firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; + } + const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => + input.isWorking && + index >= activeTurnHeaderIndex && + (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); + const isVisibleActiveToolEntry = (entry: WorkLogEntry) => + workLogEntryIsToolLike(entry) && + (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); + const activeEntries = input.isWorking + ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) + : []; + const activeTurnHasVisibleContent = + activeEntries.some((entry) => { + if (entry.kind === "message") { + return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; + } + if (entry.kind === "work") { + return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); + } + if (entry.kind === "turn-plan") return true; + return false; + }) || + input.timelineEntries + .slice(activeTurnHeaderIndex) + .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); + + const activeWorkEntryIds = new Set(); + const activeWorkRowsByAnchorId = new Map< + string, + Extract + >(); + const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); + for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { + const entry = input.timelineEntries[index]; + if (!entry) continue; + const isVisibleTurnContent = + (entry.kind === "message" && entry.message.role === "user") || + entry.kind === "proposed-plan" || + (entryBelongsToActiveTurn(entry, index) && + ((entry.kind === "message" && entry.message.role === "assistant") || + entry.kind === "turn-plan" || + (entry.kind === "work" && + entry.entry.agentSpawn === undefined && + isVisibleActiveToolEntry(entry.entry)))); + hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; + } + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entry = input.timelineEntries[index]; + if ( + !entry || + entry.kind !== "work" || + entry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(entry, index) + ) { + continue; + } + if (!isVisibleActiveToolEntry(entry.entry)) { + continue; + } + + const anchorEntry = entry; + let latestToolEntry = entry; + const batchEntryIds = [entry.id]; + const visibleBatchEntries = [entry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if ( + !nextEntry || + nextEntry.kind !== "work" || + nextEntry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(nextEntry, cursor) + ) { + break; + } + batchEntryIds.push(nextEntry.id); + if (isVisibleActiveToolEntry(nextEntry.entry)) { + latestToolEntry = nextEntry; + visibleBatchEntries.push(nextEntry.entry); + } + cursor += 1; + } + + // Once newer commentary, a plan, or another tool batch exists, this batch + // is history. Let the regular work-group path turn it into an expandable + // summary so none of its calls disappear behind the live one-line view. + if (hasLaterTurnContent[cursor] !== true) { + for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); + const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); + activeWorkRowsByAnchorId.set(anchorEntry.id, { + kind: "work-live", + id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, + createdAt: anchorEntry.createdAt, + entry: latestToolEntry.entry, + groupedEntries: visibleBatchEntries, + groupId, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + }); + } + index = cursor - 1; + } + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); - if (turnFold) { + if (input.isWorking && index === activeTurnHeaderIndex) { nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } + const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); + if (anchoredTurnFolds) { + for (const turnFold of anchoredTurnFolds) { + nextRows.push({ + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + }); + } + } + if (collapsedEntryIds.has(timelineEntry.id)) { continue; } + if (activeWorkEntryIds.has(timelineEntry.id)) { + const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); + if (activeWorkRow) { + nextRows.push(activeWorkRow); + if (activeWorkRow.expanded) { + for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, + }); + } + } + } + continue; + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || + activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + const onlyToolEntries = visibleGroupedEntries.every( + (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, + ); + if (onlyToolEntries) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); + nextRows.push({ + kind: "work-toggle", + id: `work-toggle:${timelineEntry.id}`, + createdAt: timelineEntry.createdAt, + groupId, + hiddenCount: visibleGroupedEntries.length, + expanded, + onlyToolEntries: true, + summary: summarizeToolGroup(visibleGroupedEntries), + summaryKind, + hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), + }); + if (expanded) { + for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, + }); + } + } + } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } else { - const groupId = `work-group:${timelineEntry.id}`; + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } @@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries: visibleGroupedEntries.every((entry) => - workLogEntryIsToolLike(entry), + onlyToolEntries, + summary: null, + summaryKind: null, + hasFailure: visibleGroupedEntries.some((entry) => + workEntryIndicatesToolFailure(entry), ), }); } @@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking) { + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } @@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking + ); case "turn-fold": { const bf = b as typeof a; @@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": - return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); + case "work": { + const bw = b as typeof a; + return ( + a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && + a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } + + case "work-live": { + const bw = b as typeof a; + return ( + a.createdAt === bw.createdAt && + a.groupId === bw.groupId && + a.expanded === bw.expanded && + Equal.equals(a.entry, bw.entry) && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } case "work-toggle": { const bw = b as typeof a; @@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries + a.onlyToolEntries === bw.onlyToolEntries && + a.summary === bw.summary && + a.summaryKind === bw.summaryKind && + a.hasFailure === bw.hasFailure ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5bb28b27ce98642964efca8f883c1ce..3dcf6cf2a3025db26b2b365021be70debba90937 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,7 +554,49 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("formats changed file paths from the workspace root", () => { + it("makes the whole live tool row expandable without adding a chevron", () => { + const turnId = TurnId.make("turn-live-tools"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('aria-label="Expand current tool calls"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("Running psql"); + expect(markup).not.toContain("lucide-chevron-right"); + expect(markup).not.toContain("hover:bg-accent/20"); + }); + + it("summarizes completed changed-file activity", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("Changed 1 file"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b2afd5b817310af012cc5a11143fb1..3ccd4808d064a4d1522f697286de8ae841fce4c3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,7 +35,6 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -57,7 +56,6 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; + const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; + const isExpandedToolGroupHeader = + (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || + (row.kind === "work-live" && row.expanded); + return (
- {row.kind === "work" ? : null} + {row.kind === "work" ? ( + + ) : null} + {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - 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" + 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" > {row.label} - +
); @@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
- - - - - - +
+
+
{row.createdAt ? ( <> Working for @@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} +
+ {row.showThinking ? ( +
+ +
+ ) : null}
); } @@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, + isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; + isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} + isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} + +function ThinkingActivityRow() { + return ; +} + +function LiveActivityContent({ + label, + iconName, + highlighted = false, +}: { + label: string; + iconName: WorkEntryIconName | undefined; + highlighted?: boolean; +}) { + return ( +
+ {iconName ? ( + + + + ) : null} + {label} +
+ ); +} + +function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + + return ( + + ); +} + +function toolGroupSummaryIconName( + kind: Extract["summaryKind"], +): WorkEntryIconName { + switch (kind) { + case "read": + return "eye"; + case "edit": + return "square-pen"; + case "command": + return "terminal"; + case "search": + return "globe"; + case "other": + return "wrench"; + case "mixed": + case null: + return "hammer"; + } +} + function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); + if (row.onlyToolEntries && row.summary) { + return ( + + ); + } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -2019,32 +2159,101 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -function workEntryRawCommand( - workEntry: Pick, -): string | null { - const rawCommand = workEntry.rawCommand?.trim(); - if (!rawCommand || !workEntry.command) { - return null; +type CommandWrapper = "env" | "sudo"; + +const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { + env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), + sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), +}; + +const COMMAND_WRAPPER_FLAGS: Record> = { + env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]), + sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), +}; + +function commandProgramName(command: string): string | null { + const tokens = command.trim().split(/\s+/); + let index = 0; + let wrapper: CommandWrapper | null = null; + + while (index < tokens.length) { + const token = tokens[index]?.replace(/^["']|["']$/g, ""); + if (!token) return null; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + index += 1; + continue; + } + if (token === "env" || token === "sudo") { + wrapper = token; + index += 1; + continue; + } + if (wrapper !== null && token === "--") { + wrapper = null; + index += 1; + continue; + } + if (wrapper !== null && token.startsWith("-")) { + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { + index += 1; + continue; + } + if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { + let consumesNextToken = false; + for (const [optionIndex, option] of token.slice(1).split("").entries()) { + const shortOption = `-${option}`; + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { + consumesNextToken = optionIndex === token.length - 2; + break; + } + if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; + } + if (consumesNextToken && tokens[index + 1] === undefined) return null; + index += consumesNextToken ? 2 : 1; + continue; + } + return null; + } + return token.split(/[\\/]/).at(-1) || null; } - return rawCommand === workEntry.command.trim() ? null : rawCommand; + + return null; +} + +function liveWorkEntryLabel( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): string { + const command = workEntry.command?.trim(); + if (command) { + const program = commandProgramName(command); + if (program) return `Running ${program}`; + return "Running command"; + } + + return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { + const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); const blocks: string[] = []; + if (command) { + blocks.push(command); + } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); - } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + const detail = workEntry.detail?.trim(); + if (detail && detail !== command) { + blocks.push(detail); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time : "working" : failed > 0 ? `${failed} failed` - : "✓ completed"; + : "Completed"; return ( - +
+
+
+ + + {lead} + {workflowName ? ( + + {workflowName} + + ) : null} + + {status} + {totalTokens > 0 ? ( + + Σ {formatSubagentTokenCount(totalTokens)} + + ) : null} + +
+ +
+
); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ; + return ( + + ); }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; - const activity = use(TimelineRowActivityCtx); + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); + const entryIconName = + showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); + const isCommandEntry = + workEntry.requestKind === "command" || + workEntry.itemType === "command_execution" || + Boolean(workEntry.command); + const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-5 shrink-0 items-center justify-center", - showWarningIndicator + "flex size-6 shrink-0 items-center justify-center", + showWarningIndicator || showFailedIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground"; - const turnSettled = !activity.activeTurnInProgress; - const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); - const showSuccessIndicator = - workEntryIndicatesToolSuccess(workEntry) || - (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + : workLogEntryIsToolLike(workEntry) + ? "text-secondary-label" + : "text-foreground/80"; + const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- - - + {showEntryIcon ? ( + + + + ) : null}
-

- {heading} - {preview && ( - {preview} +

+ {displayText}

-
- - {canExpand ? ( - - ) : null} - - - {showFailedIndicator ? ( - - - } - > - - - Failed - - ) : showSuccessIndicator ? ( - - } - > - - - - - Completed - - ) : showNeutralIndicator ? ( - - } - > - - - Empty - - ) : null} - -
{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 6f281558ff80ff3b587a254f3643bb87a66172a5..c2fa204ffbc81e6b1e4124e68996da4ba223ced5 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,6 +1,7 @@ import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { memo } from "react"; +import { cn } from "../../lib/utils"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -12,6 +13,7 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; + rightPanelUnavailableLabel?: string; /** Running + waiting subagents in this thread; badges the right panel toggle. */ liveAgentCount: number; onToggleTerminal: () => void; @@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, + rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = + "block self-center truncate leading-none select-none"; -// The skill label is smaller than the surrounding prompt text; offset its -// glyphs without moving the pill box or changing the editor's line height. -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "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"; + "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"; export const SKILL_CHIP_ICON_SVG = ``; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3fd2bc010afba7470215022b7e6c2597..7457d04d2c9eb5412d46a5166e4ca64ec75c28cf 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,6 +25,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, + LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -50,6 +51,7 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -74,6 +77,7 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; import { Menu, MenuItem, @@ -116,6 +120,7 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { + PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -349,7 +354,6 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", - chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -381,12 +385,6 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; - /** - * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` - * folds the whole of it into the top row once the active tab scrolls, and unfolds at the - * top — the chrome spends its height on what is being read. - */ - chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -423,26 +421,13 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); - // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll - // events, so the capture handler always writes the active tab's entry — and a tab switch - // reads the destination's memory instead of inheriting the tab being left. A tab too short - // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome - // it has no scrollbar to reopen. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeVariant === "collapse" && chromeCondensed; - // Collapsing removes the fold's height from the chrome, which would otherwise hand that - // height to the scrollport and leap the content up by it mid-scroll. The cure is exact - // compensation: collapse only once the reader has scrolled at least the fold's height, - // then give that height back to `scrollTop` before the next paint — the content under - // their eyes does not move, and the collapse itself is the only thing that changes. + const condensed = chromeCondensed; const scrollerRef = useRef(null); const foldRef = useRef(null); - // The condensed chrome's second row opens as the fold closes, so the height the scrollport - // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` - // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); const compensationRef = useRef(null); useLayoutEffect(() => { @@ -463,7 +448,6 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); - // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -502,6 +486,30 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const baseBranchRefQuery = useEnvironmentQuery( + detail === null + ? null + : vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: detail.workspaceRoot, + query: detail.baseBranch, + includeMatchingRemoteRefs: true, + limit: 20, + }, + }), + ); + const matchingBaseBranchRefs = + detail === null + ? [] + : (baseBranchRefQuery.data?.refs.filter( + (refName) => + refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), + ) ?? []); + const isStackedPullRequest = + matchingBaseBranchRefs.length > 0 && + !matchingBaseBranchRefs.some((refName) => refName.isDefault); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes - // to the thing that would help instead of a Merge button that only ever says no. + // One live action holds the slot. Conflicts take priority because every other completion action + // depends on resolving them first, even for a reader who cannot merge on the host themselves. const primaryAction = detail === null || detail.state !== "open" ? null - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null - : conflicting - ? "resolve" + : conflicting + ? "resolve" + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. Conflicts keep their own row below: an open pull request remains green there. + // it. The conflict action is separate from this state: an open pull request remains green. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; const checksState = detail ? pullRequestChecksState(detail.checks) : null; + if (detailQuery.isPending && !detail) { + return ; + } + return (
- {/* The top row's geometry never changes: both of its states occupy the same stacked - cell and crossfade, so the actions on the right have one home whatever the chrome - is doing below. The fold and this fade share one 200ms clock. */}
- {/* The fixed height lives on the two top-row cells — not the grid, whose later rows - are the fold — so the actions have one immovable home in both states. */} -
+
{detail && statePresentation ? ( <> - - {detail.repository} - + {repositoryUrl ? ( + + ) : ( + + {detail.repository} + + )} - +

{detail.title} - - {conflicting ? ( - - - Conflicts - - ) : checksSummary ? ( - - {detail && checksState !== null ? ( - - ) : null} - {checksSummary} - - ) : null} +

) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "ready" ? ( + {primaryAction === "resolve" ? ( + + } + > + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + ) : primaryAction === "ready" ? ( @@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({ ) : null}
- {/* The condensed chrome's second row: the tabs that the closing fold takes with it, - and compact copies of the branch pair and diff stat so they stay in sight while - the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} -
+
{detail ? ( -
- - - {detail.baseBranch} - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" + + {detail.changedFiles.toLocaleString()} + + - ) : null} - - {detail.headBranch} - - - - - {detail.changedFiles.toLocaleString()} - - +
) : null}
- {/* Folding is a grid track going to zero: the rows below stay mounted, the track - animates closed over them, and `inert` takes the hidden controls out of the tab - order for as long as the chrome is condensed. */} -
+
{detail ? ( -
+
{titleDraft === null ? (

@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({
- - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - /> - ) : null} - - + {freshness ? ( + void perform("update-branch", undefined, method)} + /> + ) : null} + + + @@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({

) : null} +
+
- {detail && conflicting ? ( -
- + + {visibleTabs.map((item) => ( + setTab(item.value)} > - - Merge conflicts - + {item.label} + + ))} + + {tab === "summary" ? ( + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + + ) : tab === "timeline" ? ( +
+ + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commentCount.toLocaleString()} + + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commits.length.toLocaleString()} + +
) : null} - - {detail ? ( - - ) : null} -
-
+ + ) : null}
{ - if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; - // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; - // The chrome trades the fold for the condensed second row, so the height the - // scrollport actually gains is the difference between the two. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.isPending && !detail ? ( - // The ghost wears the shape of the tab being waited on, so switching tabs mid-load - // does not flash a summary outline under a timeline heading. - tab === "timeline" ? ( - - ) : tab === "code" ? ( - - ) : ( - - ) - ) : detailQuery.error && !detail ? ( + {detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 09b79cf340e63ce149a5906d84b8046de789c776..38a3ab70d6424502924e9b39d8f4f1c964cd4531 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,13 +45,11 @@ export function PullRequestListGhost({
- +
- +
))} @@ -59,32 +57,101 @@ export function PullRequestListGhost({ ); } -/** The summary's own shape: a title, a byline, the facts rows, the description. */ +/** + * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description + * boundaries in the ghost prevents the loaded pull request from replacing one layout with + * another a moment later. + */ export function PullRequestDetailGhost() { return (
-
- - +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+ + + +
+ +
-
- {Array.from({ length: 4 }, (_, index) => ( -
+ +
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ +
+
+ - -
- ))} -
-
- - - - +
+ + + + +
+
); @@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 3066eafc38a19ab34adc9aa5a682aaba01d4cf02..04fee465b5063e198253c0d74c50a98b17ce5e31 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,6 +25,7 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Button } from "../ui/button"; import { Menu, @@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({ return ( + } > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index a57f2a4d16021c7be30c985c29caeb8ea47c743e..29566e048d107264090baec9ed61251ec9424273 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -196,12 +196,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index a472c6a8d3d7bef1f6cbbfb6013562d7e68dc7af..9c36d32ff51a37f7ac09a5b10fe1f818ad452996 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + +
- {!isElectron && ( -
- -
- )} - {isElectron && ( -
- -
- )} + + +
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 174c9e9fe97c6b69e36dd853169dbacc941bfd33..1618f5045eb8fbd7b002e70104db6c80343b4c17 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { ArchiveIcon, - ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -19,7 +18,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -34,6 +33,7 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; +import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); @@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); - const handleBackClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, isMobile, navigate, setOpenMobile]); - return ( <> @@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
- - - - - Back - - - +
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 7e4b80d195114af4d8487d41d1b6a7660428840d..5399d071be9d93d8dbf9fa18bec7e6d0469c4bae 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -768,7 +768,7 @@ export function ThemeLibrary({
{STANDARD_THEME_CARDS.map((standardTheme) => ( location.hash }); @@ -247,12 +250,12 @@ export function SettingsPageContainer({ return (
-
+ {children} -
+
); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index f4a98dec86c7366b7aed6a9885baabe15c139b02..8fc6b835bf1ac53fef39d0df24e1eec6c9c5da85 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,8 +4,9 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; +import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -117,16 +118,44 @@ function T3Wordmark() { ); } -export const SidebarChromeFooter = memo(function SidebarChromeFooter() { +function SidebarUtilityItem({ + icon, + label, + onClick, +}: { + icon: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + + + {icon} + + } + /> + {label} + + + ); +} + +export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); + const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + /^\/settings(?:\/|$)/.test(location.pathname) + ? "settings" + : location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const handleBackClick = useCallback(() => { closeMobileSidebar(); + if (canGoBack) { + window.history.back(); + return; + } void navigate({ to: "/" }); - }, [closeMobileSidebar, navigate]); + }, [canGoBack, closeMobileSidebar, navigate]); + return ( + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> + } + label="Settings" + onClick={handleSettingsClick} + /> + {pullRequestsSupported ? ( + } + label="Pull Requests" + onClick={handlePullRequestsClick} + /> + ) : null} + } + label="Usage" + onClick={handleUsageClick} + /> + + )} + + + ); +}); + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - - - - - - } - /> - Settings - - - {pullRequestsSupported ? ( - - - - - - } - /> - Pull Requests - - - ) : null} - - - - - - } - /> - Usage - - - - )} - - + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0ad5c35445ec3fe014b2110dee7264..477bc9c02630ec263ec824291048e43b5329e0f7 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] { return buildThreadActionMenuItems(state).map((item) => item.id); } +function allIds(state: ThreadActionMenuState): string[] { + const flatten = (items: ReturnType): string[] => + items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]); + return flatten(buildThreadActionMenuItems(state)); +} + describe("buildThreadActionMenuItems", () => { it("hides lifecycle items when the environment lacks the capabilities", () => { expect( @@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = ids({ ...baseState, branch: "feat/menu" }); + const withBranch = allIds({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(ids(baseState)).not.toContain("new-thread-on-branch"); - expect(ids(baseState)).not.toContain("copy-branch"); + expect(allIds(baseState)).not.toContain("new-thread-on-branch"); + expect(allIds(baseState)).not.toContain("copy-branch"); }); it("flips lifecycle labels with thread state", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdacdbb3e15943b8a38bc60c37611d27e..1218e2dd58cbd4757d68a3233a802ab8ed36da8b 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,6 +18,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" + | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -53,14 +54,15 @@ export function buildThreadActionMenuItems( { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, + icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread" } - : { id: "pin" as const, label: "Pin thread" }, + ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } + : { id: "pin" as const, label: "Pin thread", icon: "pin" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -69,17 +71,18 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread" } - : { id: "settle" as const, label: "Settle thread" }, + ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } + : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread" } + ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } : { id: "snooze" as const, label: "Snooze", + icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -88,20 +91,37 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread" }, + { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), - { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + { + id: "copy", + label: "Copy", + icon: "copy", + separatorBefore: true, + children: [ + { id: "copy-path", label: "Path", icon: "folder" }, + ...(state.branch + ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + : []), + { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, + ], + }, + { + id: "delete", + label: "Delete", + destructive: true, + icon: "trash", + separatorBefore: true, + }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29b91e18bb40ec10dbfe6a551bb3f92bc88bcada --- /dev/null +++ b/apps/web/src/components/ui/segmented-tabs.tsx @@ -0,0 +1,40 @@ +import type { ComponentProps, HTMLAttributes } from "react"; + +import { cn } from "~/lib/utils"; +import { Toggle } from "~/components/ui/toggle"; + +function SegmentedTabList({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +function SegmentedTab({ + selected, + density = "default", + className, + ...props +}: { + selected: boolean; + density?: "default" | "compact"; +} & Omit, "aria-pressed" | "pressed" | "size" | "type" | "variant">) { + return ( + + ); +} + +export { SegmentedTab, SegmentedTabList }; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 5bf04adf41a1d298e89a11d95fa1ae1ffffeaa95..7173eab140ec2135bf55e51e3d559ee9d6e646a0 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,6 +18,10 @@ const toggleVariants = cva( "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", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", + segmented: + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + "segmented-compact": + "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -27,6 +31,8 @@ const toggleVariants = cva( "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", outline: "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", + segmented: + "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", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883db2482844cf88287d3ccd18f39b7adc..b9bebadc00e7cd0f95f7fc8c48beaaafaed1fced 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,13 +19,22 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; -import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; -import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../WorkspacePageContainer"; +import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -66,21 +75,6 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); - // Ranked by whatever the toggle is showing, so the bars always descend. - const orderedProviders = useMemo( - () => - merged.providers.toSorted((a, b) => - metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, - ), - [merged.providers, metric], - ); - - const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( - (period) => period.totalTokens > 0, - ).length; - const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; - const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; - const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -100,78 +94,66 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; + const windowLabel = + isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; + const topbarContent = ( +
+ + +

Usage

+
+ + + {windowLabel} + +
+
+ + {(["cost", "tokens"] as const).map((option) => ( + setMetric(option)} + > + {option === "cost" ? "Cost" : "Tokens"} + + ))} + + + {WINDOW_OPTIONS.map((option) => ( + selectWindow(option.days)} + > + {option.label} + + ))} + + + + +
+
+ ); return (
- {!isElectron && ( -
- - Usage - -
- )} - - {isElectron && ( -
- - Usage - -
- )} + + {topbarContent} + -
-
-

- {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined - ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` - : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} -

-
-
- {WINDOW_OPTIONS.map((option) => ( - - ))} -
- -
-
- + {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -181,88 +163,62 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> - {/* Cost first: the financial answer, then the provider split. */} -
- {/* The summary follows the chart toggle, so the headline and the - series are always reading the same units. */} -
+
+
- - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` + ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" - : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + ? `${formatCount(merged.sessions)} sessions · API estimate` + : `${formatCount(merged.sessions)} sessions`}
- {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + {PROVIDER_ORDER.map((provider) => { + const totals = merged.providers.find((entry) => entry.provider === provider); + const share = + metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); + const providerSessions = totals?.sessions ?? 0; + const sessionLabel = `${formatCount(providerSessions)} ${ + providerSessions === 1 ? "session" : "sessions" + }`; return ( -
-
- - - {PROVIDER_LABEL[provider.provider]} +
+
+ + + + {PROVIDER_LABEL[provider]} + + {sessionLabel} + + - + {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)}
-
-
-
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
); })}
-
-
-

- {isPast24Hours ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

-
-
- {(["cost", "tokens"] as const).map((option) => ( - - ))} -
- -
-
+
+

+ {isPast24Hours ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"} +

-
- - - - - 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" - } - /> +
+

Totals

+
+ + + + + +

Breakdown

-
+ {( [ - { value: "model", label: "model" }, - { value: "time", label: isPast24Hours ? "hour" : "day" }, + { value: "model", label: "Model" }, + { value: "time", label: isPast24Hours ? "Hour" : "Day" }, ] as const ).map((option) => ( - + ))} -
+
{breakdown === "model" ? ( @@ -356,7 +291,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -403,7 +338,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -433,7 +368,7 @@ export function UsagePage() {
)} -
+
@@ -452,20 +387,11 @@ function ProviderMark({ return ; } -function Metric({ - label, - value, - detail, -}: { - readonly label: string; - readonly value: string; - readonly detail: string; -}) { +function Metric({ label, value }: { readonly label: string; readonly value: string }) { return ( -
+
{label} - {value} - {detail} + {value}
); } @@ -569,70 +495,51 @@ function UsageDeviceStrip({ ); } -/** Deterministic bar heights (each unique: they double as keys). */ -const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; - /** - * Static stand-in with the loaded page's shape: headline, provider split, - * chart and metrics strip. No shimmer; blocks fill in exactly once when the - * last device answers. + * Static stand-in with the loaded page's shape. No shimmer; blocks fill in + * exactly once when the last device answers. */ -function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { +function UsageSkeleton() { return ( <> -
+
- - Raw token cost - -
-
+
+
- {PROVIDER_ORDER.map((provider) => ( -
-
- +
+
+ - {PROVIDER_LABEL[provider]} +
-
))}
-

- {resolution === "hour" ? "Hourly" : "Daily"} cost -

- {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a - relayout when the real chart swaps in. */} -
- {SKELETON_BAR_HEIGHTS.map((height) => ( -
- ))} -
+
+
-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
-
- ), - )} +
+

Totals

+
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+ ), + )} +
); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe286d76bd66ceb61bdf45ab647e4e9e1..963c28fe6a014b5080c70d8e35be73d114f5046f 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,13 +68,7 @@ function buildPeriodColumns( }); } -/** - * Monotone cubic tangents (Fritsch-Carlson). - * - * Plain cubic smoothing overshoots on spiky daily data and would dip the area - * below zero between points, which reads as negative spend. This variant is - * shape-preserving, so a smoothed series never leaves the range of its samples. - */ +/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } -/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -123,7 +116,6 @@ interface CurveSegment { readonly to: Point; } -/** Smoothed polyline through `points`, as explicit cubic control points. */ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { +function curvePath(segments: readonly CurveSegment[]): string { const first = segments[0]; if (first === undefined) return ""; - let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { 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)}`; } @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re /** * Turns the merged daily totals into one column per day. * - * Values are absolute, not cumulative: the series are layered from a shared - * zero baseline rather than stacked. A stacked chart puts whichever provider is - * drawn last permanently above the other, which reads as "that one is bigger" - * even on days where it is not. + * Values are absolute, not cumulative: each provider is drawn from the same + * zero baseline so the chart never implies that one provider is always larger. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -216,42 +206,39 @@ export function UsageProviderChart({ ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); + const tooltipRef = useRef(null); + const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const { paths, ticks, stepX, toY, series } = useMemo(() => { + const { paths, series, stepX, ticks, toY } = useMemo(() => { if (periods.length === 0) { return { paths: [], - ticks: [0] as readonly number[], + series: [] as readonly DayColumn[], stepX: 0, + ticks: [0] as readonly number[], toY: () => VIEW_HEIGHT, - series: [] as readonly DayColumn[], }; } const columns = buildPeriodColumns(periods, byPeriod, metric); - - // The scale tops out at the largest single provider-day, not the largest - // sum: layered series each measure from zero, so a combined peak would - // leave the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); - // Reserve a sliver above the top gridline so the series stroke, which is - // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const curve = smoothCurve( - columns.map((column, dayIndex) => ({ - x: dayIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), + const line = curvePath( + smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })), + ), ); - const line = curvePath(curve, "M"); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -260,30 +247,65 @@ export function UsageProviderChart({ }; }); - // Paint the heavier series first so the lighter one is never buried under - // it. The fills are faint enough that the order barely shows, but the - // strokes are drawn in a second pass regardless, so neither can be hidden. - const ordered = [...built].sort((a, b) => b.total - a.total); - - return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; + return { + paths: built.toSorted((a, b) => b.total - a.total), + series: columns, + stepX: step, + ticks: tickValues, + toY, + }; }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; + const positionTooltip = useCallback(() => { + const plot = plotRef.current; + const tooltip = tooltipRef.current; + const hoverPosition = hoverPositionRef.current; + if (plot === null || tooltip === null || hoverPosition === null) return; + + const gap = 12; + const tooltipWidth = tooltip.offsetWidth; + const tooltipHeight = tooltip.offsetHeight; + const plotWidth = plot.clientWidth; + const plotHeight = plot.clientHeight; + const preferredLeft = + hoverPosition.x + gap + tooltipWidth <= plotWidth + ? hoverPosition.x + gap + : hoverPosition.x - gap - tooltipWidth; + const preferredTop = + hoverPosition.y + gap + tooltipHeight <= plotHeight + ? hoverPosition.y + gap + : hoverPosition.y - gap - tooltipHeight; + const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); + const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); + plot.style.setProperty("--usage-tooltip-left", `${left}px`); + plot.style.setProperty("--usage-tooltip-top", `${top}px`); + }, []); + + useLayoutEffect(() => { + if (hoverIndex !== null) positionTooltip(); + }, [hoverIndex, positionTooltip]); + const handleMove = useCallback( (event: React.MouseEvent) => { - const bounds = plotRef.current?.getBoundingClientRect(); - if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; - const fraction = (event.clientX - bounds.left) / bounds.width; + const plot = plotRef.current; + if (plot === null || periods.length === 0) return; + const bounds = plot.getBoundingClientRect(); + if (bounds.width === 0) return; + const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); + const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); + const fraction = localX / bounds.width; const index = Math.round(fraction * (periods.length - 1)); + hoverPositionRef.current = { x: localX, y: localY }; + positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [periods.length], + [periods.length, positionTooltip], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; - const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -311,7 +333,10 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => setHoverIndex(null)} + onMouseLeave={() => { + hoverPositionRef.current = null; + setHoverIndex(null); + }} > ( ))} @@ -368,10 +392,11 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", + left: "var(--usage-tooltip-left, 0px)", + top: "var(--usage-tooltip-top, 0px)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -418,21 +443,3 @@ export function UsageProviderChart({
); } - -export function UsageChartLegend() { - return ( -
- {PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; - return ( - - - {PROVIDER_LABEL[provider]} - - ); - })} -
- ); -} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf40f80425d8e9bfba99a613245d1ab..3ec1718590277f404cf4a6befd9c7bb93d944c9b 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. + * Stable provider reading order across summaries, tables, and hover rows. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 769826e3999c2113b16446bfa61309179d3b63d3..4bc3237d2a66cf9d35b3ed6ba45e529cb61b4697 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,6 +4,15 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { + "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], + "circle-check": [ + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, + ], + clock: [ + { tag: "path", attrs: { d: "M12 6v6l4 2" } }, + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + ], pencil: [ { tag: "path", @@ -17,6 +26,71 @@ const ICON_PATHS: Record( "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { + if (item.separatorBefore === true && inner.childElementCount > 0) { + const separator = document.createElement("div"); + separator.className = "my-1 h-px bg-border/70"; + separator.style.cssText = + "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; + separator.dataset.contextMenuSeparator = "true"; + separator.setAttribute("role", "separator"); + inner.appendChild(separator); + } + if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; @@ -247,10 +331,12 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - const chevron = document.createElement("span"); - chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; - chevron.textContent = ">"; - button.appendChild(chevron); + const chevron = createIconElement("chevron-right", "neutral"); + if (chevron) { + chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); + chevron.dataset.contextMenuChevron = "true"; + button.appendChild(chevron); + } } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4e636eb4ff0f0b181dd0c5f9c3c02dcde25e9b47..bd49f53702cd0c332df40b6942bd4aada1262db5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,6 +241,22 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } + @keyframes live-activity-focus { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } + } + @keyframes live-activity-focus-counter { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } + } @keyframes status-ping { /* Burst first (immediate feedback for click ripples), then hold invisible for the rest of the cycle. Mirrors animate-ping's @@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility live-activity-focus { + --live-activity-focus-width: 4.5rem; + + right: auto; + left: calc(-1 * var(--live-activity-focus-width)); + width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + mask-repeat: no-repeat; + animation: live-activity-focus 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 0; + will-change: auto; + } +} + +@utility live-activity-focus-counter { + width: 100%; + animation: live-activity-focus-counter 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + will-change: auto; + } +} + +@utility live-activity-focus-aligned { + width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); + margin-left: var(--live-activity-focus-width); +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { /* The panel layout toggles stay ghost: they render both inside the header and in the titlebar strip, so filling them would make them change appearance as - the panel opens. They only take the themed foreground; hover and pressed - keep the base ghost accent. The tooltip trigger's data-slot wins over the - toggle's when the trigger renders the toggle, so match both. */ + the panel opens. Their icons use the same themed foreground as the toolbar + action text; hover and pressed keep the base ghost accent. The tooltip + trigger's data-slot wins over the toggle's when it renders the toggle, so + match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-foreground); - color: var(--toolbar-foreground); + --control-icon-color: var(--toolbar-control-foreground); + color: var(--toolbar-control-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0b7e6bf0f9701963bc60dfef8d74b5a6737f4da3..5bfb80bfec32417a88cd3ab60c54afcfda8022ef 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } +/** The repository root behind a recognised change-request URL, without PR-specific state. */ +export function changeRequestRepositoryUrl(targetUrl: string): string | null { + const changeRequest = parseChangeRequestUrl(targetUrl); + if (changeRequest === null) return null; + const url = new URL(targetUrl); + url.pathname = `/${changeRequest.repository}`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 803ba787116bc4d1d1b74e24fe1bbcaedc18bfba..0e1fdc9f884cb85d6dcf8aafd3c9077f04c74018 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); + expect(onboardingHeader).toContain(''); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 4f4da0c751effcc86f03e89cc71823fcdc219ce2..271715be3ca1937acb535e4eaf4e49309106676a 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -17,8 +18,6 @@ import { import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -143,18 +142,13 @@ function HostedStaticOnboardingState() { return (
-
+
{APP_DISPLAY_NAME}
-
+
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 66d9f0caa5da32342a78775aa208e630c03b7385..7d73a225d5a0d8f0cdb67de15f8890eb1328179f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,6 +74,12 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../components/WorkspacePageContainer"; +import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -99,7 +105,6 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1181,6 +1186,17 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); + const linkedSelectionMatchesSurface = + linkedSelection !== null && + selectedPullRequestSurface !== null && + linkedSelection.environmentId === selectedPullRequestSurface.environmentId && + linkedSelection.projectId === selectedPullRequestSurface.projectId && + linkedSelection.repository === selectedPullRequestSurface.repository && + linkedSelection.number === selectedPullRequestSurface.number; + // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are + // history, though, not a current selection: without this check they leave the toggle looking + // available after the selected pull request has been cleared. + const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1294,9 +1310,10 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelState.surfaces.length > 0} + rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} + rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1603,7 +1620,6 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} - chromeVariant="collapse" /> ) : null} @@ -1827,18 +1843,10 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
-
+ {/* A closed right panel leaves this column full-width, so the shared header + reserves native window controls. While the panel is open, the column ends + at the panel and the absolute controls strip owns the top-right corner. */} + {condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1880,27 +1888,24 @@ function PullRequestsColumn({ )}
{condensed ? ( - { - topbarSearchFocusedRef.current = focused; - }} - /> +
+ { + topbarSearchFocusedRef.current = focused; + }} + /> + +
) : null} - - {rightPanelControl} -
+ {rightPanelControl ? ( + {rightPanelControl} + ) : null} +
+
{searchInput} {filtersMenu} + {!condensed ? ( + + ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} -
+
); } + +function PullRequestRefreshControl({ + compact = false, + refreshing, + onRefresh, +}: { + compact?: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a4b248c84ed985fa7e902c5319a93d1064604638..431e196de8b1b51203f1a78a156119ebfcd6998a 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,9 +13,8 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -72,41 +71,16 @@ function SettingsContentLayout() { return (
- {!isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
-
- )} - - {isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
+ +
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null}
- )} +
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f5effff6602c9f21d02d779c33f81e99c297f899..2eadd1fc5fb56b206c6952154713604b1c89c240 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("omits tool started entries and keeps completed entries", () => { + it("shows a command from its start event while it is still running", () => { const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + id: "tool-start", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "inProgress", + sourceActivityKind: "tool.started", + }); + }); + + it("retains the start command when the matching completion omits it", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + data: { input: { command: "vp test run" } }, + }, + }), + makeActivity({ + id: "other-tool-start", + createdAt: "2026-02-23T00:00:02.500Z", + summary: "Other command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "inProgress", + title: "Other command", + data: { input: { command: "vp lint" } }, + }, + }), makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:03.000Z", - summary: "Tool call complete", + summary: "Command run", kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "completed", + title: "Command run", + }, }), makeActivity({ - id: "tool-start", - createdAt: "2026-02-23T00:00:02.000Z", - summary: "Tool call", - kind: "tool.started", + id: "other-tool-complete", + createdAt: "2026-02-23T00:00:04.000Z", + summary: "Other command", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "completed", + title: "Other command", + }, }), ]; const entries = deriveWorkLogEntries(activities); - expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "tool-complete", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + expect(entries[1]).toMatchObject({ + id: "other-tool-complete", + command: "vp lint", + toolCallId: "call-2", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + }); + + it("does not merge non-adjacent tool starts without stable call ids", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unkeyed-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + makeActivity({ + id: "keyed-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-between", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "unkeyed-start-2", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "unkeyed-start-1", + "keyed-start", + "unkeyed-start-2", + ]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", + toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b33b89cbaa939353c2939afc6b0b5..efe1876dfc1cef3351a6bf789b1591a064c3c9fd 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,6 +65,8 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; + /** Stable provider identity across in-progress and completed lifecycle updates. */ + toolCallId?: string; label: string; detail?: string; command?: string; @@ -748,7 +750,6 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -757,8 +758,13 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Plan updates have a dedicated task row. Keeping the raw activity here + // duplicates it as a legacy "Work Log / Plan updated" row when history + // is expanded. + if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isCodexTerminalInteractionActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -769,7 +775,11 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + if ( + activity.kind !== "tool.started" && + activity.kind !== "tool.updated" && + activity.kind !== "tool.completed" + ) { return false; } @@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } +/** + * Codex terminal interactions report bytes written to an already-running PTY. + * Some thread histories contain them as generic tool.updated rows, so filter + * their exact wire shape from the presentation model. This repairs existing + * history without deleting or rewriting persisted activities. + */ +function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated") { + return false; + } + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + return ( + payload?.itemType === "command_execution" && + typeof data?.itemId === "string" && + typeof data.processId === "string" && + typeof data.stdin === "string" && + typeof data.threadId === "string" && + typeof data.turnId === "string" + ); +} + function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.started") { + toolLifecycleStatus = "inProgress"; + } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } +function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { + return undefined; + } + return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries( }); continue; } + const lifecycleKey = toolLifecycleCollapseMapKey(entry); + if (lifecycleKey !== undefined) { + const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); + if (matchingLifecycleIndex !== undefined) { + const matchingEntry = collapsed[matchingLifecycleIndex]; + if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { + toolLifecycleRowIndex.delete(lifecycleKey); + const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); + collapsed[matchingLifecycleIndex] = merged; + if (merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); + } + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const previousIndex = collapsed.length - 1; + const previousKey = toolLifecycleCollapseMapKey(previous); + if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); + const merged = mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + const mergedKey = toolLifecycleCollapseMapKey(merged); + if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(mergedKey, previousIndex); + } continue; } collapsed.push(entry); + if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } @@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.activityKind !== "tool.started" && + previous.activityKind !== "tool.updated" && + previous.activityKind !== "tool.completed" + ) { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { return false; } if (previous.activityKind === "tool.completed") { @@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { return undefined; } if (entry.toolCallId) { @@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); + const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, + dataInput?.command, + stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(data?.toolCallId); + return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1fe6e4e88d1e3c62a46d2a866125f02..f7a6412d51dba91d226ef373ebae0773d1fb6cdd 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => { it("reconciles terminal ids from an external ordered list", () => { const store = useTerminalUiStateStore.getState(); store.setTerminalOpen(THREAD_REF, true); + store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); + store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); const terminalUiState = selectThreadTerminalUiState( @@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => { { id: "group-term-a", terminalIds: ["term-a"] }, { id: "group-term-b", terminalIds: ["term-b"] }, ]); + expect( + useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ + scopedThreadKey(THREAD_REF) + ], + ).toEqual({ "term-a": "API server" }); }); it("does not import a closed panel terminal from stale metadata", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954c48c647eb5bea65f9f91f3105d31f..545e195a1287416e7f84be0ea4c5ac371b5dc8b8 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,8 +32,11 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; terminalStateByThreadKey?: Record; + terminalCustomLabelsByThreadKey?: Record>; } +const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly> = Object.freeze({}); + export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, @@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState( parseScopedThreadKey(threadKey), ), ); + const terminalCustomLabelsByThreadKey = Object.fromEntries( + Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( + ([threadKey, labels]) => { + if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; + const normalizedLabels = Object.fromEntries( + Object.entries(labels).flatMap(([terminalId, label]) => { + const normalizedTerminalId = terminalId.trim(); + const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; + return normalizedTerminalId && normalizedLabel + ? [[normalizedTerminalId, normalizedLabel] as const] + : []; + }), + ); + return Object.keys(normalizedLabels).length > 0 + ? [[threadKey, normalizedLabels] as const] + : []; + }, + ), + ); - return { terminalUiStateByThreadKey }; + return { + terminalUiStateByThreadKey, + ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 + ? { terminalCustomLabelsByThreadKey } + : {}), + }; } function createTerminalUiStateStorage() { @@ -489,6 +516,18 @@ export function selectThreadTerminalUiState( ); } +export function selectThreadTerminalCustomLabels( + terminalCustomLabelsByThreadKey: Record>, + threadRef: ScopedThreadRef | null | undefined, +): Readonly> { + if (!threadRef || threadRef.threadId.length === 0) { + return EMPTY_TERMINAL_CUSTOM_LABELS; + } + return ( + terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS + ); +} + function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record, threadRef: ScopedThreadRef, @@ -562,6 +601,7 @@ function removeRecordEntry(record: Record, key: string): Record; + terminalCustomLabelsByThreadKey: Record>; /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -575,6 +615,11 @@ interface TerminalUiStateStoreState { options?: { open?: boolean; active?: boolean }, ) => void; setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + setTerminalCustomLabel: ( + threadRef: ScopedThreadRef, + terminalId: string, + label: string | null, + ) => void; closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; clearTerminalUiState: (threadRef: ScopedThreadRef) => void; @@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { terminalId: string; suppressed: boolean }, + suppression?: { + terminalId: string; + suppressed: boolean; + clearCustomLabel?: boolean; + }, + pruneCustomLabels = false, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create()( suppression.suppressed, ) : state.suppressedTerminalIdsByThreadKey; + const terminalIdToClear = suppression?.clearCustomLabel + ? suppression.terminalId.trim() + : ""; + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + let nextTerminalCustomLabelsByThreadKey = + terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined + ? Object.keys(currentLabels).length === 1 + ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) + : { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), + } + : state.terminalCustomLabelsByThreadKey; + if (pruneCustomLabels) { + const survivingIds = new Set( + selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, + ); + const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; + const survivingLabels = Object.fromEntries( + Object.entries(labelsForThread).filter(([terminalId]) => + survivingIds.has(terminalId), + ), + ); + if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { + nextTerminalCustomLabelsByThreadKey = + Object.keys(survivingLabels).length > 0 + ? { + ...nextTerminalCustomLabelsByThreadKey, + [threadKey]: survivingLabels, + } + : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); + } + } if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && + nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create()( ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), + setTerminalCustomLabel: (threadRef, terminalId, label) => + set((state) => { + const normalizedTerminalId = terminalId.trim(); + if (normalizedTerminalId.length === 0) return state; + const threadKey = terminalThreadKey(threadRef); + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + const normalizedLabel = label?.trim().slice(0, 80) ?? ""; + if (normalizedLabel.length > 0) { + if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; + return { + terminalCustomLabelsByThreadKey: { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, + }, + }; + } + if (currentLabels[normalizedTerminalId] === undefined) return state; + const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; + return { + terminalCustomLabelsByThreadKey: + Object.keys(remainingLabels).length > 0 + ? { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: remainingLabels, + } + : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), + }; + }), closeTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { terminalId, suppressed: true, + clearCustomLabel: true, }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal(threadRef, (state, suppressedTerminalIds) => { - if (suppressedTerminalIds.length === 0) { - return reconcileThreadTerminalSessionIds(state, nextIds); - } - const suppressedIds = new Set(suppressedTerminalIds); - return reconcileThreadTerminalSessionIds( - state, - nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), - ); - }), + updateTerminal( + threadRef, + (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }, + undefined, + true, + ), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds + !hadSuppressedTerminalIds && + !hadCustomLabels ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds) { + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; + if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { return state; } return { @@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create()( const orphanedIds = new Set( [ ...Object.keys(state.terminalUiStateByThreadKey), + ...Object.keys(state.terminalCustomLabelsByThreadKey), ...Object.keys(state.suppressedTerminalIdsByThreadKey), ].filter((key) => !activeThreadKeys.has(key)), ); @@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create()( const nextSuppressedTerminalIdsByThreadKey = { ...state.suppressedTerminalIdsByThreadKey, }; + const nextTerminalCustomLabelsByThreadKey = { + ...state.terminalCustomLabelsByThreadKey, + }; for (const id of orphanedIds) { delete nextTerminalUiStateByThreadKey[id]; + delete nextTerminalCustomLabelsByThreadKey[id]; delete nextSuppressedTerminalIdsByThreadKey[id]; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), @@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 4, + version: 5, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, }), }, ), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602ac72082f7e9c414348264b650f197..03451cc7b2ec72100606fac8fda482498be3ce62 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,6 +111,8 @@ export interface ContextMenuItem { header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; + /** Inserts a visual section divider immediately before this item. */ + separatorBefore?: boolean; children?: readonly ContextMenuItem[]; } @@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; + readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), + separatorBefore: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index c2fa9e2a86a193b596d0e3ccc0f8235b66924e26..81270ad320cb6179abcc331abbfd21c0a146afca 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,6 +248,7 @@ describe("mergeUsage", () => { ); expect(merged.sessions).toBe(1); + expect(merged.providers[0]?.sessions).toBe(1); }); it("returns empty totals with no environments", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 886b214183bc20b0e32d5052f3e7266584cf5bab..f5e54434fd973318b5c8db06f6e1d5bb577335f1 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,6 +25,7 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { +): { + readonly buckets: readonly UsageBucket[]; + readonly sessionsByProvider: ReadonlyMap; +} { const ownedProviders = new Set(); - let sessions = 0; + const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - ownedProviders.add(source.fingerprint.provider); + const provider = source.fingerprint.provider; + ownedProviders.add(provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessions += source.distinctSessions; + sessionsByProvider.set( + provider, + (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + ); } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessions, + sessionsByProvider, }; } @@ -228,7 +236,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number } + { costUsd: number; totalTokens: number; records: number; sessions: number } >(); const modelAccumulator = new Map< string, @@ -255,12 +263,20 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessions: environmentSessions } = ownedContribution( - environment, - ownerByFingerprint, - ); + const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - sessions += environmentSessions; + + for (const [providerKind, providerSessions] of sessionsByProvider) { + sessions += providerSessions; + const provider = providerAccumulator.get(providerKind) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + sessions: 0, + }; + provider.sessions += providerSessions; + providerAccumulator.set(providerKind, provider); + } for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -280,6 +296,7 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -341,6 +358,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, }))