Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/web/src/components/sidebar/SidebarBackgroundWorkRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { buildThreadRouteParams } from "../../threadRoutes";
import { cn } from "../../lib/utils";
import {
SidebarCaretToggle,
SidebarCaretSlot,
SidebarThreadColumns,
PROJECTS_WORK_ROW_HEIGHT,
} from "./SidebarColumns";
Expand Down Expand Up @@ -246,6 +247,7 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps)
) : null;
const content = props.columns ? (
<SidebarThreadColumns
caret={sameProgram ? <SidebarCaretSlot count={sameProgram} /> : undefined}
location={locationIcon}
role={roleIcon}
provider={icon}
Expand Down Expand Up @@ -293,7 +295,6 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps)
<ThreadPullRequestBadge thread={row.child} />
) : undefined
}
count={sameProgram ?? (toggle && !toggle.open ? toggle.count : undefined)}
time={time}
status={<ThreadStatusMark status={status} />}
/>
Expand Down Expand Up @@ -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,
)}
Expand Down Expand Up @@ -465,6 +466,7 @@ export function SidebarBackgroundWorkRows(props: SidebarBackgroundWorkRowsProps)
{toggle ? (
<SidebarCaretToggle
open={toggle.open}
count={props.columns ? toggle.count : undefined}
label={`${toggle.open ? "Hide" : "Show"} ${toggle.count} running ${toggle.count === 1 ? "task" : "tasks"} under ${row.label}`}
onToggle={toggle.onToggle}
className="top-0.5 h-5"
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/components/sidebar/SidebarColumns.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,30 @@
align-items: center;
overflow: hidden;
}
@utility sidebar-thread-count {
font-size: 0.6875rem;
text-align: right;
.sidebar-caret-count {
font-size: 0.625rem;
font-variant-numeric: tabular-nums;
color: var(--muted-foreground);
}
/* Overlay toggles are siblings of their row; match only that row, never its descendants. */
.sidebar-caret-row:hover ~ .sidebar-caret-toggle {
& .sidebar-caret-slot[data-count] {
.sidebar-caret-count {
display: none;
}
.sidebar-caret-icon {
display: flex;
}
}
}
/* One caret width for the overlay toggle and the in-row slot, so both carets sit at the same x. */
.sidebar-caret-toggle > span,
.sidebar-thread-caret > span {
width: var(--sidebar-caret-width, 1rem);
}
/* 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 {
Expand Down
23 changes: 8 additions & 15 deletions apps/web/src/components/sidebar/SidebarColumns.layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@
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;
--sidebar-model-width: 5.65ch;
--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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -106,9 +102,6 @@
.sidebar-thread-spacer {
grid-column: spacer;
}
.sidebar-thread-count {
grid-column: count;
}
.sidebar-thread-issue {
grid-column: issue;
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
31 changes: 18 additions & 13 deletions apps/web/src/components/sidebar/SidebarColumns.layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/sidebar/SidebarColumns.layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/sidebar/SidebarColumns.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
4 changes: 4 additions & 0 deletions apps/web/src/components/sidebar/SidebarColumns.logic.ts
Original file line number Diff line number Diff line change
@@ -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);
}
53 changes: 44 additions & 9 deletions apps/web/src/components/sidebar/SidebarColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span aria-hidden className={cn(CARET_SLOT_CLASS, props.className)}>
{props.open === undefined ? props.children : <SidebarCaret open={props.open} />}
const count = sidebarCaretCount(props.count, props.open);
const slot = (
<span
aria-hidden
className={cn(CARET_SLOT_CLASS, "sidebar-caret-slot", props.className)}
data-count={count === undefined ? undefined : ""}
>
{count === undefined ? null : (
<span
className={cn(
"sidebar-caret-count",
props.open !== undefined &&
"group-hover/sidebar-caret:hidden group-focus/sidebar-caret:hidden",
)}
>
{count}
</span>
)}
{props.open === undefined ? (
props.children
) : (
<span
className={cn(
"sidebar-caret-icon",
count !== undefined &&
"hidden group-hover/sidebar-caret:flex group-focus/sidebar-caret:flex",
)}
>
<SidebarCaret open={props.open} />
</span>
)}
</span>
);
return props.count === undefined ? (
slot
) : (
<Tooltip>
<TooltipTrigger render={slot} />
<TooltipPopup side="right">{props.count}</TooltipPopup>
</Tooltip>
);
}

/**
Expand All @@ -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;
Expand All @@ -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}
>
<span className={CARET_SLOT_CLASS}>
<SidebarCaret open={props.open} />
</span>
<SidebarCaretSlot open={props.open} count={props.count} />
</button>
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -164,7 +200,6 @@ export function SidebarThreadColumns(props: {
<span className={cn("sidebar-thread-name", props.wideName && "sidebar-thread-name-wide")}>
{props.name}
</span>
<span className={cn("sidebar-thread-count", props.trailingClassName)}>{props.count}</span>
{columns.issue !== false ? (
<span className={cn("sidebar-thread-badge sidebar-thread-issue", props.trailingClassName)}>
{props.issue}
Expand Down
Loading