Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
45 changes: 45 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@
Working is emerald, deliberately diverging from the sky the mobile Live
Activity still uses; mobile has not been migrated.

A card is three lines only when it has a PR or a diff for the third; with
neither, the model and runtime move up beside the branch and the card closes
at the design's two-line 64px. Nothing else varies the height: an absent
branch or model leaves its half of a row empty rather than closing the row
up. The li's contain-intrinsic-size is chosen by the same predicate the
component renders from, so it can never claim a height the row does not have.

Diff counts bind to --success-foreground / --destructive-foreground rather
than emerald/red literals, so they hold up in light mode. They render only
once latestTurnDiff() returns data: it is an upstream stub returning null
Expand All @@ -295,6 +302,44 @@
- apps/web/src/__fork_guards__/sidebarV2Rain.test.ts
- apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts

- id: sidebar-v2-project-grouping
intent: >
Sidebar V2's active cards can be grouped under project headers, from a
switch at the top of the fork's project scope row. Upstream's flat list
stays the default, and the preference is device-local (localStorage) rather
than a field in packages/contracts.

Only the active cards group. The snoozed and settled shelves stay flat, and
grouping is skipped entirely while a project scope is set.

Group order is projectGroups' own order and thread order inside a group is
the order it arrives in, so grouping introduces no second notion of sorting
and cannot disagree with the user's project sort order.

orderedThreads is rebuilt from the grouped order. It backs the number-key
jump labels, arrow navigation and shift-range selection, so a flat ordered
list under a grouped DOM misaddresses all three.

A thread whose project resolves to no group collects under a trailing
"Other" header. It is never dropped, and never trails headerless under the
previous project.

A grouped card is passed no projectTitle, because the header above it
carries the name; rows in the ungrouped sections still name their project.

The header is static text rather than a collapsible button, and carries a
16px folder mark and no trailing hairline.
tier: 4
files:
- apps/web/src/custom/sidebarV2ProjectGrouping.ts
- apps/web/src/custom/SidebarV2ProjectGroupHeader.tsx
- apps/web/src/custom/SidebarV2ChromeRows.tsx
shadows: []
watch:
- apps/web/src/components/SidebarV2.tsx
verify:
- apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts

- id: fork-surface-palette
intent: >
The dark shell is not black. The workspace stage is #212121 and the sidebar
Expand Down
25 changes: 25 additions & 0 deletions .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ Related deep-dives that predate this file and stay where they are:
- The status-mark vocabulary (form carries the state, hue reinforces it) is taken from the
phanttom Ghostty sidebar design. Working takes emerald from that design rather than the sky the
mobile Live Activity uses; the divergence is deliberate and mobile has not been migrated.
- The two-line card is the design's own variant, not a space saving invented here. The third line
exists to carry the PR badge and the diff counts; with neither it is a blank 15px strip under
every card. Worth knowing while reading it: `latestTurnDiff()` is an upstream stub returning
null today, so until shells carry checkpoint summaries the three-line form only appears on
threads with a PR.

## sidebar-v2-project-grouping

- Grouping and the project scope filter answer different questions — "everything, arranged by
repo" versus "only this repo" — which is why grouping is a switch inside the scope menu rather
than another entry in its radio list. It is also why grouping is skipped while a scope is set:
the list is already one project, so the header would only repeat the scope row's own label one
line lower.
- The shelves stay flat because both are time-ordered tails whose value is being short. Slicing a
tail by project turns one header into a dozen sub-headers over one or two rows each.
- The preference is device-local because a client setting would mean a fork-shaped key in
`packages/contracts` — a schema upstream owns and every sync has to carry — for a view
preference that is per-window anyway.
- The header is not collapsible. Upstream's two shelf headers collapse because each hides a tail;
a project header sits over the inbox, where nothing wants hiding, and the scope menu one row
above already does what collapsing would. It drops the shelf headers' trailing hairline for the
same reason: a shelf rule is one divider closing off the list above it, while project headers
recur every few rows and a rule at that cadence stripes the panel against the card edges. The
folder mark and the space above it carry the separation instead, and the mark is the scope
menu's own so a header and its menu entry read as the same object.

## fork-sidebar-chrome

Expand Down
24 changes: 21 additions & 3 deletions apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { describe, expect, it } from "vite-plus/test";

import { SidebarV2IdleMark } from "../custom/SidebarV2StatusIndicator";
import { threadCardTitleRecedes } from "../custom/sidebarV2RowPolicy";
import { SidebarV2ThreadCardMeta } from "../custom/SidebarV2ThreadCardMeta";
import { SidebarV2ThreadCardMeta, threadCardShowsMetaRow } from "../custom/SidebarV2ThreadCardMeta";

