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