diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index e3c5d5dd6431..58870bbab1db 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,10 +98,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [ - { id: "copy", label: "Copy" }, - { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, - ], + items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -113,12 +110,6 @@ 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 ca8cc246e895..4d3e5a1c2416 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,7 +78,6 @@ 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) { @@ -142,17 +141,10 @@ 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) { - appendSeparator(); + template.push({ type: "separator" }); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 047e40ccf490..fc9ea4b62268 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", () => { +describe("projectActivityPayload agent-field survival", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,45 +44,6 @@ describe("projectActivityPayload", () => { 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 659760c049a4..f68a3ee96e9b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,24 +104,6 @@ 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)) { @@ -305,9 +287,8 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - const command = projectCommandValue(data); - if (command !== undefined) { - projectedData.command = command; + if ("command" in data) { + projectedData.command = data.command; } const changedFiles: string[] = []; @@ -387,10 +368,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`): 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. + * 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. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = - asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); + const 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 b5feda5052d8..258aa010e3e6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), - itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "inProgress", - title: "Command run", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, + status: "in_progress", + title: "Read file", + detail: "/tmp/file.ts", }, }); @@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - 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" }, - }, - }); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), + ).toBe(true); }); 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 1eb7e54b3b36..03253797242e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,7 +794,6 @@ 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 } : {}), @@ -822,8 +821,6 @@ 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 } : {}), @@ -850,10 +847,7 @@ 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 e7193a7d0ffd..6eab33aec1c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ 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"; @@ -219,11 +220,7 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { - selectThreadTerminalCustomLabels, - selectThreadTerminalUiState, - useTerminalUiStateStore, -} from "../terminalUiStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -259,7 +256,6 @@ 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, @@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; - onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, - onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -996,7 +990,6 @@ 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} @@ -1547,16 +1540,6 @@ 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, @@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - activeTerminalCustomLabels[session.target.terminalId] ?? - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + 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; - }, [activeTerminalCustomLabels, activeThreadKnownSessions]); + }, [activeThreadKnownSessions]); + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); - const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } + chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6174,11 +6160,20 @@ 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 cfc40f93638b..82dddd8f41e0 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,15 +1,26 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { WorkspacePageHeader } from "./WorkspacePageContainer"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return (
- +
{isElectron ? ( - No active thread + + No active thread + ) : (
@@ -17,7 +28,7 @@ export function NoActiveThreadState() {
)} - +
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f43bd5ea629b..9cb09219df09 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, - hasProjectScope = false, ): boolean { - return hasProjectScope || shiftKey || projectGroupCount <= 1; + return shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 010571b915df..2f0c5a221405 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,7 +184,6 @@ 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"; @@ -367,20 +366,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - /> - } - > - - + + 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 + {presets.map((preset) => ( ) : ( @@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - - {props.isPinned ? ( - props.pinningSupported ? ( - - ) : ( - - - - ) - ) : 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. */} + + + Unpin thread + + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, + actions on hover/keyboard focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + + {/* 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. */} - {/* 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. */} + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {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 ? ( - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - + ) : null} + {props.settlementSupported ? ( + + - ) : 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} - + + Settle thread + + ) : null} + + ) : null}
@@ -3211,25 +3218,17 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // 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. + // 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. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - if ( - shouldCreateNewThreadInCurrentProject( - event?.shiftKey ?? false, - projectGroups.length, - scopedProjectGroup !== null, - ) - ) { + // 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 (isMobile) setOpenMobile(false); - if (scopedProjectGroup) { - void newThreadContext.handleNewThread( - scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), - ); - return; - } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3241,19 +3240,20 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, setOpenMobile], ); - // 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. + // 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. const newThreadShortcutLabel = - scopedProjectGroup === null - ? (shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 - ? shortcutLabelForCommand(keybindings, "chat.newLocal") - : undefined)) - : undefined; + shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,9 +3332,7 @@ export default function Sidebar() { /> - {scopedProjectGroup ? ( - `New thread in ${scopedProjectGroup.displayName}` - ) : projectGroups.length > 1 ? ( + {projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index deec13ec3bda..1266e5ed7e94 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,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, + type ReactNode, type SetStateAction, useCallback, useEffect, @@ -29,9 +30,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 { @@ -59,7 +60,6 @@ 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,15 +72,10 @@ 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; @@ -249,10 +244,6 @@ 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 }; @@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; - onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -901,6 +891,35 @@ 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, @@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, - onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -932,21 +950,6 @@ 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, @@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); + const hasTerminalSidebar = normalizedTerminalIds.length > 1; 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 automaticTerminalLabelById = useMemo(() => { + const terminalLabelById = 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 ( @@ -1129,9 +1123,6 @@ 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 @@ -1142,6 +1133,9 @@ 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"; @@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - 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]); + const onNewTerminalAction = useCallback(() => { + onNewTerminal(); + }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - const compactTerminalToolbar = ( - <> - - - - - {!isPanel && onHide ? ( - <> - - - - ) : null} - - ); + return ( + {showGroupHeaders && ( + + )} + + {normalizedTerminalIds.length > 1 && ( + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + + )} +
+ ); + })} +
+ + ); + })} + + + )} + ); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx deleted file mode 100644 index 4613dd465b1c..000000000000 --- a/apps/web/src/components/WorkspacePageContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 bc3c4fa80dfc..e9fa1895bf99 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,9 +23,13 @@ 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="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); + expect(markup).toContain( + 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', + ); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); + 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="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -34,11 +38,9 @@ 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 clean representative-file preview for a large latest change", () => { + it("renders a scope and 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/web/src/"); - expect(markup).toContain("packages/shared/src/"); + expect(markup).toContain("apps"); + expect(markup).toContain("2 files"); + expect(markup).toContain("packages"); + expect(markup).toContain("root"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - 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).toContain("Show all 4 files"); 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 a8bb461c0e12..d29d8b7f2f44 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; +import { + changedFileName, + selectChangedFilePreview, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -46,12 +50,13 @@ 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 ? ( @@ -150,35 +158,43 @@ 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} @@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -289,11 +305,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 82338dec2a89..6d74204bc1ca 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", - "assistant-thought-entry", - "work-toggle:work-entry-1", "turn-fold:turn-1", + "assistant-thought-entry", + "work-entry-1", "assistant-final-entry", ]); expect( @@ -638,84 +638,6 @@ 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: [ @@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -867,133 +788,10 @@ 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-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([ + "work-entry-1", "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", () => { @@ -1054,7 +852,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("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 3, + hiddenCount: 2, 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 8d7fc52fdca6..6bc0a2a6203c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,6 @@ import * as Equal from "effect/Equal"; import { formatDuration, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -167,17 +166,6 @@ 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"; @@ -187,9 +175,6 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; - summary: string | null; - summaryKind: ToolGroupAction | "mixed" | null; - hasFailure: boolean; } | { kind: "turn-fold"; @@ -223,12 +208,7 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { - kind: "working"; - id: string; - createdAt: string | null; - showThinking: boolean; - }; + | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -258,90 +238,6 @@ 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, @@ -414,34 +310,17 @@ 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 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. + * "Worked for ..." row anchored at the turn's first foldable entry; the + * terminal assistant message stays visible below the fold. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap> { +}): ReadonlyMap { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -496,7 +375,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -526,24 +405,6 @@ 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"; @@ -570,16 +431,13 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - const fold = { + foldsByAnchorEntryId.set(firstEntry.id, { turnId, - anchorEntryId: anchorEntry.id, - createdAt: anchorEntry.createdAt, + anchorEntryId: firstEntry.id, + createdAt: firstEntry.createdAt, hiddenEntryIds, label, - }; - const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); - if (anchoredFolds) anchoredFolds.push(fold); - else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); + }); } return foldsByAnchorEntryId; } @@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - 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); - } + for (const fold of foldsByAnchorEntryId.values()) { + 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; } - if (input.isWorking && index === activeTurnHeaderIndex) { + const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); + if (turnFold) { nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, }); } - 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; @@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || - activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - 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) { + 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 = workGroupId(timelineEntry.id, timelineEntry.entry); + const groupId = `work-group:${timelineEntry.id}`; 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 @@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, }); } @@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries, - summary: null, - summaryKind: null, - hasFailure: visibleGroupedEntries.some((entry) => - workEntryIndicatesToolFailure(entry), + onlyToolEntries: visibleGroupedEntries.every((entry) => + workLogEntryIsToolLike(entry), ), }); } @@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + if (input.isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, }); } @@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - 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": + return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); case "work-toggle": { const bw = b as typeof a; @@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries && - a.summary === bw.summary && - a.summaryKind === bw.summaryKind && - a.hasFailure === bw.hasFailure + a.onlyToolEntries === bw.onlyToolEntries ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3dcf6cf2a302..194edc0bd5bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,49 +554,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - 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", () => { + it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Changed 1 file"); + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); 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 3ccd4808d064..e190f47569b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,6 +35,7 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, + workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -56,6 +57,7 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, + MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -918,34 +920,17 @@ 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-live" ? : null} + {row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - 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" + 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" > {row.label} - +
); @@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
-
+
+
+ + + + + + {row.createdAt ? ( <> Working for @@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract + + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
- {row.showThinking ? ( -
- -
- ) : null}
); } @@ -1358,10 +1340,8 @@ 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( @@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} - isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1399,128 +1375,12 @@ 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" @@ -2159,101 +2019,32 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -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 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"; +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; } - - return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + return rawCommand === workEntry.command.trim() ? null : rawCommand; } 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 detail = workEntry.detail?.trim(); - if (detail && detail !== command) { - blocks.push(detail); + 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 changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2389,88 +2180,71 @@ 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, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = 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, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = props; + const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - 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 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 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-6 shrink-0 items-center justify-center", - showWarningIndicator || showFailedIndicator + "flex size-5 shrink-0 items-center justify-center", + showWarningIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : workLogEntryIsToolLike(workEntry) - ? "text-secondary-label" - : "text-foreground/80"; - const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; + : "font-medium text-foreground"; + const turnSettled = !activity.activeTurnInProgress; + const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); + const showSuccessIndicator = + workEntryIndicatesToolSuccess(workEntry) || + (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); 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 === " ") { @@ -2505,50 +2280,94 @@ 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 c2fa204ffbc8..6f281558ff80 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,7 +1,6 @@ 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"; @@ -13,7 +12,6 @@ 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; @@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, - rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = - "block self-center truncate leading-none select-none"; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; +// 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_CLASS_NAME = - "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"; + "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"; 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 7457d04d2c9e..2f4e84dc3fd2 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,7 +25,6 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, - LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -51,7 +50,6 @@ 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"; @@ -62,7 +60,6 @@ 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 { @@ -77,7 +74,6 @@ 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, @@ -120,7 +116,6 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { - PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -354,6 +349,7 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", + chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -385,6 +381,12 @@ 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. @@ -421,13 +423,26 @@ 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 = chromeCondensed; + 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 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(() => { @@ -448,6 +463,7 @@ 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(() => { @@ -486,30 +502,6 @@ 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(() => { @@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // 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. + // 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. const primaryAction = detail === null || detail.state !== "open" ? null - : conflicting - ? "resolve" - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null + : conflicting + ? "resolve" : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts keep their own row below: an open pull request remains green there. 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 ? ( <> - {repositoryUrl ? ( - - ) : ( - - {detail.repository} - - )} + + {detail.repository} + -

+ {detail.title} -

+ + {conflicting ? ( + + + Conflicts + + ) : checksSummary ? ( + + {detail && checksState !== null ? ( + + ) : null} + {checksSummary} + + ) : null} ) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "resolve" ? ( - - } - > - - {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} - - ) : primaryAction === "ready" ? ( + {primaryAction === "ready" ? ( @@ -1408,86 +1394,113 @@ 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.author?.login ?? "ghost"} - {formatRelativeTimeLabel(detail.updatedAt)} - - - - - {isStackedPullRequest ? ( - - ) : null} - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" - /> - ) : null} - - {detail.headBranch} - - - + + + {detail.baseBranch} + {freshness ? ( + void perform("update-branch", undefined, method)} + iconClassName="size-3" /> + ) : 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 ? (

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

) : null} -
-
- {detail ? ( - - ) : null} + + {detail ? ( + + ) : 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, @@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.error && !detail ? ( + {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 ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..09b79cf340e6 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,11 +45,13 @@ export function PullRequestListGhost({
- +
- +
))} @@ -57,101 +59,32 @@ export function PullRequestListGhost({ ); } -/** - * 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. - */ +/** The summary's own shape: a title, a byline, the facts rows, the description. */ export function PullRequestDetailGhost() { return (
-
-
-
- - -
-
- - -
-
- -
- -
- - -
-
- - - -
- - -
-
-
- -
-
- - - -
- -
+
+ +
- -
-
-
-
- - -
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - -
- -
-
- -
-
- +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
-
- - - - -
-
+ ))} +
+
+ + + +
); @@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -201,8 +134,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 04fee465b506..3066eafc38a1 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,7 +25,6 @@ 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, @@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({ return ( - } + className={cn( + // The icon-button size that pairs with a full-height input, so the two read as one strip. + "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8", + filtered && "text-foreground", + )} + aria-label="Filter pull requests" > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 29566e048d10..a57f2a4d1602 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 9c36d32ff51a..a472c6a8d3d7 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 1618f5045eb8..174c9e9fe97c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { ArchiveIcon, + ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -18,7 +19,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -33,7 +34,6 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; -import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,6 +72,7 @@ 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(""); @@ -175,6 +176,17 @@ 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 ( <> @@ -284,7 +296,14 @@ 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 5399d071be9d..7e4b80d19511 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 }); @@ -250,12 +247,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 8fc6b835bf1a..f4a98dec86c7 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,9 +4,8 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; -import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -118,44 +117,16 @@ function T3Wordmark() { ); } -function SidebarUtilityItem({ - icon, - label, - onClick, -}: { - icon: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - - ); -} - -export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - /^\/settings(?:\/|$)/.test(location.pathname) - ? "settings" - : location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + 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 @@ -186,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const handleBackClick = useCallback(() => { closeMobileSidebar(); - if (canGoBack) { - window.history.back(); - return; - } void navigate({ to: "/" }); - }, [canGoBack, closeMobileSidebar, navigate]); - - return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - } - label="Settings" - onClick={handleSettingsClick} - /> - {pullRequestsSupported ? ( - } - label="Pull Requests" - onClick={handlePullRequestsClick} - /> - ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> - - )} - - - ); -}); + }, [closeMobileSidebar, navigate]); -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 477bc9c02630..93dc653e7c0a 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,12 +19,6 @@ 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( @@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = allIds({ ...baseState, branch: "feat/menu" }); + const withBranch = ids({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(allIds(baseState)).not.toContain("new-thread-on-branch"); - expect(allIds(baseState)).not.toContain("copy-branch"); + expect(ids(baseState)).not.toContain("new-thread-on-branch"); + expect(ids(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 1218e2dd58cb..ef4b38dcdacd 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,7 +18,6 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" - | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -54,15 +53,14 @@ 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", icon: "pin-off" } - : { id: "pin" as const, label: "Pin thread", icon: "pin" }, + ? { id: "unpin" as const, label: "Unpin thread" } + : { id: "pin" as const, label: "Pin thread" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -71,18 +69,17 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } - : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, + ? { id: "unsettle" as const, label: "Un-settle thread" } + : { id: "settle" as const, label: "Settle thread" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } + ? { id: "unsnooze" as const, label: "Wake thread" } : { id: "snooze" as const, label: "Snooze", - icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -91,37 +88,20 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, + { id: "rename", label: "Rename thread" }, ...(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", 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, - }, + { 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" }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx deleted file mode 100644 index 29b91e18bb40..000000000000 --- a/apps/web/src/components/ui/segmented-tabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 7173eab140ec..5bf04adf41a1 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,10 +18,6 @@ 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", }, @@ -31,8 +27,6 @@ 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 b9bebadc00e7..7a5cdd883db2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,22 +19,13 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; import { SidebarInset } from "../ui/sidebar"; -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"; +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"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -75,6 +66,21 @@ 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, @@ -94,66 +100,78 @@ 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 (
- - {topbarContent} - + {!isElectron && ( +
+ + Usage + +
+ )} + + {isElectron && ( +
+ + Usage + +
+ )} - +
+
+

+ {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} - + ) : ( <> @@ -163,62 +181,88 @@ 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" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
- {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" - }`; + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( -
-
- - - - {PROVIDER_LABEL[provider]} - - {sessionLabel} - - +
+
+ + + {PROVIDER_LABEL[provider.provider]} - + {metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)}
+
+
+
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
); })}
-
-

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

+
+
+

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

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

Totals

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

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) => ( - setBreakdown(option.value)} + className={cn( + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", + option.value === breakdown + ? "bg-muted text-foreground" + : "text-muted-foreground hover:text-foreground", + )} > {option.label} - + ))} - +
{breakdown === "model" ? ( @@ -291,7 +356,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -338,7 +403,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -368,7 +433,7 @@ export function UsagePage() {
)} - +
@@ -387,11 +452,20 @@ function ProviderMark({ return ; } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { return ( -
+
{label} - {value} + {value} + {detail}
); } @@ -495,51 +569,70 @@ 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. No shimmer; blocks fill in - * exactly once when the last device answers. + * 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. */ -function UsageSkeleton() { +function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { 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) => ( +
+ ))} +
-
-

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
- ), - )} -
+
+ {["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 963c28fe6a01..f41945bfe286 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, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,7 +68,13 @@ function buildPeriodColumns( }); } -/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ +/** + * 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. + */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } +/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -116,6 +123,7 @@ 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); @@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[]): string { +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { const first = segments[0]; if (first === undefined) return ""; - let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `${startCommand}${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)}`; } @@ -171,8 +179,10 @@ 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: each provider is drawn from the same - * zero baseline so the chart never implies that one provider is always larger. + * 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. * * 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 @@ -206,39 +216,42 @@ 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, series, stepX, ticks, toY } = useMemo(() => { + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { paths: [], - series: [] as readonly DayColumn[], - stepX: 0, ticks: [0] as readonly number[], + stepX: 0, 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 line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), + const curve = smoothCurve( + columns.map((column, dayIndex) => ({ + x: dayIndex * 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), @@ -247,65 +260,30 @@ export function UsageProviderChart({ }; }); - return { - paths: built.toSorted((a, b) => b.total - a.total), - series: columns, - stepX: step, - ticks: tickValues, - toY, - }; + // 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 }; }, [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 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 bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; + const fraction = (event.clientX - bounds.left) / 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, positionTooltip], + [periods.length], ); 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) => @@ -333,10 +311,7 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => { - hoverPositionRef.current = null; - setHoverIndex(null); - }} + onMouseLeave={() => setHoverIndex(null)} > ( ))} @@ -392,11 +368,10 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -443,3 +418,21 @@ 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 3ec171859027..f8b65877dcf4 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Stable provider reading order across summaries, tables, and hover rows. + * 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. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 4bc3237d2a66..769826e3999c 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,15 +4,6 @@ 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", @@ -26,71 +17,6 @@ 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"; @@ -331,12 +247,10 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - 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); - } + const chevron = document.createElement("span"); + chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; + chevron.textContent = ">"; + button.appendChild(chevron); } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bd49f53702cd..4e636eb4ff0f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,22 +241,6 @@ 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 @@ -447,62 +431,6 @@ 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 @@ -1421,16 +1349,15 @@ 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. 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. */ + 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. */ 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-control-foreground); - color: var(--toolbar-control-foreground); + --control-icon-color: var(--toolbar-foreground); + color: var(--toolbar-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 5bfb80bfec32..0b7e6bf0f970 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,17 +118,6 @@ 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 0e1fdc9f884c..803ba787116b 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain(''); + 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).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 271715be3ca1..4f4da0c751ef 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,7 +8,6 @@ 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, @@ -18,6 +17,8 @@ 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(); @@ -142,13 +143,18 @@ 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 7d73a225d5a0..66d9f0caa5da 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,12 +74,6 @@ 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"; @@ -105,6 +99,7 @@ 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; @@ -1186,17 +1181,6 @@ 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); @@ -1310,10 +1294,9 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelAvailable} + rightPanelAvailable={rightPanelState.surfaces.length > 0} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} - rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1620,6 +1603,7 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} + chromeVariant="collapse" /> ) : null} @@ -1843,10 +1827,18 @@ 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 @@ -1888,24 +1880,27 @@ function PullRequestsColumn({ )}
{condensed ? ( -
- { - topbarSearchFocusedRef.current = focused; - }} - /> - -
- ) : null} - {rightPanelControl ? ( - {rightPanelControl} + { + topbarSearchFocusedRef.current = focused; + }} + /> ) : null} - + + {rightPanelControl} +
+
{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 431e196de8b1..a4b248c84ed9 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,8 +13,9 @@ 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); @@ -71,16 +72,41 @@ function SettingsContentLayout() { return (
- -
- - {showRestoreDefaults ? ( -
- -
- ) : null} + {!isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
+
+ )} + + {isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
- + )}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2eadd1fc5fb5..f5effff6602c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("shows a command from its start event while it is still running", () => { + it("omits tool started entries and keeps completed entries", () => { 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: "Command run", - kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "completed", - title: "Command run", - }, - }), - makeActivity({ - id: "other-tool-complete", - createdAt: "2026-02-23T00:00:04.000Z", - summary: "Other command", + summary: "Tool call complete", kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-2", - status: "completed", - title: "Other command", - }, }), - ]; - - const entries = deriveWorkLogEntries(activities); - 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", + id: "tool-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", + summary: "Tool call", 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", - ]); + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1359,7 +1239,6 @@ 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 efe1876dfc1c..4d0a76cf133b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,8 +65,6 @@ 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; @@ -750,6 +748,7 @@ 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 @@ -758,13 +757,8 @@ 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)); } @@ -775,11 +769,7 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if ( - activity.kind !== "tool.started" && - activity.kind !== "tool.updated" && - activity.kind !== "tool.completed" - ) { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } @@ -790,28 +780,6 @@ 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 { @@ -910,9 +878,6 @@ 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"; } @@ -968,17 +933,6 @@ 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[] { @@ -995,7 +949,6 @@ 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 && @@ -1040,40 +993,12 @@ 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)) { - 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); - } + collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); continue; } collapsed.push(entry); - if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); - } } return collapsed; } @@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if ( - previous.activityKind !== "tool.started" && - previous.activityKind !== "tool.updated" && - previous.activityKind !== "tool.completed" - ) { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { return false; } - if ( - next.activityKind !== "tool.started" && - next.activityKind !== "tool.updated" && - next.activityKind !== "tool.completed" - ) { + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } if (previous.activityKind === "tool.completed") { @@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if ( - entry.activityKind !== "tool.started" && - entry.activityKind !== "tool.updated" && - entry.activityKind !== "tool.completed" - ) { + if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } if (entry.toolCallId) { @@ -1370,8 +1283,6 @@ 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[] = [ @@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, - dataInput?.command, - stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); + return 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 f7a6412d51db..b0b1df96e1fe 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -249,8 +248,6 @@ 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( @@ -263,11 +260,6 @@ 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 545e195a1287..290ca8e5954c 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,11 +32,8 @@ 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, @@ -53,32 +50,8 @@ 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, - ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 - ? { terminalCustomLabelsByThreadKey } - : {}), - }; + return { terminalUiStateByThreadKey }; } function createTerminalUiStateStorage() { @@ -516,18 +489,6 @@ 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, @@ -601,7 +562,6 @@ 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; @@ -615,11 +575,6 @@ 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; @@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { - terminalId: string; - suppressed: boolean; - clearCustomLabel?: boolean; - }, - pruneCustomLabels = false, + suppression?: { terminalId: string; suppressed: boolean }, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -659,57 +609,21 @@ 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 && - nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, - terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -768,56 +682,22 @@ 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)), - ); - }, - undefined, - true, - ), + 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)), + ); + }), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds && - !hadCustomLabels + !hadSuppressedTerminalIds ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { + if (!hadTerminalUiState && !hadSuppressedTerminalIds) { return state; } return { @@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -878,7 +747,6 @@ 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)), ); @@ -889,17 +757,12 @@ 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, }; }), @@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 5, + version: 4, 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 03451cc7b2ec..09d7d7a4602a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,8 +111,6 @@ 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[]; } @@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; - readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -134,7 +131,6 @@ 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 81270ad320cb..c2fa9e2a86a1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,7 +248,6 @@ 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 f5e54434fd97..886b214183bc 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,7 +25,6 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; - readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { - readonly buckets: readonly UsageBucket[]; - readonly sessionsByProvider: ReadonlyMap; -} { +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { const ownedProviders = new Set(); - const sessionsByProvider = new Map(); + let sessions = 0; for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - const provider = source.fingerprint.provider; - ownedProviders.add(provider); + ownedProviders.add(source.fingerprint.provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessionsByProvider.set( - provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, - ); + sessions += source.distinctSessions; } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessionsByProvider, + sessions, }; } @@ -236,7 +228,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + { costUsd: number; totalTokens: number; records: number } >(); const modelAccumulator = new Map< string, @@ -263,20 +255,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - - 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); - } + sessions += environmentSessions; for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -296,7 +280,6 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, - sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -358,7 +341,6 @@ 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, }))