function readSibling(relativePath: string): string {
return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
Expand Down Expand Up @@ -92,11 +92,29 @@ describe("fork guard: sidebar-v2-card-rows", () => {
expect(sidebarV2).toContain("<SidebarV2IdleMark />");
});

it("reserves the card's drawn height for offscreen rows", () => {
it("collapses to two lines only when there is no PR and no diff", () => {
// The third line exists to carry the PR badge and the diff counts. With
// neither, drawing it leaves a blank 15px strip under every card.
const show = threadCardShowsMetaRow;
expect(show({ hasPr: false, insertions: null, deletions: null })).toBe(false);
expect(show({ hasPr: true, insertions: null, deletions: null })).toBe(true);
expect(show({ hasPr: false, insertions: 3, deletions: null })).toBe(true);
expect(show({ hasPr: false, insertions: null, deletions: 3 })).toBe(true);
// Zero is a real count — "+0 −0" is a turn that touched nothing, not a
// thread with no diff at all.
expect(show({ hasPr: false, insertions: 0, deletions: 0 })).toBe(true);
});

it("reserves each card's drawn height for offscreen rows", () => {
// content-visibility skips offscreen rows; the intrinsic size is what keeps
// the scrollbar honest while they are skipped. A stale value here makes the
// list jump as you scroll — three lines are 86px, not the old two-line 76.
// list jump as you scroll, so both heights are pinned: three lines are 86px
// (24 padding + 18 + 15 + 15 rows + 2x7 gaps), two are 64.
expect(sidebarV2).toContain("[contain-intrinsic-size:auto_86px]");
expect(sidebarV2).toContain("[contain-intrinsic-size:auto_64px]");
// And the choice is made from the same predicate the component renders
// from, so the hint cannot drift from the row count it describes.
expect(sidebarV2).toContain("threadCardShowsMetaRow({");
});

it("binds diff counts to semantic tokens rather than palette literals", () => {
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Fork guard — see `.fork/README.md` §4b and
* `.fork/customizations.yaml#sidebar-v2-project-grouping`.
*
* Grouping re-buckets the active cards, so the one thing an upstream rewrite of
* SidebarV2 can silently break without breaking the render is the ordered
* thread list: it backs the jump labels, arrow navigation and range selection,
* and nothing about a grouped list *looks* wrong when that list still holds the
* flat order. Most of this file is about that seam.
*/

import * as NodeFS from "node:fs";
import * as NodeURL from "node:url";
import { describe, expect, it } from "vite-plus/test";

import { UNGROUPED_PROJECT_KEY, groupThreadsByProject } from "../custom/sidebarV2ProjectGrouping";

function readSibling(relativePath: string): string {
return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}

const sidebar = readSibling("../components/SidebarV2.tsx");
const chromeRows = readSibling("../custom/SidebarV2ChromeRows.tsx");

describe("fork guard: sidebar-v2-project-grouping", () => {
it("offers the switch from the project scope menu", () => {
expect(chromeRows).toContain("Group by project");
expect(chromeRows).toContain("onGroupByProjectChange");
// Toggling a view preference must not dismiss the menu it lives in.
expect(chromeRows).toContain("closeOnClick={false}");
expect(sidebar).toContain("groupByProject={groupByProject}");
});

it("drives keyboard order from the grouped order, not the flat one", () => {
// The whole point of the fenced hunk: orderedThreads is what jump hints,
// arrow nav and shift-range selection read, so it has to be the order rows
// are painted in.
expect(sidebar).toContain("[...orderedActiveThreads, ...visibleSnoozedThreads");
const definition = sidebar.indexOf("const orderedActiveThreads");
const use = sidebar.indexOf("[...orderedActiveThreads,");
expect(definition).toBeGreaterThanOrEqual(0);
expect(use).toBeGreaterThan(definition);
});

it("groups the active cards only, and not while a project scope is set", () => {
expect(sidebar).toContain("groupByProject && projectScopeKey === null");
// The shelves keep their flat, time-ordered rendering.
expect(sidebar).toContain("for (const thread of renderedSettledThreads)");
expect(sidebar).toContain("for (const thread of visibleSnoozedThreads)");
});

it("drops the card's project name under a project header", () => {
// Otherwise every card repeats the header two rows above it, and the branch
// — the half that actually separates two threads on one project — keeps
// yielding width to it.
// Scoped to the grouped section on purpose: the snoozed and settled shelves
// are not grouped, so their rows have no header to inherit the project
// from and must keep naming it themselves.
expect(sidebar).toContain('activeThreadProjectGroups !== null && section === "active"');
});

it("keeps every thread when a project no longer resolves to a group", () => {
const groups = groupThreadsByProject(
[
{ environmentId: "local", projectId: "p1" },
{ environmentId: "local", projectId: "deleted" },
],
[
{
projectKey: "first",
displayName: "First",
memberProjectRefs: [{ environmentId: "local", projectId: "p1" }],
},
],
);

expect(groups.flatMap((group) => group.threads)).toHaveLength(2);
expect(groups.at(-1)?.projectKey).toBe(UNGROUPED_PROJECT_KEY);
});

it("keeps the caller's project order and each group's thread order", () => {
// Grouping must not become a second sort: upstream already ordered both
// the projects and the threads.
const groups = groupThreadsByProject(
[
{ id: "a", environmentId: "local", projectId: "p1" },
{ id: "b", environmentId: "local", projectId: "p2" },
{ id: "c", environmentId: "local", projectId: "p1" },
],
[
{
projectKey: "second",
displayName: "Second",
memberProjectRefs: [{ environmentId: "local", projectId: "p2" }],
},
{
projectKey: "first",
displayName: "First",
memberProjectRefs: [{ environmentId: "local", projectId: "p1" }],
},
],
);

expect(groups.map((group) => group.projectKey)).toEqual(["second", "first"]);
expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["a", "c"]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two cases re-test groupThreadsByProject orphan retention and project/thread order — already covered (better) in custom/sidebarV2ProjectGrouping.test.ts. the guard's job is the SidebarV2 seam (ordered list wiring, menu toggle, active-only / no-scope gating), not a second copy of the pure function's behavior.

delete the duplicated behavior tests from the guard. keeping them here also encourages pinning implementation strings like the null-sentinel projectTitle check, which locks in the dual-mode design we should be deleting.

});
84 changes: 74 additions & 10 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,20 @@ import {
SidebarV2WorkingRain,
type SidebarV2DotTone,
} from "~/custom/SidebarV2StatusIndicator";
import { SidebarV2ThreadCardMeta } from "~/custom/SidebarV2ThreadCardMeta";
import { SidebarV2ThreadCardMeta, threadCardShowsMetaRow } from "~/custom/SidebarV2ThreadCardMeta";
import { SidebarV2ProjectScopeRow, SidebarV2SearchRow } from "~/custom/SidebarV2ChromeRows";
import {
threadCardTitleClassName,
threadCardTitleRecedes,
threadRowSurfaceClassName,
} from "~/custom/sidebarV2RowPolicy";
/* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */
import { SidebarV2ProjectGroupHeader } from "~/custom/SidebarV2ProjectGroupHeader";
import {
groupThreadsByProject,
useSidebarV2GroupByProject,
} from "~/custom/sidebarV2ProjectGrouping";
/* fork:end sidebar-v2-project-grouping */
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
import { primaryServerProvidersAtom } from "../state/server";
Expand Down Expand Up @@ -822,11 +829,21 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
}

const diff = latestTurnDiff(thread);
// A card is three lines only when it has a PR or a diff to put on the third;
// the hint has to follow, or the scrollbar lies about every skipped row.
const showsMetaRow = threadCardShowsMetaRow({
hasPr: prBadge !== null,
insertions: diff?.insertions ?? null,
deletions: diff?.deletions ?? null,
});

return (
<li
data-thread-item
className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_86px]"
className={cn(
"list-none py-0.5 [content-visibility:auto]",
showsMetaRow ? "[contain-intrinsic-size:auto_86px]" : "[contain-intrinsic-size:auto_64px]",
)}
>
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -986,6 +1003,9 @@ export default function SidebarV2() {
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
/* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */
const [groupByProject, setGroupByProject] = useSidebarV2GroupByProject();
/* fork:end sidebar-v2-project-grouping */
const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } =
useThreadActions();
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
Expand Down Expand Up @@ -1484,9 +1504,31 @@ export default function SidebarV2() {
return routeThread === undefined ? [] : [routeThread];
}, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);

/* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */
// Null means "render flat", which is both the default and what a scoped
// sidebar gets: scoping already reduces the list to one project, so grouping
// it would only repeat the scope row's own label as a header.
const activeThreadProjectGroups = useMemo(
() =>
groupByProject && projectScopeKey === null
? groupThreadsByProject(activeThreads, projectGroups)
: null,
[activeThreads, groupByProject, projectGroups, projectScopeKey],
);
// Grouping reorders the cards, and this list is what the ⌘-jump labels,
// arrow navigation and shift-range selection read. It has to be the order
// the rows are actually painted in, or a jump hint points at another row.
const orderedActiveThreads = useMemo(
() =>
activeThreadProjectGroups === null
? activeThreads
: activeThreadProjectGroups.flatMap((group) => [...group.threads]),
[activeThreadProjectGroups, activeThreads],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this null-as-mode dual model is the structural problem. null means flat, and that sentinel has to stay synchronized with orderedActiveThreads, the projectTitle branch, and the active-items ternary below — three coordinated special cases in an already 2.6k-line file.

code-judo: extract a single section model in custom/sidebarV2ProjectGrouping.ts that always returns ReadonlyArray<{ header: { projectKey; displayName } | null; threads }> — flat/scoped is one section with header: null; grouped is one section per project with a header. Then orderedActiveThreads = sections.flatMap(s => s.threads) and the render path stops branching on null. that deletes this second memo's ternary entirely and shrinks the fenced hunk to a call site (same extraction pattern as ChromeRows / ThreadCardMeta).

);
/* fork:end sidebar-v2-project-grouping */
const orderedThreads = useMemo(
() => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
[activeThreads, visibleSnoozedThreads, renderedSettledThreads],
() => [...orderedActiveThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
[orderedActiveThreads, visibleSnoozedThreads, renderedSettledThreads],
);
const orderedThreadKeys = useMemo(
() =>
Expand Down Expand Up @@ -2215,6 +2257,8 @@ export default function SidebarV2() {
void handleProjectActions(event, project);
}}
onAddProject={openAddProjectCommandPalette}
groupByProject={groupByProject}
onGroupByProjectChange={setGroupByProject}
/>
{/* fork:end fork-sidebar-chrome */}
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1 [scrollbar-gutter:stable]">
Expand Down Expand Up @@ -2285,11 +2329,19 @@ export default function SidebarV2() {
projectCwd={
projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
}
/* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */
// Under a project header the card's repo line would only
// repeat the header two rows up, once per card. The
// branch, which is what actually tells two threads on one
// project apart, takes the whole line instead.
projectTitle={
projectDisplayNameByKey.get(
`${thread.environmentId}:${thread.projectId}`,
) ?? null
activeThreadProjectGroups !== null && section === "active"
? null
: (projectDisplayNameByKey.get(
`${thread.environmentId}:${thread.projectId}`,
) ?? null)
}
Comment on lines 2380 to 2384

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this activeThreadProjectGroups !== null && section === "active" branch is the same dual-mode leaking into row props. under a section model, hide-project-title is just section.header !== null at the call site that already knows which section it is painting — no null-sentinel check inside renderThreadRow, and no section === "active" coupling to grouping state.

also: the fork guard currently pins this exact expression, which will fight the refactor. drop that string assert when you collapse the model.

/* fork:end sidebar-v2-project-grouping */
providerEntryByInstanceId={providerEntryByInstanceId}
onThreadClick={handleThreadClick}
onThreadActivate={navigateToThread}
Expand All @@ -2308,9 +2360,21 @@ export default function SidebarV2() {
/>
);
};
const items: ReactNode[] = activeThreads.map((thread) =>
renderThreadRow(thread, "active"),
);
/* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */
// Only the active cards group. The two shelves below are
// time-ordered tails whose value is that they stay short.
const items: ReactNode[] =
activeThreadProjectGroups === null
? activeThreads.map((thread) => renderThreadRow(thread, "active"))
: activeThreadProjectGroups.flatMap((group, groupIndex) => [
<SidebarV2ProjectGroupHeader
key={`project-group-header:${group.projectKey}`}
label={group.displayName}
isFirst={groupIndex === 0}
/>,
...group.threads.map((thread) => renderThreadRow(thread, "active")),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dual render path is the symptom of the null mode above. once sections always exist, this is one flatMap: optional <SidebarV2ProjectGroupHeader /> when header !== null, then the thread rows. no .map vs .flatMap fork, and no need to keep activeThreads and activeThreadProjectGroups both live in this IIFE.

minor while you are here: flatMap((group) => [...group.threads]) above copies for nothing — group.threads is already a readonly array.

/* fork:end sidebar-v2-project-grouping */
// Snoozed shelf: between the inbox and Settled — out of the
// way, never gone. The header always renders while anything
// is snoozed (the count is the whole footprint when
Expand Down
Loading
Loading