From d58d3f89c6553a340526b0aad5b9a6d229498722 Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 31 Jul 2026 13:18:56 -0400 Subject: [PATCH 1/9] feat(web): sidebar drafts, collapsible groups, and 14px card marks Show unpromoted drafts as cards (title from typed prompt), let project groups collapse, and keep the working rain/status slot at 14px without text-xs forcing a 16px title line. Co-authored-by: Cursor --- .fork/customizations.yaml | 143 ++++-- .../__fork_guards__/sidebarV2CardRows.test.ts | 74 ++- .../sidebarV2DraftRows.test.ts | 70 +++ .../sidebarV2ProjectGrouping.test.ts | 49 +- .../sidebarV2RowActionHitArea.test.ts | 6 +- apps/web/src/components/SidebarV2.tsx | 426 +++++++++++++++--- .../custom/SidebarV2ProjectGroupHeader.tsx | 83 +++- .../src/custom/SidebarV2StatusIndicator.tsx | 31 +- .../src/custom/SidebarV2ThreadCardMeta.tsx | 60 ++- .../web/src/custom/sidebarV2DraftRows.test.ts | 107 +++++ apps/web/src/custom/sidebarV2DraftRows.ts | 103 +++++ .../custom/sidebarV2ProjectGrouping.test.ts | 35 ++ .../src/custom/sidebarV2ProjectGrouping.ts | 51 ++- apps/web/src/custom/sidebarV2RowPolicy.ts | 20 +- .../web/src/custom/sidebarV2TrailingColumn.ts | 10 +- apps/web/src/theme.custom.css | 13 +- 16 files changed, 1053 insertions(+), 228 deletions(-) create mode 100644 apps/web/src/__fork_guards__/sidebarV2DraftRows.test.ts create mode 100644 apps/web/src/custom/sidebarV2DraftRows.test.ts create mode 100644 apps/web/src/custom/sidebarV2DraftRows.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 6d127536fb8..e307fdefe21 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -306,42 +306,52 @@ when selected or routed to; it is never filled because of its status. Done and Idle recede their title colour at rest (restored on hover or selection), per the component set — Working, Approval, Input and Failed - keep the foreground title. Every title is the design's Regular body/sm - weight; colour alone carries the receded/forward distinction, which is - why receded titles use --fork-sidebar-card-title-receded (#c0c0c0) rather + keep the foreground title. Every title is Regular at 0.875rem (14px); + colour alone carries the receded/forward distinction, which is why + receded titles use --fork-sidebar-card-title-receded (#c0c0c0) rather than lifting the shared --muted-foreground channel (see - fork-surface-palette). A card's surface reads the same Done/Idle - predicate as its title (`cardRecedes`); upstream's shouldRecede survives - for the slim shelves only, where brightness still encodes unread-ness — - the card delegates unread to the Done dot. - - Status text labels ("Working", "Approval") are replaced by a single 16px + fork-surface-palette). The size is literal on threadCardTitleClassName so + the panel's --text-xs/--text-sm remap to 13px cannot shrink it. The repo + line (project + branch) is 0.75rem (12px) for the same reason. A card's + surface reads the same Done/Idle predicate as its title (`cardRecedes`); + upstream's shouldRecede survives for the slim shelves only, where + brightness still encodes unread-ness — the card delegates unread to the + Done dot. + + Status text labels ("Working", "Approval") are replaced by a single 14px mark in a fixed leading slot on the title line, and the slot is never empty — idle draws a hollow ring rather than falling back to a variable-width relative-time string, so the title text and the rows below share one left - edge. Repo and meta rows indent 24px (the mark's 16 plus the title gap's 8) - so they align under the prompt rather than under the mark. The mark's form - carries the state — falling pixels while the agent runs, a static dot once - it stops, a clock for woke, a ring for idle — and the hue only reinforces - it. Working is emerald, deliberately diverging from the sky the mobile Live - Activity still uses; mobile has not been migrated. + edge. Repo and meta rows indent 24px (the mark's 14 plus the title gap's + 10) so they align under the prompt rather than under the mark. The mark's + form carries the state — falling pixels while the agent runs, a static + dot once it stops, a clock for woke, a ring for idle — and the hue only + reinforces it. Working is emerald, deliberately diverging from the sky + the mobile Live Activity still uses; mobile has not been migrated. Working duration and the hover actions stay on the title line's trailing cell. Status no longer shares that cell, so the opacity-crossfade hit-path bug that used to eat settle clicks cannot return. - Card metrics from Figma t3-fork node 113:3718: px-4 py-8 (4/8), all rows - 16px tall (the working rain's 18px grid overflows the title line by 1px - into the card's padding rather than growing it). Title-to-status gap is - 8px so the prompt lines up with the group header label (24px folder + - 4px gap); repo/meta indent 24px (16 status + 8 gap). Row gap is 6px - (gap-1.5), not Figma's 4. + Card metrics from Figma t3-fork node 113:3718: px-4 py-8 (4/8). The + title line (status + prompt) is 14px — status slot, rain, and title + leading all share that height; at 16px the rain read as hanging below + the prompt. Repo line stays 16px. Title-to-status gap is 10px + (gap-2.5) so 14+10 keeps the prompt lined up with the group header + label (24px folder + 4px gap); repo/meta indent 24px. Drawn card + heights are 54 / 77. Row gap is 8px (gap-2), not Figma's 4. Cards sit 4px apart — the list ul's gap-1 (fork retune of Figma's 2px / gap-0.5, for breathing room on the lifted panel); the li itself carries no padding. List pad 8 puts the leading status at 12px — the same axis as - Search and the group folder icon. contain-intrinsic-size is 54 / 75, the - drawn card exactly. Branch and runtime glyphs are 16px; the model label - is caption 11/15 on either line it lands on. + Search and the group folder icon. contain-intrinsic-size is 56 / 79, the + drawn card exactly. Branch and runtime glyphs are 14px; the runtime mark + sits in a 24px box flush with the card edge so it shares the settle/ + discard centre on the trailing axis (a lone 14px + pr-[3px] was 2px + off once actions grew to size-6). The model label is caption 11/15 on + either line it lands on. The branch/worktree cluster (mark plus name) + reads at text-foreground/70 — closer to the title than the surrounding + muted-foreground/70 meta — so the checkout stays legible without + matching the prompt. A card is three lines only when it has a PR or a diff for the third, or does not yet know whether it has a PR; with a known-empty third line the model and @@ -353,15 +363,15 @@ 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, and it measures the li — which carries no padding of its own, - so the hints equal the drawn heights of 54 and 75. Retune the card's - vertical padding and both hints move with it, or the scrollbar misreports - every skipped row by the difference. + so the hints equal the drawn heights of 56 and 79. Retune the card's + vertical padding or row gap and both hints move with it, or the scrollbar + misreports every skipped row by the difference. A thread that runs in a worktree of its own draws a worktree mark where the branch mark otherwise goes — replacing it, not joining it. That slot already answers "which code is this on", and the two facts are not independent: a thread on a worktree is on that worktree's branch, so a second glyph would - spend ~16px of a line whose branch name is already capped and truncating to + spend ~14px of a line whose branch name is already capped and truncating to restate what the first implies. The predicate is the one the row's own git cwd and env mode come from, so the mark cannot claim a worktree the rest of the row is not treating as one, and the worktree case carries a `sr-only` @@ -564,12 +574,25 @@ UNGROUPED_PROJECT_KEY. Not on its label being null: the label correlates today, and one signal carrying two meanings is how it stops correlating. - The heading role sits on the label span, not on the header row. The row now - also holds that button, and a heading containing one takes the button's text - into its own accessible name, so the landmark announcing which project a run - of cards belongs to would read " New thread in ". The row - is a presentational li, the label is the heading, and neither is a - collapsible button; the folder mark stays 16px with no trailing hairline. + The heading role sits on the label span, not on the header row. The row also + holds the collapse control and the new-thread plus, and a heading containing + either takes that control's text into its own accessible name, so the + landmark announcing which project a run of cards belongs to would read + " Collapse " (or the plus's name). The row is a + presentational li and the label is the heading. + + Groups collapse from the header row. Hovering anywhere on the row swaps the + folder mark for a chevron (FolderOpen at rest — Figma 113:3718 Frame 48); + clicking the mark or the label toggles. Collapse is an absolutely- + positioned hit layer behind the row; the new-thread plus paints above it + (z-10) so its clicks start a thread instead of toggling — a flex-1 + collapse sibling was eating them. Row-hover still swaps the mark when the + pointer is over the plus. When collapsed the chevron stays (rotated to + point at the label). Collapsed keys persist in localStorage (device-local, + like the grouping switch). A collapsed section hides its cards from both + paint and keyboard order, with the open route thread kept visible — the + same exception the snoozed shelf makes for a deep-linked row — so collapse + cannot bury the thread you are viewing. The header has no own horizontal pad — the list's 8px is the edge. The folder sits in a 24px box so its 16px glyph centres on the same 12px axis as @@ -604,6 +627,45 @@ verify: - apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts +- id: sidebar-v2-draft-rows + intent: > + New thread (project-header plus, chrome plus, shortcut) creates a + client-only draft and navigates to /draft/$draftId, but Sidebar V2's + list is fed from server shells — so until the first send the card was + missing and the sidebar looked empty for the thread you just opened. + + Unpromoted drafts in composerDraftStore.draftThreadsByThreadKey are + projected into the same EnvironmentThreadShell shape the list already + paints, then folded into the active partition before sort. The card + title is the unsaved composer prompt (trimmed/truncated like ChatView's + auto-title) or "New thread" when empty — so typing then clicking away + leaves a named draft in the list. Prompts are read from getState() when + the open route changes, not subscribed per keystroke. Promoted drafts + and drafts whose reserved thread id already has a server shell are + skipped so the row never doubles. + + Clicking a draft card navigates to /draft/$draftId (not the server + thread route). The open draft highlights via its reserved thread id — + upstream resolveActiveThreadRouteRef only resolves after promotion. + Settle, snooze, and rename are disabled. The settle slot shows an X + (Discard draft) on hover instead of Check; the context menu offers the + same discard via clearDraftThread. Discarding the draft you are viewing + selects the next painted row below (then the row above); it must not + fall through to handleNewThread the way settle does. Await the next + route (replace: true) before clear — a sync clear while still on + /draft/$id races the draft route's missing-session redirect to `/`, + and `_chat.index` immediately opens a fresh draft, so the card + animates out and back in while the workspace stays on "New thread". + tier: 4 + files: + - apps/web/src/custom/sidebarV2DraftRows.ts + shadows: [] + watch: + - apps/web/src/components/SidebarV2.tsx + verify: + - apps/web/src/__fork_guards__/sidebarV2DraftRows.test.ts + - apps/web/src/custom/sidebarV2DraftRows.test.ts + - id: sidebar-v2-row-action-hit-area intent: > This entry owns the size AND the placement of the Sidebar V2 row hover @@ -647,7 +709,7 @@ The card's trailing cell is h-6 rather than the title line's h-4 — a 24px target cannot fit a 16px cell. It is centred in the 16px line, so it - overhangs 4px into the card's py-2 above and its gap-1.5 below, neither of + overhangs 4px into the card's py-2 above and its gap-2 below, neither of which carries anything to collide with. Status no longer shares this cell — it leads the title line — so the opacity-crossfade hit-path bug that used to eat settle clicks (an opacity-0 status span stacking above the actions) @@ -749,11 +811,12 @@ - id: fork-sidebar-type-size intent: > - Sidebar V2 body type is 0.8125rem (13px at the default root). The panel - remaps Tailwind's --text-xs and --text-sm (and their --line-height - companions) to 0.8125rem / 1rem so every text-xs and text-sm consumer - inside the panel — Search, scope, project headers, card titles, slim - shelves — shares one size without rewriting each className. Rem, not px: + Sidebar V2 chrome body type is 0.8125rem (13px at the default root). The + panel remaps Tailwind's --text-xs and --text-sm (and their --line-height + companions) to 0.8125rem / 1rem so Search, scope, project headers, and + slim shelves share one size without rewriting each className. Card titles + and the repo/branch line opt out with literal rem sizes (0.875rem / 0.75rem + — see sidebar-v2-card-rows) so the remap cannot flatten them. Rem, not px: a px pin would freeze the sidebar while h-4 and the rest of the app still honour the browser font-size setting. Line-height stays 1rem so the card's h-4 rows scale with the type. Literal captions (text-[11px], text-[10px] diff --git a/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts b/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts index 87c4de4ebcc..b15c831197c 100644 --- a/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts @@ -73,6 +73,15 @@ describe("fork guard: sidebar-v2-card-rows", () => { expect(meta).toContain("props.hasWorktree || props.branch ?"); expect(meta).toContain("props.hasWorktree ?"); expect(meta).toContain("'); + expect(meta).toContain(''); + expect(meta).toContain("inline-flex size-6 shrink-0 items-center justify-center"); + expect(meta).toContain(''); + expect(meta).toContain(''); + expect(meta).not.toContain("pr-[3px]"); // Decorative marks carry nothing to a screen reader, so the distinction // rides on text; a `sr-only` here is the whole of it. expect(meta).toMatch(/sr-only">Worktree { }); it("draws every card title at the design's regular weight", () => { - // body/sm is Regular; the component set draws no medium titles. Colour - // alone separates a receded title from a forward one. + // Regular; the component set draws no medium titles. Colour alone + // separates a receded title from a forward one. expect(threadCardTitleClassName({ recedes: true })).toContain("font-normal"); expect(threadCardTitleClassName({ recedes: true })).not.toContain("font-medium"); expect(threadCardTitleClassName({ recedes: false })).toContain("font-normal"); expect(threadCardTitleClassName({ recedes: false })).not.toContain("font-medium"); }); + it("sizes the card title at 0.875rem / 14px line and the repo/branch line at 0.75rem", () => { + // Explicit rem so the panel's --text-xs/--text-sm → 13px remap cannot + // flatten title and branch to the chrome body size. Title leading matches + // the 14px status slot — leading-4 left a 16px line around the rain. + expect(threadCardTitleClassName({ recedes: false })).toContain("text-[0.875rem]"); + expect(threadCardTitleClassName({ recedes: false })).toContain("leading-[14px]"); + const meta = readSibling("../custom/SidebarV2ThreadCardMeta.tsx"); + expect(meta).toContain('REPO_ROW = "flex h-4 min-w-0 items-center text-[0.75rem] leading-4"'); + }); + it("lifts receded titles via a dedicated token, not the shared muted channel", () => { // Done/Idle titles read --fork-sidebar-card-title-receded so meta /70 and // shelf unread encoding keep calibrating against upstream @@ -155,25 +174,56 @@ describe("fork guard: sidebar-v2-card-rows", () => { it("keeps a mark in the leading status slot for every status, idle included", () => { // Idle used to fall back to a relative-time string on the trailing edge, - // so the column alternated between a 16px mark and a variable-width label. + // so the column alternated between a mark and a variable-width label. // The mark now leads the title line and is never empty — the hollow ring // holds the left column so the title text and the indented rows below - // share one edge. + // share one edge. 14px slot: at 16px the rain read as hanging below the + // title. overflow-hidden is load-bearing — the native grid is taller. expect(typeof SidebarV2IdleMark).toBe("function"); expect(sidebarV2).toContain(""); expect(sidebarV2).toContain( - "pointer-events-none flex size-4 shrink-0 items-center justify-center", + "pointer-events-none flex size-[14px] shrink-0 items-center justify-center overflow-hidden", ); + const rain = readSibling("../custom/SidebarV2StatusIndicator.tsx"); + expect(rain).toContain('className="block h-[14px] w-auto shrink-0 overflow-hidden"'); + expect(rain).toContain("const SLOT = 14"); + expect(rain).not.toContain("overflow-visible"); }); it("indents the card's lower rows under the title text", () => { - // 24px = the leading 16px status + the title row's 8px gap — matches the + // 24px = the leading 14px status + the title row's 10px gap — matches the // group header label once list pad is shared. Dropping the indent puts // the branch under the rain instead of under the prompt. const meta = readSibling("../custom/SidebarV2ThreadCardMeta.tsx"); expect(meta).toContain('CONTENT_INDENT = "pl-6"'); expect(meta).toContain("${CONTENT_INDENT}"); - expect(sidebarV2).toContain("flex h-4 min-w-0 items-center gap-2"); + expect(sidebarV2).toContain( + "flex h-[14px] min-h-[14px] min-w-0 items-center gap-2.5 overflow-hidden", + ); + }); + + it("does not layer text-xs onto card titles (that forced a 16px line box)", () => { + // text-xs carries --text-xs--line-height: 1rem. Paired with the card's + // explicit 0.875rem size it still won the cascade for leading and grew + // the title row to 16px around a 14px rain. Slim shelves keep text-sm. + const titleClass = + /className=\{cn\(\s*"min-w-0 flex-1[^"]*",([\s\S]*?)isRegeneratingTitle/u.exec( + sidebarV2, + )?.[1]; + expect(titleClass).toBeDefined(); + expect(titleClass).toContain("threadCardTitleClassName({ recedes: cardRecedes })"); + expect(titleClass).not.toMatch(/variant === "card" \? "text-xs"/u); + expect(titleClass).toContain('"text-sm"'); + }); + + it("lifts the branch cluster closer to the title than the surrounding meta", () => { + // Project, model, and runtime stay at muted/70; the checkout mark+name + // shares the title's foreground channel at 70% so it stays readable + // without matching the prompt. + const meta = readSibling("../custom/SidebarV2ThreadCardMeta.tsx"); + expect(meta).toContain('BRANCH = "text-foreground/70"'); + expect(meta).toContain("${BRANCH}"); + expect(meta).toContain('MUTED = "text-muted-foreground/70"'); }); it("collapses to two lines only when it knows there is no PR and no diff", () => { @@ -204,12 +254,12 @@ describe("fork guard: sidebar-v2-card-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, so both heights are pinned. They measure the li, - // which carries no padding of its own and so equals the drawn card: at - // px-1 py-2 over 16px rows with a 6px row gap, two lines are 54 and three - // are 75. Change the card's vertical padding and these move with it or the + // which carries no padding of its own and so equals the drawn card: title + // line 14px + repo 16px + gaps/pad → 54 two-line, 77 three-line. Change + // the card's vertical padding or gap and these move with it or the // scrollbar starts lying by the difference on every row it skips. - expect(sidebarV2).toContain("gap-1.5 px-1 py-2"); - expect(sidebarV2).toContain("[contain-intrinsic-size:auto_75px]"); + expect(sidebarV2).toContain("gap-2 px-1 py-2"); + expect(sidebarV2).toContain("[contain-intrinsic-size:auto_77px]"); expect(sidebarV2).toContain("[contain-intrinsic-size:auto_54px]"); // And the choice is made from the same predicate the component renders // from, so the hint cannot drift from the row count it describes. diff --git a/apps/web/src/__fork_guards__/sidebarV2DraftRows.test.ts b/apps/web/src/__fork_guards__/sidebarV2DraftRows.test.ts new file mode 100644 index 00000000000..efb2d37041a --- /dev/null +++ b/apps/web/src/__fork_guards__/sidebarV2DraftRows.test.ts @@ -0,0 +1,70 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#sidebar-v2-draft-rows`. + * + * Projection helpers are tested next to the module. This file guards only the + * seam inside upstream's `SidebarV2.tsx`: drafts enter the active partition, + * draft cards navigate to `/draft/$draftId`, the open draft highlights, and + * server lifecycle actions stay off the row. + */ + +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +function readSibling(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const sidebar = readSibling("../components/SidebarV2.tsx"); + +describe("fork guard: sidebar-v2-draft-rows", () => { + it("folds unpromoted drafts into the active partition", () => { + expect(sidebar).toContain("listSidebarDraftRows"); + expect(sidebar).toContain("draftThreadsByThreadKey"); + expect(sidebar).toContain("for (const row of draftRows)"); + expect(sidebar).toContain("active.push(shell)"); + // Unsent composer text becomes the card title when the route changes — + // getState() so keystrokes do not rebuild the partition. + expect(sidebar).toContain("promptForDraft:"); + expect(sidebar).toContain("getComposerDraft(draftId)?.prompt"); + expect(sidebar).toContain("routeThreadKey"); + }); + + it("opens draft cards on the draft route and highlights the reserved thread", () => { + expect(sidebar).toContain('to: "/draft/$draftId"'); + expect(sidebar).toContain("buildDraftThreadRouteParams(draftId)"); + // Unpromoted drafts: reserved thread id, not wait-for-promotedTo. + expect(sidebar).toContain( + "return scopeThreadRef(routeDraftThread.environmentId, routeDraftThread.threadId)", + ); + }); + + it("keeps settle/snooze/rename off drafts and offers discard", () => { + expect(sidebar).toContain("const isDraftRow = draftIdByThreadKey.has(threadKey)"); + expect(sidebar).toContain("!isDraftRow &&"); + expect(sidebar).toContain('id: "discard-draft"'); + expect(sidebar).toContain("discardDraftThread"); + expect(sidebar).toContain('aria-label="Discard draft"'); + expect(sidebar).toContain("onDiscardDraft={isDraftRow ? discardDraftThread : null}"); + expect(sidebar).toContain( + "if (draftIdByThreadKeyRef.current.has(scopedThreadKey(threadRef))) return;", + ); + // Prefer the painted row below; never spawn a replacement draft. + expect(sidebar).toContain("orderedKeys.slice(currentIndex + 1)"); + expect(sidebar).not.toMatch(/discardDraftThread[\s\S]{0,800}handleNewThreadRef\.current/u); + // Await next route, then clear — sync clear races `/` → index new-draft. + const discardStart = sidebar.indexOf("const discardDraftThread = useCallback("); + expect(discardStart).toBeGreaterThanOrEqual(0); + const discardBody = sidebar.slice( + discardStart, + sidebar.indexOf("/* fork:end sidebar-v2-draft-rows */", discardStart), + ); + expect(discardBody).toContain("await router.navigate("); + expect(discardBody).toContain("replace: true"); + expect(discardBody.indexOf("await router.navigate(")).toBeLessThan( + discardBody.indexOf("clearDraftThread(draftId)"), + ); + }); +}); diff --git a/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts b/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts index 35a58176908..2884a964e00 100644 --- a/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts @@ -45,10 +45,11 @@ describe("fork guard: sidebar-v2-project-grouping", () => { it("renders and orders from one sequence", () => { expect(sidebar).toContain("const activeSections = useMemo("); - // Keyboard order is the flattened render sequence, not a parallel - // derivation that has to agree with it by convention. - expect(sidebar).toContain("activeSections.flatMap((section) => section.threads)"); - expect(sidebar).toContain("activeSections.flatMap((section, sectionIndex)"); + // Collapse filters paint before flatten: keyboard order reads the same + // visible sequence the list draws, not the unfiltered bucket list. + expect(sidebar).toContain("const visibleActiveSections = useMemo("); + expect(sidebar).toContain("visibleActiveSections.flatMap((section) => section.threads)"); + expect(sidebar).toContain("visibleActiveSections.flatMap((section, sectionIndex)"); expect(sidebar).toContain("[...orderedActiveThreads, ...visibleSnoozedThreads"); const definition = sidebar.indexOf("const orderedActiveThreads"); const use = sidebar.indexOf("[...orderedActiveThreads,"); @@ -61,7 +62,7 @@ describe("fork guard: sidebar-v2-project-grouping", () => { // header from the same section whose threads follow it. Dropping this hunk // is the likeliest outcome of a merge that rewrites upstream's list body, // and it would leave a flat-looking sidebar over a grouped ordered list. - const start = sidebar.indexOf("const items: ReactNode[] = activeSections.flatMap("); + const start = sidebar.indexOf("const items: ReactNode[] = visibleActiveSections.flatMap("); expect(start).toBeGreaterThanOrEqual(0); const render = sidebar.slice(start, sidebar.indexOf("});", start)); expect(render).toContain(" { // the same value, so they cannot come to disagree. expect(render).toContain("const header = section.header;"); expect(render).toContain('renderThreadRow(thread, "active", header !== null)'); + expect(render).toContain("collapsed={collapsed}"); + expect(render).toContain("onToggleCollapsed="); + }); + + it("collapses from the row with a hover chevron, leaving the plus alone", () => { + const header = readSibling("../custom/SidebarV2ProjectGroupHeader.tsx"); + expect(header).toContain('data-testid="sidebar-v2-project-group-collapse"'); + expect(header).toContain("aria-expanded={!props.collapsed}"); + expect(header).toContain("FolderOpenIcon"); + expect(header).toContain("ChevronDownIcon"); + // Hover group is on the row; collapse is a behind-layer so the plus can + // paint above it. A flex-1 collapse sibling was eating plus clicks. + expect(header).toContain("group/collapse relative flex w-full"); + expect(header).toContain("absolute inset-0 z-0"); + expect(header).toContain('"relative z-10"'); + expect(header).toContain("group-hover/collapse:invisible"); + expect(header).toContain("group-hover/collapse:visible"); + expect(header).toContain("New thread in ${label}"); + expect(header).toContain("event.stopPropagation()"); + // Persistence and the route-thread keep live in the grouping module so a + // sync that drops the SidebarV2 wiring still fails the behaviour tests. + const grouping = readSibling("../custom/sidebarV2ProjectGrouping.ts"); + expect(grouping).toContain("SIDEBAR_V2_COLLAPSED_PROJECTS_STORAGE_KEY"); + expect(grouping).toContain("threadsVisibleInProjectSection"); + expect(sidebar).toContain("useSidebarV2CollapsedProjects"); }); it("starts a thread in the header's own project, and only where there is one", () => { @@ -122,13 +148,12 @@ describe("fork guard: sidebar-v2-project-grouping", () => { const header = readSibling("../custom/SidebarV2ProjectGroupHeader.tsx"); expect(header).toContain('role="presentation"'); expect(header).toContain("aria-level={3}"); - // On the label span, not on the row. The row also holds the new-thread - // button, and a heading containing one takes the button's text into its own - // accessible name — " New thread in " for a landmark - // whose whole job is to say which project a run of cards belongs to. That - // was the role's position before the button existed, so a sync restoring it - // is the realistic regression, and asserting the role appears *somewhere* - // in the file cannot tell the two apart. + // On the label span, not on the row. The row also holds the collapse + // control and the new-thread button, and a heading containing either takes + // that control's text into its own accessible name. That was the role's + // position before those buttons existed, so a sync restoring it onto the + // row is the realistic regression, and asserting the role appears + // *somewhere* in the file cannot tell the two apart. expect(header).toMatch(//u.exec( header, diff --git a/apps/web/src/__fork_guards__/sidebarV2RowActionHitArea.test.ts b/apps/web/src/__fork_guards__/sidebarV2RowActionHitArea.test.ts index e5b79c17da7..4aa8e23cea5 100644 --- a/apps/web/src/__fork_guards__/sidebarV2RowActionHitArea.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2RowActionHitArea.test.ts @@ -120,8 +120,8 @@ describe("fork guard: sidebar-v2-row-action-hit-area", () => { }); it("gives the card's trailing cell room for a 24px target", () => { - // h-4 is the title line (the rain overflows it by design); the cell - // holding the actions has to be 24 or the button is clipped back to 18. + // Title line is 14px; the cell holding the actions has to be 24 or the + // button is clipped. It centres in the line and overhangs into py-2/gap-2. expect(sidebarV2).toContain("grid h-6 shrink-0 grid-cols-1 items-center justify-items-end"); }); @@ -190,7 +190,7 @@ describe("fork guard: sidebar-v2-row-action-hit-area", () => { // structural fix — assert it stays there, and that the leading slot itself // is never a target. expect(sidebarV2).toContain( - "pointer-events-none flex size-4 shrink-0 items-center justify-center", + "pointer-events-none flex size-[14px] shrink-0 items-center justify-center", ); expect(sidebarV2).toContain(""); // The trailing cell still fades elapsed on a working row when the hover diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 30b343feaa9..e53491570b3 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -29,6 +29,9 @@ import { TerminalIcon, Trash2Icon, Undo2Icon, + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + XIcon, + /* fork:end sidebar-v2-draft-rows */ } from "lucide-react"; /* fork:begin sidebar-v2-dev-server-pulse — see .fork/customizations.yaml#sidebar-v2-dev-server-pulse */ // A statement of its own rather than a name in upstream's list: the phosphor @@ -112,6 +115,9 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + buildDraftThreadRouteParams, + /* fork:end sidebar-v2-draft-rows */ buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, @@ -177,12 +183,26 @@ import { SidebarV2ProjectGroupHeader } from "~/custom/SidebarV2ProjectGroupHeade import { buildActiveThreadSections, createProjectRefIndex, + threadsVisibleInProjectSection, UNGROUPED_PROJECT_KEY, + useSidebarV2CollapsedProjects, useSidebarV2GroupByProject, } from "~/custom/sidebarV2ProjectGrouping"; /* fork:end sidebar-v2-project-grouping */ +/* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ +import { + draftIdByThreadKey as indexDraftIdsByThreadKey, + listSidebarDraftRows, +} from "~/custom/sidebarV2DraftRows"; +/* fork:end sidebar-v2-draft-rows */ import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { + deriveProviderInstanceEntries, + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + NO_PROVIDER_MODEL_SELECTION, + /* fork:end sidebar-v2-draft-rows */ + type ProviderInstanceEntry, +} from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -492,6 +512,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Drafts sit in the settle slot with an X instead of Check — same hover + // cell, no server lifecycle. null on real threads. + onDiscardDraft: ((threadRef: ScopedThreadRef) => void) | null; + /* fork:end sidebar-v2-draft-rows */ onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -500,6 +525,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onCancelRename, onCommitRename, onContextMenu, + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + onDiscardDraft, + /* fork:end sidebar-v2-draft-rows */ onRenameTitleChange, onSettle, onSnooze, @@ -760,6 +788,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onSettle, threadRef], ); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + const handleDiscardDraftClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDiscardDraft?.(threadRef); + }, + [onDiscardDraft, threadRef], + ); + const showDiscardDraft = onDiscardDraft !== null; + /* fork:end sidebar-v2-draft-rows */ const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -834,10 +873,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -1050,41 +1093,35 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } > {/* Three rows: title, repo, meta. Status leads the title line; repo - and meta indent under the title text (16px mark + 8px gap). The + and meta indent under the title text (14px mark + 10px gap). The trailing cell on the title line holds elapsed time while working and the hover actions — status no longer shares that cell, so the opacity crossfade hit-path bug cannot return. - The design draws this at px-4 py-8 over 16px rows (Figma - 113:3718 Thread card v2); row gap is 6px. Repo/meta indent is - 24px (16 status + 8 gap). Drawn heights: two-line 54, three-line - 75; the li adds nothing, so contain-intrinsic-size is those - exact values. */} + Drawn heights: two-line 54 (8+14+8+16+8), three-line 77; the li + adds nothing, so contain-intrinsic-size is those exact values. */} {/* fork:begin sidebar-v2-card-rows — see .fork/customizations.yaml#sidebar-v2-card-rows Figma 113:3718: px-4 py-8 (4/8). List pad 8 puts the leading status at 12px — same axis as Search and the group folder icon. - Row gap is 6px (gap-1.5), not Figma's 4 — retuned live with the - 8px title gap below: once the prompt moved to the header label's - 36px axis, the mock's literal 4px read cramped between two 16px - text rows. The drawn heights (54/75) and the guards pin this - retune, not the Figma literal. */} -
+ Row gap is 8px (gap-2), not Figma's 4 — retuned so the title and + branch breathe. Title line is 14px (status + prompt); repo stays + 16px. Drawn heights (54/77) and the guards pin this retune. */} +
{/* fork:end sidebar-v2-card-rows */} - {/* 16px, same as the metadata lines — the card draws at 54 - (8 + 16 + 6 + 16 + 8). The working rain's 3x5 grid is 18px - tall; rather than growing the row for it, the SVG's - overflow-visible lets it spill 1px into the card's py-2 on - each side, where there is nothing to collide with. */} - {/* gap-2 (8px): status stays on the 12px axis; the prompt starts - at 36px — same as the group header label (24px folder box + - 4px gap). Figma's title gap was 4px; that put the prompt 4px - left of the group name. */} -
- {/* Leading 16px status column. Always present (idle draws the + {/* Title line is 14px tall — the card draws at 54 + (8 + 14 + 8 + 16 + 8). Status mark and title share that + height; a 16px line around a 14px rain made the mark look + oversized / low against the prompt. */} + {/* gap-2.5 (10px): 14px status + 10px gap keeps the prompt at + 36px — same as the group header label (24px folder box + + 4px gap). gap-2 would have walked it 2px left. */} +
+ {/* Leading 14px status column. Always present (idle draws the hollow ring) so the title text and the indented rows below share one left edge. pointer-events-none: a mark is never a - target. */} - + target. Explicit px so DevTools / rem remaps cannot leave + this at 16. */} + {topStatus ? ( <> @@ -1121,12 +1158,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { against whichever child is showing. Status used to live here too; moving it left is what let the indent below line up. */} {/* fork:begin sidebar-v2-row-action-hit-area — see .fork/customizations.yaml#sidebar-v2-row-action-hit-area */} - {/* h-6, not the line's h-4: the hover actions share this cell - and a 24px target cannot fit in a 16px one. The cell is - centred in the 16px line, so it overhangs 4px into the card's - py-2 above and its gap-1.5 below — neither of which carries + {/* h-6, not the line's 14px: the hover actions share this cell + and a 24px target cannot fit in a 14px one. The cell is + centred in the title line, so it overhangs into the card's + py-2 above and its gap-2 below — neither of which carries anything to collide with. */} - {props.settlementSupported || showSnoozeButton || status === "working" ? ( + {props.settlementSupported || + showSnoozeButton || + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + showDiscardDraft || + /* fork:end sidebar-v2-draft-rows */ + status === "working" ? ( {/* fork:end sidebar-v2-row-action-hit-area */} {status === "working" ? ( @@ -1137,7 +1179,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // actions. When neither action is offered there is // nothing to yield to, and an unconditional fade // blanks the timer on hover with nothing in its place. - (props.settlementSupported || showSnoozeButton) && + (props.settlementSupported || + showSnoozeButton || + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + showDiscardDraft) && + /* fork:end sidebar-v2-draft-rows */ "transition-opacity group-hover/v2-row:opacity-0", snoozeMenuOpen && "opacity-0", )} @@ -1150,7 +1196,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} - {props.settlementSupported || showSnoozeButton ? ( + {props.settlementSupported || + showSnoozeButton || + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + showDiscardDraft ? ( + /* fork:end sidebar-v2-draft-rows */ ) : null} + {/* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */} + {showDiscardDraft ? ( + + ) : null} + {/* fork:end sidebar-v2-draft-rows */} ) : null} @@ -1263,6 +1325,7 @@ export default function SidebarV2() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); /* fork:begin sidebar-v2-project-grouping — see .fork/customizations.yaml#sidebar-v2-project-grouping */ const [groupByProject, setGroupByProject] = useSidebarV2GroupByProject(); + const [collapsedProjectKeys, toggleProjectGroupCollapsed] = useSidebarV2CollapsedProjects(); /* fork:end sidebar-v2-project-grouping */ const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = useThreadActions(); @@ -1339,10 +1402,19 @@ export default function SidebarV2() { const routeDraftThread = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Upstream only resolves a draft route after promotion. Unpromoted drafts + // still own a reserved thread id — use it so the sidebar card highlights + // and keyboard order treat the draft the same as any other open thread. + const routeThreadRef = useMemo(() => { + const promoted = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); + if (promoted) return promoted; + if (routeTarget?.kind === "draft" && routeDraftThread) { + return scopeThreadRef(routeDraftThread.environmentId, routeDraftThread.threadId); + } + return null; + }, [routeDraftThread, routeTarget]); + /* fork:end sidebar-v2-draft-rows */ const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; @@ -1652,6 +1724,46 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const projectDefaultModelByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.defaultModelSelection, + ]), + ), + [projects], + ); + const serverThreadKeys = useMemo( + () => + new Set( + threads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + [threads], + ); + // Prompt titles are read from getState() on route/shell changes — not + // subscribed per keystroke — so typing in the composer does not rebuild the + // partition. Leaving the draft (routeThreadKey) is what refreshes the label. + const draftRows = useMemo( + () => + listSidebarDraftRows({ + draftsById: draftThreadsByThreadKey, + modelSelectionForDraft: (_draftId, draft) => + projectDefaultModelByKey.get(`${draft.environmentId}:${draft.projectId}`) ?? + NO_PROVIDER_MODEL_SELECTION, + promptForDraft: (draftId) => + useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt ?? "", + hasServerShell: (threadRef) => serverThreadKeys.has(scopedThreadKey(threadRef)), + }), + // routeThreadKey: snapshot composer prompts when the open thread changes. + [draftThreadsByThreadKey, projectDefaultModelByKey, routeThreadKey, serverThreadKeys], + ); + const draftIdByThreadKey = useMemo(() => indexDraftIdsByThreadKey(draftRows), [draftRows]); + const draftIdByThreadKeyRef = useRef(draftIdByThreadKey); + draftIdByThreadKeyRef.current = draftIdByThreadKey; + /* fork:end sidebar-v2-draft-rows */ const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: @@ -1694,6 +1806,21 @@ export default function SidebarV2() { active.push(thread); } } + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Client-only drafts (plus → /draft/$id) never enter the shell stream + // until the first send. Fold them into the active list so the card shows + // up under the project the moment the draft exists. + for (const row of draftRows) { + const shell = row.shell; + if ( + scopedProjectKeys !== null && + !scopedProjectKeys.has(`${shell.environmentId}:${shell.projectId}`) + ) { + continue; + } + active.push(shell); + } + /* fork:end sidebar-v2-draft-rows */ return { activeThreads: sortThreadsForSidebarV2(active), // Soonest wake first: "what comes back next" is the shelf's question. @@ -1708,6 +1835,9 @@ export default function SidebarV2() { }, [ autoSettleAfterDays, changeRequestStateByKey, + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + draftRows, + /* fork:end sidebar-v2-draft-rows */ nowMinute, scopedProjectKeys, serverConfigs, @@ -1816,6 +1946,28 @@ export default function SidebarV2() { }), [activeThreads, groupByProject, projectGroups, projectRefIndex, projectScopeKey], ); + // Collapse filters the paint sequence: a closed group hides its cards, with + // the open route thread kept visible so a deep link (or a collapse while + // viewing) cannot bury the row you are on — same exception the snoozed shelf + // makes. Paint and keyboard order both read this, so they cannot disagree. + const visibleActiveSections = useMemo( + () => + activeSections.map((section) => { + const collapsed = + section.header !== null && collapsedProjectKeys.has(section.header.projectKey); + return { + ...section, + threads: threadsVisibleInProjectSection({ + threads: section.threads, + collapsed, + keepThread: (thread) => + routeThreadKey !== null && + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + }), + }; + }), + [activeSections, collapsedProjectKeys, routeThreadKey], + ); // Positional consumers read this: resolveAdjacentThreadId (arrow nav), // rangeSelectTo (shift-select) and planForwardNavigation (where you land // after settling or snoozing the thread you are viewing). It has to be the @@ -1824,8 +1976,8 @@ export default function SidebarV2() { // misaddress — but they are numbered from this list, so a stale order shows // them out of sequence down the screen. const orderedActiveThreads = useMemo( - () => activeSections.flatMap((section) => section.threads), - [activeSections], + () => visibleActiveSections.flatMap((section) => section.threads), + [visibleActiveSections], ); /* fork:end sidebar-v2-project-grouping */ const orderedThreads = useMemo( @@ -1911,6 +2063,16 @@ export default function SidebarV2() { if (isMobile) { setOpenMobile(false); } + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + const draftId = draftIdByThreadKeyRef.current.get(scopedThreadKey(threadRef)); + if (draftId) { + void router.navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(draftId), + }); + return; + } + /* fork:end sidebar-v2-draft-rows */ void router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -1922,6 +2084,11 @@ export default function SidebarV2() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Drafts are client-only until the first send; there is no server title + // to rename, and the painted label is fixed ("New thread"). + if (draftIdByThreadKeyRef.current.has(scopedThreadKey(threadRef))) return; + /* fork:end sidebar-v2-draft-rows */ setRenamingThreadKey(scopedThreadKey(threadRef)); setRenamingTitle(title); }, []); @@ -2011,10 +2178,79 @@ export default function SidebarV2() { [navigateToThread, router], ); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Discard must not reuse planForwardNavigation: that helper's last-card + // fallback spawns a fresh draft (correct for settle, wrong here — the user + // just deleted one and would appear stuck on "New thread"). Prefer the row + // visually below in the painted order, then the row above; never create. + // + // Await navigation, then clear. Fire-and-forget navigate + sync clear left + // us on /draft/$id with a null session; that route's missing-session effect + // replaces to `/`, and `_chat.index` immediately handleNewThread's a fresh + // draft — so the card animates out and straight back in, still viewing a + // draft. replace: true keeps the discarded draft off the back stack. + const discardDraftThread = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + const draftId = draftIdByThreadKeyRef.current.get(threadKey); + if (!draftId) return; + + if (routeThreadKeyRef.current === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const belowKey = + currentIndex === -1 + ? null + : (orderedKeys.slice(currentIndex + 1).find((key) => key !== threadKey) ?? null); + const aboveKey = + currentIndex <= 0 + ? null + : ([...orderedKeys.slice(0, currentIndex)] + .reverse() + .find((key) => key !== threadKey) ?? null); + const targetKey = belowKey ?? aboveKey; + const nextThread = targetKey ? threadByKeyRef.current.get(targetKey) : null; + if (nextThread) { + const nextRef = scopeThreadRef(nextThread.environmentId, nextThread.id); + const nextKey = scopedThreadKey(nextRef); + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(nextKey); + const nextDraftId = draftIdByThreadKeyRef.current.get(nextKey); + if (nextDraftId) { + await router.navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), + replace: true, + }); + } else { + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(nextRef), + replace: true, + }); + } + } else { + await router.navigate({ to: "/", replace: true }); + } + } + + useComposerDraftStore.getState().clearDraftThread(draftId); + })(); + }, + [clearSelection, router, setSelectionAnchor], + ); + /* fork:end sidebar-v2-draft-rows */ + const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { const threadKey = scopedThreadKey(threadRef); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + if (draftIdByThreadKeyRef.current.has(threadKey)) return; + /* fork:end sidebar-v2-draft-rows */ if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { @@ -2092,6 +2328,9 @@ export default function SidebarV2() { ) => { void (async () => { const threadKey = scopedThreadKey(threadRef); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + if (draftIdByThreadKeyRef.current.has(threadKey)) return; + /* fork:end sidebar-v2-draft-rows */ if (snoozingThreadKeysRef.current.has(threadKey)) return; snoozingThreadKeysRef.current.add(threadKey); try { @@ -2322,6 +2561,19 @@ export default function SidebarV2() { const api = readLocalApi(); if (!api) return; const threadKey = scopedThreadKey(threadRef); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + if (draftIdByThreadKeyRef.current.has(threadKey)) { + const clicked = await settlePromise(() => + api.contextMenu.show( + [{ id: "discard-draft", label: "Discard draft", destructive: true, icon: "trash" }], + position, + ), + ); + if (clicked._tag === "Failure" || clicked.value !== "discard-draft") return; + discardDraftThread(threadRef); + return; + } + /* fork:end sidebar-v2-draft-rows */ const selectionState = useThreadSelectionStore.getState(); if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { await handleMultiSelectContextMenu(position); @@ -2520,6 +2772,9 @@ export default function SidebarV2() { attemptSnooze, attemptUnsettle, attemptUnsnooze, + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + discardDraftThread, + /* fork:end sidebar-v2-draft-rows */ confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, @@ -2751,6 +3006,10 @@ export default function SidebarV2() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + // Drafts are not on the server yet — settle/snooze would fail. + const isDraftRow = draftIdByThreadKey.has(threadKey); + /* fork:end sidebar-v2-draft-rows */ // Settled and snoozed are the ONLY things that collapse a // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, @@ -2779,10 +3038,16 @@ export default function SidebarV2() { : "settle" } settlementSupported={ + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + !isDraftRow && + /* fork:end sidebar-v2-draft-rows */ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } snoozeSupported={ + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + !isDraftRow && + /* fork:end sidebar-v2-draft-rows */ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } @@ -2826,6 +3091,9 @@ export default function SidebarV2() { isRenaming={renamingThreadKey === threadKey} renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} + /* fork:begin sidebar-v2-draft-rows — see .fork/customizations.yaml#sidebar-v2-draft-rows */ + onDiscardDraft={isDraftRow ? discardDraftThread : null} + /* fork:end sidebar-v2-draft-rows */ onSettle={attemptSettle} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} @@ -2840,39 +3108,47 @@ export default function SidebarV2() { // Flat is the one-headerless-section case, so this is the only // path either way — and it is the same sequence // orderedActiveThreads flattens. - const items: ReactNode[] = activeSections.flatMap((section, sectionIndex) => { - // Bound here rather than read off `section.header` inside the - // callback below: the narrowing does not survive into a - // closure, and the key the plus starts a thread from must be - // the one this header was drawn for. - const header = section.header; - return [ - ...(header - ? [ - handleNewThreadInProject(header.projectKey) - } - />, - ] - : []), - ...section.threads.map((thread) => - renderThreadRow(thread, "active", header !== null), - ), - ]; - }); + const items: ReactNode[] = visibleActiveSections.flatMap( + (section, sectionIndex) => { + // Bound here rather than read off `section.header` inside the + // callback below: the narrowing does not survive into a + // closure, and the key the plus starts a thread from must be + // the one this header was drawn for. + const header = section.header; + const collapsed = + header !== null && collapsedProjectKeys.has(header.projectKey); + return [ + ...(header + ? [ + + toggleProjectGroupCollapsed(header.projectKey) + } + // The unresolved-project section names no project, + // so there is nowhere for its plus to start a + // thread. Keyed off the bucket's own identity + // rather than off its label being null: the label + // is a rendering detail that happens to correlate + // today, and one signal carrying two meanings is + // how it stops correlating later. + onNewThread={ + header.projectKey === UNGROUPED_PROJECT_KEY + ? undefined + : () => handleNewThreadInProject(header.projectKey) + } + />, + ] + : []), + ...section.threads.map((thread) => + renderThreadRow(thread, "active", header !== null), + ), + ]; + }, + ); /* 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 diff --git a/apps/web/src/custom/SidebarV2ProjectGroupHeader.tsx b/apps/web/src/custom/SidebarV2ProjectGroupHeader.tsx index 24bb1f4bb98..cbb1b2d750a 100644 --- a/apps/web/src/custom/SidebarV2ProjectGroupHeader.tsx +++ b/apps/web/src/custom/SidebarV2ProjectGroupHeader.tsx @@ -2,11 +2,13 @@ * The project header that separates grouped active cards — see * `.fork/customizations.yaml#sidebar-v2-project-grouping`. * - * Deliberately not a button. Upstream's two shelf headers (Snoozed, Settled) - * collapse because each hides a tail whose whole job is to stay out of the way; - * a project header sits over the inbox, where nothing wants hiding, and the - * scope menu one row above already answers "just this project" without leaving - * a row of collapsed stubs behind. + * The whole row owns the collapse affordance: hovering anywhere on it swaps + * the folder mark for a chevron, and clicking the mark or the label toggles + * the group. Collapse is an absolutely-positioned hit layer behind the row + * content so the new-thread plus can sit above it (`z-10`) and keep its own + * clicks — a flex-1 collapse button beside the plus was eating them. When the + * group is collapsed the chevron stays (rotated to point at the label) so the + * row does not look like an open folder over a missing list. * * Metrics are the shelf headers' verbatim, but the trailing hairline is not. * The shelves use a rule because each is one divider closing off the list above @@ -27,7 +29,7 @@ * visually hidden, since the header carries it for sighted users — so grouped * mode never carries less information than flat mode. */ -import { FolderOpenIcon, PlusIcon } from "lucide-react"; +import { ChevronDownIcon, FolderOpenIcon, PlusIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { @@ -43,39 +45,65 @@ const UNGROUPED_PROJECT_LABEL = "Unknown project"; export function SidebarV2ProjectGroupHeader(props: { readonly label: string | null; readonly isFirst: boolean; + readonly collapsed: boolean; + readonly onToggleCollapsed: () => void; /** Starts a thread in this header's project. Omitted for the unresolved-project section, which names no project to start one in. */ readonly onNewThread?: (() => void) | undefined; }) { + const label = props.label ?? UNGROUPED_PROJECT_LABEL; return (
  • + {/* group/collapse on the row so hovering the plus still swaps the mark. + Collapse is a behind-layer button; the plus paints above it. */}
    - {/* FolderOpen, matching the chrome's New-project glyph — the design - (113:3718 Frame 48) draws the group mark open, not closed. */} - - +