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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions apps/web/src/components/Sidebar.drag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -120,20 +121,24 @@ 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")] : []),
...(sourceSection === "pinned" ? [thread("source", "pinned")] : []),
divider,
thread("a", "active"),
...(sourceSection === "active" ? [thread("source", "active")] : []),
...(sourceSection === "working" ? [workingHeader, thread("source", "working")] : []),
settledHeader,
...(sourceSection === "settled" ? [thread("source", "settled")] : []),
];
Expand Down Expand Up @@ -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,
},
Expand All @@ -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");
},
);

Expand Down Expand Up @@ -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,
Expand Down
34 changes: 23 additions & 11 deletions apps/web/src/components/Sidebar.drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -98,6 +101,7 @@ export function createSidebarSortingStrategy(input: {
settledVisibleCount?: number;
routeThreadKey?: string | null;
snoozedThreadCount?: number;
workingThreadCount?: number;
cardHeight?: number;
slimHeight?: number;
boundaryLabelHeight?: number;
Expand All @@ -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<SidebarSection, ThreadItem[]> = {
pinned: [],
active: [],
Expand Down Expand Up @@ -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)
: [];
Expand All @@ -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);
}
Expand Down
42 changes: 23 additions & 19 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
},
);

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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 &&
Expand Down
3 changes: 2 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading