diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e1febcd68d533..1ebcbd537f4ce 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -24,6 +24,7 @@ const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker" const pinnedHeader = marker("pinned-header"); const divider = marker("pinned-divider"); const settledHeader = marker("settled-header"); +const workingHeader = marker("working-header"); const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; function layout( @@ -120,13 +121,16 @@ describe("sidebar collision detection", () => { ); it.each([ - { sourceSection: "active", pins: 0 }, - { sourceSection: "active", pins: 1 }, - { sourceSection: "pinned", pins: 1 }, - { sourceSection: "settled", pins: 1 }, + { sourceSection: "active", pins: 0, pinsAt: 316 }, + { sourceSection: "active", pins: 1, pinsAt: 316 }, + { sourceSection: "pinned", pins: 1, pinsAt: 316 }, + { sourceSection: "settled", pins: 1, pinsAt: 316 }, + // Active refuses a Working row, so it pins anywhere above the Active label, not on it. + { sourceSection: "working", pins: 0, pinsAt: 299 }, + { sourceSection: "working", pins: 1, pinsAt: 299 }, ] as const)( "switches on crossing the divider row from $sourceSection with $pins pins", - ({ sourceSection, pins }) => { + ({ sourceSection, pins, pinsAt }) => { const items = [ pinnedHeader, ...(pins ? [thread("p", "pinned")] : []), @@ -134,6 +138,7 @@ describe("sidebar collision detection", () => { divider, thread("a", "active"), ...(sourceSection === "active" ? [thread("source", "active")] : []), + ...(sourceSection === "working" ? [workingHeader, thread("source", "working")] : []), settledHeader, ...(sourceSection === "settled" ? [thread("source", "settled")] : []), ]; @@ -181,7 +186,7 @@ describe("sidebar collision detection", () => { current: item === divider ? boundaryNode - : item === settledHeader + : item === settledHeader || item === workingHeader ? ({ getBoundingClientRect: () => ({ top: 600 }) } as unknown as HTMLElement) : null, }, @@ -192,16 +197,16 @@ describe("sidebar collision detection", () => { return over ? resolveSidebarDropTarget(items, "source", String(over.id))?.section : null; }; expect(at(330)).toBe("active"); - expect(at(317)).toBe("active"); - expect(at(316)).toBe("pinned"); + expect(at(pinsAt + 1)).toBe("active"); + expect(at(pinsAt)).toBe("pinned"); boundaryTop = 400; expect(at(316)).toBe("pinned"); expect(at(399)).toBe("pinned"); expect(at(400)).toBe("active"); boundaryTop = 300; expect(at(400)).toBe("active"); - expect(at(317)).toBe("active"); - expect(at(316)).toBe("pinned"); + expect(at(pinsAt + 1)).toBe("active"); + expect(at(pinsAt)).toBe("pinned"); }, ); @@ -530,6 +535,44 @@ describe("sidebar drag projection", () => { expect(byTime.get("w")).toEqual(stationary); }); + it("makes room for the boundary labels while a Working row is lifted", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("working-header"), + thread("w", "working"), + settledHeader, + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: false, boundaryLabelHeight: 24 }, + "w", + "w", + ); + expect(result.get("p")?.y).toBe(24); + expect(result.get("a")?.y).toBe(48); + expect(result.get("w")).toEqual(stationary); + }); + + it("closes the Working shelf in the preview only when its last thread is pinned", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("working-header"), + thread("w", "working"), + settledHeader, + thread("s", "settled"), + ]; + const input = { items, settledOrder: ["s"], settledExpanded: true }; + const last = preview({ ...input, workingThreadCount: 1 }, "w", "p"); + const another = preview({ ...input, workingThreadCount: 2 }, "w", "p"); + const settledY = (result: typeof last) => result.get(sidebarMarkerId("settled-header"))?.y ?? 0; + expect(settledY(last) - settledY(another)).toBe(-33); + }); + it("updates the projection when the target or measured geometry changes", () => { const strategy = createSidebarSortingStrategy({ items: pinned, diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..c40233936db12 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -54,7 +54,10 @@ export function createSidebarCollisionDetection( const previousY = previousPointerY ?? pointer.y; previousPointerY = pointer.y; if (pointer.x >= boundary.left && pointer.x <= boundary.right) { - if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned"; + // Active refuses a Working row, so only the space above the Active label pins it. + const reachesPinned = + source.section === "working" ? pointer.y < boundary.top : pointer.y <= boundary.bottom; + if (pointer.y < previousY && reachesPinned) boundarySection = "pinned"; else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active"; const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const) .map((marker) => @@ -98,6 +101,7 @@ export function createSidebarSortingStrategy(input: { settledVisibleCount?: number; routeThreadKey?: string | null; snoozedThreadCount?: number; + workingThreadCount?: number; cardHeight?: number; slimHeight?: number; boundaryLabelHeight?: number; @@ -113,7 +117,9 @@ export function createSidebarSortingStrategy(input: { const over = items[overIndex] ?? active; if (active?.kind !== "thread" || !over || !rects[0]) return []; const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); - if (!target) return []; + // A lifted Working row has no slot of its own, but the boundary labels still need room. + if (!target && active.section !== "working") return []; + const targetSection = target?.section ?? "working"; const groups: Record = { pinned: [], active: [], @@ -142,22 +148,24 @@ export function createSidebarSortingStrategy(input: { cardHeight ??= 82 * scale; slimHeight ??= 36 * scale; const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; - const group = groups[target.section]; + const group = groups[targetSection]; const order = - target.section === "pinned" - ? target.pinnedOrder - : target.section === "settled" - ? input.settledOrder - : (input.activeOrder ?? target.activeOrder); + target === null + ? items.flatMap((item) => (item.kind === "thread" ? [item.key] : [])) + : target.section === "pinned" + ? target.pinnedOrder + : target.section === "settled" + ? input.settledOrder + : (input.activeOrder ?? target.activeOrder); const ranks = new Map(order.map((key, index) => [key, index])); const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY; const index = group.findIndex( (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank, ); - group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section }); + group.splice(index < 0 ? group.length : index, 0, { ...active, section: targetSection }); const settledOrder = ( input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key) - ).filter((key) => key !== active.key || target.section === "settled"); + ).filter((key) => key !== active.key || targetSection === "settled"); const visible = input.settledExpanded ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length) : []; @@ -176,7 +184,11 @@ export function createSidebarSortingStrategy(input: { projected.push(...groups.pinned); marker("pinned-divider"); section("active"); - if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) { + if ( + groups.working.length > 0 || + ((active.section !== "working" || (input.workingThreadCount ?? 0) > 1) && + items.some((item) => item.kind === "marker" && item.marker === "working-header")) + ) { marker("working-header"); projected.push(...groups.working); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d1d045ed41a35..a996345f38831 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1128,30 +1128,34 @@ describe("Working shelf (beta)", () => { expect(resolveSidebarDropVerb("active", "working")).toBeNull(); }); + const planWorkingDrop = (overKey: string) => + planSidebarThreadDrop({ + activeKey: "w1", + activeSection: "working", + target: resolveSidebarDropTarget(items, "w1", overKey)!, + pinnedOrder: ["p1"], + pinnedKeysById: new Map([["p1", "m"]]), + activeOrder: ["a1", "a2"], + activeKeysById: new Map([ + ["a1", "f"], + ["a2", "t"], + ]), + activeTimeOrdered: true, + }); + + it("pins a Working thread at the spot it is dropped", () => { + expect(planWorkingDrop("p1")).toMatchObject({ kind: "pin", order: ["w1", "p1"] }); + expect(resolveSidebarDropVerb("working", "pinned")).toBe("pin"); + }); + it.each([ - ["pinned", "p1"], ["active", "a2"], ["settled", "s1"], ] as const)( - "does not move a Working thread into %s when its reference drag ends in the sidebar", + "does not move a Working thread into %s when its drag ends in the sidebar", (section, overKey) => { - const target = resolveSidebarDropTarget(items, "w1", overKey); - expect(target?.section).toBe(section); - expect( - planSidebarThreadDrop({ - activeKey: "w1", - activeSection: "working", - target: target!, - pinnedOrder: ["p1"], - pinnedKeysById: new Map([["p1", "m"]]), - activeOrder: ["a1", "a2"], - activeKeysById: new Map([ - ["a1", "f"], - ["a2", "t"], - ]), - activeTimeOrdered: true, - }), - ).toEqual({ kind: "none" }); + expect(resolveSidebarDropTarget(items, "w1", overKey)?.section).toBe(section); + expect(planWorkingDrop(overKey)).toEqual({ kind: "none" }); }, ); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7f60c1d35215a..0979c2211274d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -388,7 +388,9 @@ export function planSidebarThreadDrop(input: { activeKeysById, activeReorderableKeys, } = input; - if (activeSection === "working") return { kind: "none" }; + // Live status decides Working, and the server refuses to settle a running thread, so a thread + // lifted from Working can only be pinned. Pins outrank Working, so the pin holds. + if (activeSection === "working" && target.section !== "pinned") return { kind: "none" }; if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) { return { kind: "none" }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ff193edc73ccb..f256a75e41c04 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1726,10 +1726,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; // Folder rows only drag out to the composer; folders own their order, so nothing drops in the list. - const isContextDrag = - projectFolders !== null || - dragState?.contextDrag === true || - dragState?.activeSection === "working"; + const isContextDrag = projectFolders !== null || dragState?.contextDrag === true; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); const finishThreadDrag = (started: boolean) => { dragSensorRef.current = null; @@ -2056,6 +2053,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settledVisibleCount, routeThreadKey: routeRowThreadKey, snoozedThreadCount: snoozedThreads.length, + workingThreadCount: workingThreads.length, }); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; @@ -2141,7 +2139,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; const drop = { key: activeKey, - sourceSection: activeSection, + // Server state has no Working section, so these threads read as active until the pin lands. + sourceSection: activeSection === "working" ? "active" : activeSection, section: target.section, occurredAt: new Date().toISOString(), clearsSnooze: @@ -3547,6 +3546,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve label="Active" showHint={ from !== null && + from !== "working" && (activeThreads.length === 0 || (from === "active" && activeThreads.length === 1 && @@ -3614,6 +3614,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve label="Settled" showHint={ from !== null && + from !== "working" && (renderedSettledThreads.length === 0 || (from === "settled" && renderedSettledThreads.length === 1 && diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..1853a5a23d1e6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -138,7 +138,8 @@ That includes subagents, watched pull requests, and background commands or monit says it is waiting on. A dev server or other process an agent leaves running does not keep a thread there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in -the pinned section. +the pinned section. Drag a thread from the Working section up into the pinned section to pin it. It +cannot be dragged to another section, because its status decides where it goes. This is on by default. Turn it off with **Settings → General → Working section** on web and desktop, or **Settings → General → Working section** on iOS and Android. Each device keeps its own