diff --git a/apps/web/src/components/sidebar/SidebarBackgroundWorkRows.tsx b/apps/web/src/components/sidebar/SidebarBackgroundWorkRows.tsx index 4afa8ac26ee70..3ece3a98bcd69 100644 --- a/apps/web/src/components/sidebar/SidebarBackgroundWorkRows.tsx +++ b/apps/web/src/components/sidebar/SidebarBackgroundWorkRows.tsx @@ -44,6 +44,7 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import { cn } from "../../lib/utils"; import { SidebarCaretToggle, + SidebarCaretSlot, SidebarThreadColumns, PROJECTS_WORK_ROW_HEIGHT, } from "./SidebarColumns"; @@ -246,6 +247,7 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps) ) : null; const content = props.columns ? ( : undefined} location={locationIcon} role={roleIcon} provider={icon} @@ -293,7 +295,6 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps) ) : undefined } - count={sameProgram ?? (toggle && !toggle.open ? toggle.count : undefined)} time={time} status={} /> @@ -330,7 +331,7 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps) environmentId={props.environmentId} threadId={childThreadId} className={cn( - "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left hover:bg-accent", + "sidebar-caret-row flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left hover:bg-accent", PROJECTS_WORK_ROW_HEIGHT, rowClass, )} @@ -465,6 +466,7 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps) {toggle ? ( span, .sidebar-thread-caret > span { @@ -81,7 +91,7 @@ } /* Rows without a model (commands, processes) start their name at the model's x. */ .sidebar-thread-name-wide { - grid-column: model / count; + grid-column: model / issue; } /* Icon role display hides a title that only repeats the role; the role icon names it on hover. */ [data-sidebar-role-display="icon"] .sidebar-role-title { diff --git a/apps/web/src/components/sidebar/SidebarColumns.layout.css b/apps/web/src/components/sidebar/SidebarColumns.layout.css index 81f01f8810efd..1f15db2f7681e 100644 --- a/apps/web/src/components/sidebar/SidebarColumns.layout.css +++ b/apps/web/src/components/sidebar/SidebarColumns.layout.css @@ -15,7 +15,7 @@ initial-value: 0px; } @utility sidebar-columns-container { - --sidebar-caret-width: 0.75rem; + --sidebar-caret-width: 1.5rem; --sidebar-location-width: 0.75rem; --sidebar-role-width: 0.75rem; --sidebar-provider-width: 1rem; @@ -23,7 +23,6 @@ --sidebar-version-width: 2.35ch; --sidebar-effort-width: 2.5rem; --sidebar-name-width: 11ch; - --sidebar-count-width: 1rem; --sidebar-issue-width: 2.25rem; --sidebar-pr-width: 2.25rem; --sidebar-time-width: 4.4375rem; @@ -58,10 +57,7 @@ ) [name] minmax(0, var(--sidebar-name-track, max-content)) var( --sidebar-name-gap, var(--sidebar-column-gap) - ) [spacer] minmax(0, 1fr) var(--sidebar-spacer-gap, var(--sidebar-column-gap)) [count] var( - --sidebar-count-track, - var(--sidebar-count-width) - ) var(--sidebar-count-gap, var(--sidebar-column-gap)) [issue] var( + ) [spacer] minmax(0, 1fr) var(--sidebar-spacer-gap, var(--sidebar-column-gap)) [issue] var( --sidebar-issue-track, var(--sidebar-issue-width) ) var(--sidebar-issue-gap, var(--sidebar-column-gap)) [pr] var( @@ -106,9 +102,6 @@ .sidebar-thread-spacer { grid-column: spacer; } -.sidebar-thread-count { - grid-column: count; -} .sidebar-thread-issue { grid-column: issue; } @@ -121,7 +114,7 @@ .sidebar-thread-work { grid-column: work; } -@container sidebar-columns-name (width < calc(16.8125rem + 36px + 19ch)) { +@container sidebar-columns-name (width < calc(16.5625rem + 33px + 19ch)) { .sidebar-thread-columns { --sidebar-effort-track: 0px; --sidebar-effort-gap: 0px; @@ -130,7 +123,7 @@ display: none; } } -@container sidebar-columns-name (width < calc(14.3125rem + 33px + 19ch)) { +@container sidebar-columns-name (width < calc(14.0625rem + 30px + 19ch)) { .sidebar-thread-columns { --sidebar-model-track: 0px; --sidebar-model-gap: 0px; @@ -142,7 +135,7 @@ display: none; } } -@container sidebar-columns-name (width < calc(14.3125rem + 27px + 11ch)) { +@container sidebar-columns-name (width < calc(14.0625rem + 24px + 11ch)) { .sidebar-thread-columns { --sidebar-issue-track: 0px; --sidebar-issue-gap: 0px; @@ -154,7 +147,7 @@ display: none; } } -@container sidebar-columns (width < calc(17.5625rem + 39px + 12ch)) { +@container sidebar-columns (width < calc(17.3125rem + 36px + 12ch)) { .sidebar-thread-columns { --sidebar-effort-track: 0px; --sidebar-effort-gap: 0px; @@ -163,7 +156,7 @@ display: none; } } -@container sidebar-columns (width < calc(15.0625rem + 36px + 12ch)) { +@container sidebar-columns (width < calc(14.8125rem + 33px + 12ch)) { .sidebar-thread-columns { --sidebar-model-track: 0px; --sidebar-model-gap: 0px; @@ -175,7 +168,7 @@ display: none; } } -@container sidebar-columns (width < calc(15.0625rem + 30px + 4ch)) { +@container sidebar-columns (width < calc(14.8125rem + 27px + 4ch)) { .sidebar-thread-columns { --sidebar-issue-track: 0px; --sidebar-issue-gap: 0px; diff --git a/apps/web/src/components/sidebar/SidebarColumns.layout.test.ts b/apps/web/src/components/sidebar/SidebarColumns.layout.test.ts index 0acc5fab6211c..240e046ae09c5 100644 --- a/apps/web/src/components/sidebar/SidebarColumns.layout.test.ts +++ b/apps/web/src/components/sidebar/SidebarColumns.layout.test.ts @@ -34,27 +34,27 @@ const minimumTitlePx = (sidebarRem: number) => { describe("sidebar width budget", () => { it("budgets for the full role name before admitting optional columns", () => { expect(optionalColumns(16)).toEqual([]); - expect(optionalColumns(23.8)).toEqual([]); - expect(optionalColumns(24)).toEqual(["issue"]); - expect(optionalColumns(28)).toEqual(["issue"]); - expect(optionalColumns(28.5)).toEqual(["model", "issue"]); - expect(optionalColumns(30.8)).toEqual(["model", "issue"]); - expect(optionalColumns(31.1)).toEqual(["model", "effort", "issue"]); + expect(optionalColumns(23.5)).toEqual([]); + expect(optionalColumns(23.6)).toEqual(["issue"]); + expect(optionalColumns(27.9)).toEqual(["issue"]); + expect(optionalColumns(28)).toEqual(["model", "issue"]); + expect(optionalColumns(30.6)).toEqual(["model", "issue"]); + expect(optionalColumns(30.7)).toEqual(["model", "effort", "issue"]); }); it("admits optional columns sooner with role icons, keeping room for short command labels", () => { - expect(optionalColumns(21.3, "icon")).toEqual([]); - expect(optionalColumns(21.6, "icon")).toEqual(["issue"]); - expect(optionalColumns(25.7, "icon")).toEqual(["issue"]); - expect(optionalColumns(26, "icon")).toEqual(["model", "issue"]); - expect(optionalColumns(28.6, "icon")).toEqual(["model", "effort", "issue"]); - for (const rem of [21.6, 26, 28.6]) { + expect(optionalColumns(20.9, "icon")).toEqual([]); + expect(optionalColumns(21, "icon")).toEqual(["issue"]); + expect(optionalColumns(25.3, "icon")).toEqual(["issue"]); + expect(optionalColumns(25.4, "icon")).toEqual(["model", "issue"]); + expect(optionalColumns(28.1, "icon")).toEqual(["model", "effort", "issue"]); + for (const rem of [21, 25.4, 28.1]) { expect(optionalColumns(rem, "icon").length).toBeGreaterThan(optionalColumns(rem).length); } }); it("never overflows the default 16rem sidebar once every optional column has dropped", () => { - expect(minimumTitlePx(16)).toBeGreaterThan(0); + expect(minimumTitlePx(16)).toBeGreaterThan(40); }); }); @@ -73,6 +73,7 @@ it("keeps the committed CSS equal to generated CSS apart from formatter whitespa const ADVANCES = new Map( Object.entries({ " ": 801, + "+": 1571, ".": 603, A: 1710, G: 1614, @@ -114,6 +115,10 @@ const fits = (labels: readonly string[], sizePx: number, trackPx: number) => { }; const { tracks } = sidebarColumnLayout; +it("fits two-digit counts and their capped label inside the caret track", () => { + fits(["1", "16", "99", "99+"], 10, tracks.caret * 16); +}); + it("fits every elapsed label up to ten days inside the time track", () => { // From 100 hours the label switches to days, so hours never grow a third digit; two-digit days // overrun the track by a fraction of a pixel. diff --git a/apps/web/src/components/sidebar/SidebarColumns.layout.ts b/apps/web/src/components/sidebar/SidebarColumns.layout.ts index 351e6c6d8b648..bc9835a730738 100644 --- a/apps/web/src/components/sidebar/SidebarColumns.layout.ts +++ b/apps/web/src/components/sidebar/SidebarColumns.layout.ts @@ -9,7 +9,7 @@ const labelCh = 4; export const sidebarColumnLayout = { tracks: { - caret: 0.75, + caret: 1.5, location: 0.75, role: 0.75, provider: 1, @@ -19,12 +19,10 @@ export const sidebarColumnLayout = { version: { ch: 2.35 }, effort: 2.5, // A role-name title: "Orchestrator" is 10.625ch at 12px in OpenDyslexic Bold, plus a narrow - // space. Other titles take their text's width. Spare width goes to the spacer so counts, + // space. Other titles take their text's width. Spare width goes to the spacer so // badges, time and status stay right-aligned. name: { ch: titleCh }, spacer: 0, - // A collapsed row's hidden work count, right-aligned just before the badges. - count: 1, // Every row reserves both badge slots, so issue and pull request icons line up whichever it has: // each fits its icon and three digits (an orchestrator can hold 100+ pull requests). issue: 2.25, diff --git a/apps/web/src/components/sidebar/SidebarColumns.logic.test.ts b/apps/web/src/components/sidebar/SidebarColumns.logic.test.ts new file mode 100644 index 0000000000000..e460efb0a7b56 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarColumns.logic.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vite-plus/test"; +import { sidebarCaretCount } from "./SidebarColumns.logic"; + +it("shows a bounded count only while work is closed", () => { + expect(sidebarCaretCount(undefined, false)).toBeUndefined(); + expect(sidebarCaretCount(0, false)).toBeUndefined(); + expect(sidebarCaretCount(1, false)).toBe("1"); + expect(sidebarCaretCount(16, false)).toBe("16"); + expect(sidebarCaretCount(99, false)).toBe("99"); + expect(sidebarCaretCount(100, false)).toBe("99+"); + expect(sidebarCaretCount(1000, true)).toBeUndefined(); + expect(sidebarCaretCount(16)).toBe("16"); +}); diff --git a/apps/web/src/components/sidebar/SidebarColumns.logic.ts b/apps/web/src/components/sidebar/SidebarColumns.logic.ts new file mode 100644 index 0000000000000..8497d6db7c9b8 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarColumns.logic.ts @@ -0,0 +1,4 @@ +export function sidebarCaretCount(count: number | undefined, open?: boolean) { + if (open || count === undefined || count <= 0) return undefined; + return count > 99 ? "99+" : String(count); +} diff --git a/apps/web/src/components/sidebar/SidebarColumns.tsx b/apps/web/src/components/sidebar/SidebarColumns.tsx index 0eb9c180a4881..123b3a05f5f66 100644 --- a/apps/web/src/components/sidebar/SidebarColumns.tsx +++ b/apps/web/src/components/sidebar/SidebarColumns.tsx @@ -2,6 +2,7 @@ import { ChevronRightIcon } from "lucide-react"; import type { CSSProperties, ReactNode } from "react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { splitSidebarModelLabel } from "./SidebarBackgroundWork.logic"; +import { sidebarCaretCount } from "./SidebarColumns.logic"; import { useClientSettings } from "../../hooks/useSettings"; import { cn } from "~/lib/utils"; @@ -34,14 +35,51 @@ export function SidebarCaret(props: { readonly open: boolean }) { */ export function SidebarCaretSlot(props: { readonly open?: boolean | undefined; + readonly count?: number | undefined; readonly className?: string | undefined; readonly children?: ReactNode; }) { - return ( - - {props.open === undefined ? props.children : } + const count = sidebarCaretCount(props.count, props.open); + const slot = ( + + {count === undefined ? null : ( + + {count} + + )} + {props.open === undefined ? ( + props.children + ) : ( + + + + )} ); + return props.count === undefined ? ( + slot + ) : ( + + + {props.count} + + ); } /** @@ -52,6 +90,7 @@ export function SidebarCaretSlot(props: { */ export function SidebarCaretToggle(props: { readonly open: boolean; + readonly count?: number | undefined; readonly label: string; readonly onToggle: () => void; readonly className: string; @@ -62,14 +101,12 @@ export function SidebarCaretToggle(props: { aria-expanded={props.open} aria-label={props.label} className={cn( - "sidebar-caret-toggle absolute flex cursor-pointer items-center rounded px-1.5 text-sidebar-muted-foreground/70 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + "sidebar-caret-toggle group/sidebar-caret absolute flex cursor-pointer items-center rounded px-1.5 text-sidebar-muted-foreground/70 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", props.className, )} onClick={props.onToggle} > - - - + ); } @@ -111,7 +148,6 @@ export function SidebarThreadColumns(props: { readonly name: ReactNode; /** The row has no model or effort, so its name starts in the model's column. */ readonly wideName?: boolean | undefined; - readonly count?: number | undefined; readonly issue?: ReactNode; readonly pr?: ReactNode; readonly trailingClassName?: string | undefined; @@ -164,7 +200,6 @@ export function SidebarThreadColumns(props: { {props.name} - {props.count} {columns.issue !== false ? ( {props.issue} diff --git a/apps/web/src/components/sidebar/SidebarProjectSections.tsx b/apps/web/src/components/sidebar/SidebarProjectSections.tsx index 686ad93068cc0..ec33e6b1f68f5 100644 --- a/apps/web/src/components/sidebar/SidebarProjectSections.tsx +++ b/apps/web/src/components/sidebar/SidebarProjectSections.tsx @@ -1420,7 +1420,7 @@ const SidebarProjectThreadRow = memo( links: props.thread.pullRequests, })} className={cn( - "fork-sidebar-thread group/project-thread @container/thread-row flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 text-left outline-none transition-colors", + "fork-sidebar-thread sidebar-caret-row group/project-thread @container/thread-row flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 text-left outline-none transition-colors", PROJECTS_WORK_ROW_HEIGHT, props.codexStyle ? "sidebar-thread-tint text-xs" : "text-sm", active @@ -1474,7 +1474,6 @@ const SidebarProjectThreadRow = memo( } - count={workCount > 0 && !workOpen ? workCount : undefined} issue={ diff --git a/apps/web/src/components/sidebar/SidebarThreadProcessesGroup.tsx b/apps/web/src/components/sidebar/SidebarThreadProcessesGroup.tsx index d182d545d9365..d2fe75fd31b6f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadProcessesGroup.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadProcessesGroup.tsx @@ -66,12 +66,7 @@ export function SidebarThreadProcessesGroup(props: { fallback={} /> ); - const rowContent = ( - name: ReactElement, - startedAt: string | undefined, - caret?: ReactElement, - count?: number, - ) => { + const rowContent = (name: ReactElement, startedAt: string | undefined, caret?: ReactElement) => { const time = startedAt ? ( ) : null; @@ -82,7 +77,6 @@ export function SidebarThreadProcessesGroup(props: { provider={icon} name={name} wideName - count={count} time={time} status={status} /> @@ -116,10 +110,16 @@ export function SidebarThreadProcessesGroup(props: { const content = rowContent( groupName, group.startedAt, - expandable ? : undefined, - group.count > 1 ? group.count : undefined, + expandable ? ( + + ) : group.count > 1 && props.columns ? ( + + ) : undefined, + ); + const buttonClass = cn( + props.className, + "group/sidebar-caret w-full cursor-pointer text-left hover:bg-accent", ); - const buttonClass = cn(props.className, "w-full cursor-pointer text-left hover:bg-accent"); const single = group.count === 1 ? members[0] : undefined; return ( <> @@ -129,7 +129,11 @@ export function SidebarThreadProcessesGroup(props: { type="button" className={buttonClass} aria-expanded={open} - aria-label={`${open ? "Hide" : "Show"} ${group.label} processes`} + aria-label={ + props.columns + ? `${open ? "Hide" : "Show"} ${group.count} ${group.label} processes` + : `${open ? "Hide" : "Show"} ${group.label} processes` + } onClick={() => setExpanded([key], !open)} > {content} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3a69c5b89d90e..82f2b99242c32 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -349,6 +349,10 @@ as do processes, such as `python ×10`, and a program already listed as a comman listed again. Each thread shows up to five process groups, with their elapsed time and running mark. Expand a group to inspect up to 20 individual processes; click a process for its command (up to 500 characters), PID, start time, CPU and memory. Group expansion is remembered. +In the Codex-style sidebar, a collapsed thread's hidden work count and a collapsed process group's +member count appear at the far left. Hover the row or focus its toggle to reveal the caret; +expanded rows show a downward caret. Counts above 99 show `99+`, with the full count on hover +and in the toggle's accessible label. In the Codex-style Projects view, a settled thread shows **Waiting on background work** while it has running tasks, commands or sampled processes. An unseen completion keeps its green mark until you open the thread; completion notifications