diff --git a/apps/web/src/chat/ChatSurface.tsx b/apps/web/src/chat/ChatSurface.tsx index bec1b732..8726514d 100644 --- a/apps/web/src/chat/ChatSurface.tsx +++ b/apps/web/src/chat/ChatSurface.tsx @@ -40,6 +40,7 @@ import { finalizeStoppedMessages, resolveStopTarget } from "./stopTurn"; import { addComponent, addToolRef, appendReasoning, appendText, replaceText } from "./parts"; import { createStreamWatchdog } from "./streamWatchdog"; import { ADD_SELECTOR, isIncognitoAddClick, trackShiftHeld } from "./shiftCue"; +import { sessionsToClose } from "./bulkClose"; function messageId() { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -122,6 +123,11 @@ export function ChatSurface({ const serverTurnSessions = useServerTurnSessions(); const currentSession = chat.sessions.find((session) => session.id === chat.currentSessionId) || null; const [pendingClose, setPendingClose] = useState(null); + // Bulk close (others/left/right): GOAL tabs still waiting for their Stop/Detach confirm AFTER + // the one in `pendingClose`. Only goal tabs are queued — plain tabs close inline in + // startBulkClose — so we never parade a "Delete this chat?" dialog past each tab (the + // dialog-storm the spec warns against), and exactly one dialog is ever open. + const [closeQueue, setCloseQueue] = useState([]); const [harvestOnDelete, setHarvestOnDelete] = useState(false); // Goal tab close: default keeps the goal running (detach); toggle on to STOP it (clear the // goal + close its task backlog) instead. @@ -148,6 +154,45 @@ export function ChatSurface({ chatStore.deleteSession(id); } + // Kick off a bulk close (Close others/left/right). `ids` is the already-resolved target list + // (sessionsToClose, anchor excluded). Split it: plain tabs close immediately (no harvest — + // matching the delete dialog's default); goal-driving tabs, whose Stop-vs-Detach choice can't + // be defaulted safely, are queued through the SAME single-tab confirm one at a time. + function startBulkClose(ids: string[]) { + if (ids.length === 0) return; + const activeGoalIds = new Set( + goalSessions.filter((g) => g.status === "active").map((g) => g.session_id), + ); + const goals = ids.filter((id) => activeGoalIds.has(id)); + for (const id of ids) { + if (!activeGoalIds.has(id)) closeSession(id, false); + } + setHarvestOnDelete(false); + setStopGoalOnClose(false); + setPendingClose(goals[0] ?? null); + setCloseQueue(goals.slice(1)); + } + + // A close dialog resolved (confirmed): promote the next queued goal tab into the dialog, or + // close it when the queue is drained. Per-dialog toggles reset each step so every tab starts + // from the default (harvest off, goal detach). For a single (non-bulk) close the queue is + // empty, so this just clears the dialog. + function advanceClose() { + setHarvestOnDelete(false); + setStopGoalOnClose(false); + setPendingClose(closeQueue[0] ?? null); + setCloseQueue((queue) => queue.slice(1)); + } + + // Cancel: abort the WHOLE bulk operation, not just the current tab — hitting cancel means + // "stop closing", so the remaining queued tabs are spared. + function cancelClose() { + setPendingClose(null); + setCloseQueue([]); + setHarvestOnDelete(false); + setStopGoalOnClose(false); + } + // Tab-strip Shift cues. While Shift is held the DS TabBar signals both Shift+click gestures: // the "+" becomes the incognito EyeOff (Shift+click → new incognito chat, #1697/#1744) and the // hovered ✕ becomes a red trashcan (Shift+click → quick-delete, no confirm/harvest, #1373). @@ -203,6 +248,12 @@ export function ChatSurface({ function onTabContextMenu(id: string, e: ReactMouseEvent) { const tabEl = (e.target as HTMLElement).closest(".pl-tabbar__tab") as HTMLElement | null; const target = chat.sessions.find((s) => s.id === id); + // Resolve each bulk-close target set up front (index math, anchor excluded). An empty set + // means the entry is meaningless for this tab (e.g. "Close left" on the leftmost tab), so + // the closure is passed only when it has something to close — the menu hides the rest. + const others = sessionsToClose(chat.sessions, id, "others"); + const left = sessionsToClose(chat.sessions, id, "left"); + const right = sessionsToClose(chat.sessions, id, "right"); openContextMenu("chat-tab", e, { sessionId: id, incognito: !!target?.incognito, @@ -211,6 +262,9 @@ export function ChatSurface({ onToggleIncognito: () => chatStore.setSessionIncognito(id, !target?.incognito), onRename: () => tabEl?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })), onClose: () => setPendingClose(id), + onCloseOthers: others.length ? () => startBulkClose(others) : undefined, + onCloseLeft: left.length ? () => startBulkClose(left) : undefined, + onCloseRight: right.length ? () => startBulkClose(right) : undefined, }); } @@ -332,11 +386,10 @@ export function ChatSurface({ closeSession(pendingClose, harvestOnDelete); } } - setPendingClose(null); - setHarvestOnDelete(false); - setStopGoalOnClose(false); + // Advance the bulk-close queue (or just clear the dialog when it's a single close). + advanceClose(); }} - onClose={() => { setPendingClose(null); setHarvestOnDelete(false); setStopGoalOnClose(false); }} + onClose={cancelClose} > {pendingCloseSession ? ( closingGoal ? ( diff --git a/apps/web/src/chat/bulkClose.test.ts b/apps/web/src/chat/bulkClose.test.ts new file mode 100644 index 00000000..e9271176 --- /dev/null +++ b/apps/web/src/chat/bulkClose.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { sessionsToClose } from "./bulkClose"; + +const sessions = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }]; + +describe("sessionsToClose", () => { + it("'others' returns every tab except the anchor", () => { + expect(sessionsToClose(sessions, "c", "others")).toEqual(["a", "b", "d", "e"]); + }); + + it("'left' returns only the tabs before the anchor, in order", () => { + expect(sessionsToClose(sessions, "c", "left")).toEqual(["a", "b"]); + }); + + it("'right' returns only the tabs after the anchor, in order", () => { + expect(sessionsToClose(sessions, "c", "right")).toEqual(["d", "e"]); + }); + + it("'left' on the first tab is empty (nothing to the left)", () => { + expect(sessionsToClose(sessions, "a", "left")).toEqual([]); + }); + + it("'right' on the last tab is empty (nothing to the right)", () => { + expect(sessionsToClose(sessions, "e", "right")).toEqual([]); + }); + + it("never includes the anchor itself", () => { + for (const mode of ["others", "left", "right"] as const) { + expect(sessionsToClose(sessions, "c", mode)).not.toContain("c"); + } + }); + + it("returns empty for an unknown anchor (nothing to close)", () => { + expect(sessionsToClose(sessions, "zzz", "others")).toEqual([]); + expect(sessionsToClose(sessions, "zzz", "left")).toEqual([]); + expect(sessionsToClose(sessions, "zzz", "right")).toEqual([]); + }); + + it("returns empty for a single-tab strip in every mode", () => { + const one = [{ id: "only" }]; + expect(sessionsToClose(one, "only", "others")).toEqual([]); + expect(sessionsToClose(one, "only", "left")).toEqual([]); + expect(sessionsToClose(one, "only", "right")).toEqual([]); + }); +}); diff --git a/apps/web/src/chat/bulkClose.ts b/apps/web/src/chat/bulkClose.ts new file mode 100644 index 00000000..92b53755 --- /dev/null +++ b/apps/web/src/chat/bulkClose.ts @@ -0,0 +1,32 @@ +// Bulk chat-tab close (Close others / left / right, #context-menu). Pure index math over the +// ordered session list so it can be unit-tested without React or the store — ChatSurface owns +// the side effects (confirm dialog, deleteSession). Chat sessions have no "pinned" concept +// (unlike model favorites), so nothing needs excluding beyond the right-clicked anchor itself. + +export type BulkCloseMode = "others" | "left" | "right"; + +/** + * The session ids a bulk-close action targets, given the ordered session list and the anchor + * (right-clicked) tab. Index-based against `sessions` order: + * - "others" → every tab except the anchor + * - "left" → every tab positioned before the anchor + * - "right" → every tab positioned after the anchor + * The anchor is never included. An anchor that isn't in the list yields an empty array (nothing + * to close), as does any mode with no tabs on the requested side (e.g. "left" on the first tab). + */ +export function sessionsToClose( + sessions: readonly T[], + anchorId: string, + mode: BulkCloseMode, +): string[] { + const anchor = sessions.findIndex((s) => s.id === anchorId); + if (anchor < 0) return []; + return sessions + .filter((_, i) => { + if (i === anchor) return false; + if (mode === "left") return i < anchor; + if (mode === "right") return i > anchor; + return true; // "others" + }) + .map((s) => s.id); +} diff --git a/apps/web/src/contextMenu/registrations.test.ts b/apps/web/src/contextMenu/registrations.test.ts index 977027d3..78fd4a93 100644 --- a/apps/web/src/contextMenu/registrations.test.ts +++ b/apps/web/src/contextMenu/registrations.test.ts @@ -46,3 +46,64 @@ describe("rail-surface plugin lifecycle menu (#1521 / #1522)", () => { expect(got).toContain("uninstall"); }); }); + +// The chat-tab menu's bulk closers (Close others/left/right): ChatSurface passes each closure +// only when that action has tabs to close, and the menu shows an entry only when it received the +// closure — so, e.g., "Close left" never appears on the leftmost tab. +describe("chat-tab bulk-close menu", () => { + const noop = () => {}; + + it("offers all three bulk closers when every closure is supplied", () => { + const got = ids( + resolveMenu("chat-tab", { + sessionId: "s2", + onClose: noop, + onCloseOthers: noop, + onCloseLeft: noop, + onCloseRight: noop, + }), + ); + expect(got).toEqual(expect.arrayContaining(["close", "close-others", "close-left", "close-right"])); + }); + + it("hides a bulk closer whose closure is absent (e.g. no tabs on that side)", () => { + const got = ids( + resolveMenu("chat-tab", { + sessionId: "s1", + onClose: noop, + onCloseOthers: noop, + onCloseRight: noop, // leftmost tab: nothing to the left → no onCloseLeft + }), + ); + expect(got).toContain("close-others"); + expect(got).toContain("close-right"); + expect(got).not.toContain("close-left"); + }); + + it("omits every bulk closer (and their divider) on a lone tab", () => { + const got = ids(resolveMenu("chat-tab", { sessionId: "only", onClose: noop })); + expect(got).toContain("close"); + expect(got).not.toContain("close-others"); + expect(got).not.toContain("close-left"); + expect(got).not.toContain("close-right"); + expect(got).not.toContain("bulk-div"); + }); + + it("shows no per-tab actions (single or bulk) for the empty-space menu", () => { + const got = ids(resolveMenu("chat-tab", { onNew: noop, onNewIncognito: noop })); + expect(got).toEqual(["new", "new-incognito"]); + }); + + it("marks the bulk closers destructive", () => { + const items = resolveMenu("chat-tab", { + sessionId: "s2", + onClose: noop, + onCloseOthers: noop, + onCloseLeft: noop, + onCloseRight: noop, + }) as Item[]; + for (const id of ["close-others", "close-left", "close-right"]) { + expect(items.find((i) => i.id === id)?.danger).toBe(true); + } + }); +}); diff --git a/apps/web/src/contextMenu/registrations.tsx b/apps/web/src/contextMenu/registrations.tsx index d97237a0..56032eac 100644 --- a/apps/web/src/contextMenu/registrations.tsx +++ b/apps/web/src/contextMenu/registrations.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftRight, ChevronDown, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, RefreshCw, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { ArrowLeftRight, ChevronDown, ChevronsLeft, ChevronsRight, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, RefreshCw, SlidersHorizontal, Trash2, X } from "lucide-react"; import { openView } from "../app/usePaletteRegistry"; import { useUI } from "../state/uiStore"; @@ -204,12 +204,15 @@ registerContextMenu({ }); // Right-click a chat session tab → New chat / New incognito chat / Rename / Incognito toggle / -// Close (ADR 0036). ChatSurface owns the behavior and passes it in `ctx` as closures: Close -// reuses its confirm-dialog flow, Rename triggers the DS TabBar's inline editor (a synthetic -// dblclick on the tab), the incognito entries flip the per-thread flag (ADR 0069 D3b — every -// send while ON carries metadata.incognito). Right-clicking empty tab-bar space carries only -// the `onNew*` closures. (The DS TabBar exposes no per-tab context-menu hook — a DS gap noted -// for contribute-back; ChatSurface delegates from the tab-bar wrapper meanwhile.) +// Close + bulk Close others/left/right (ADR 0036). ChatSurface owns the behavior and passes it +// in `ctx` as closures: Close reuses its confirm-dialog flow, Rename triggers the DS TabBar's +// inline editor (a synthetic dblclick on the tab), the incognito entries flip the per-thread +// flag (ADR 0069 D3b — every send while ON carries metadata.incognito). The bulk closers arrive +// pre-resolved: ChatSurface passes each `onCloseOthers/Left/Right` closure only when that action +// has tabs to close (e.g. no `onCloseLeft` on the leftmost tab), so the menu hides an entry +// simply by not receiving its closure. Right-clicking empty tab-bar space carries only the +// `onNew*` closures. (The DS TabBar exposes no per-tab context-menu hook — a DS gap noted for +// contribute-back; ChatSurface delegates from the tab-bar wrapper meanwhile.) registerContextMenu({ type: "chat-tab", items: (ctx: { @@ -220,6 +223,9 @@ registerContextMenu({ onToggleIncognito?: () => void; onRename?: () => void; onClose?: () => void; + onCloseOthers?: () => void; + onCloseLeft?: () => void; + onCloseRight?: () => void; }): MenuEntry[] => { const out: MenuEntry[] = [ { id: "new", label: "New chat", icon: , run: () => ctx?.onNew?.() }, @@ -240,6 +246,23 @@ registerContextMenu({ }); out.push({ id: "tab-div", divider: true }); out.push({ id: "close", label: "Close chat", icon: , danger: true, run: () => ctx.onClose?.() }); + // Bulk closers, each present only when it has targets (ChatSurface passes the closure + // only then). A divider precedes them, but only if at least one is offered — so the menu + // never shows a trailing separator with nothing under it. + const bulk: MenuEntry[] = []; + if (ctx.onCloseOthers) { + bulk.push({ id: "close-others", label: "Close others", icon: , danger: true, run: () => ctx.onCloseOthers?.() }); + } + if (ctx.onCloseLeft) { + bulk.push({ id: "close-left", label: "Close left", icon: , danger: true, run: () => ctx.onCloseLeft?.() }); + } + if (ctx.onCloseRight) { + bulk.push({ id: "close-right", label: "Close right", icon: , danger: true, run: () => ctx.onCloseRight?.() }); + } + if (bulk.length) { + out.push({ id: "bulk-div", divider: true }); + out.push(...bulk); + } } return out; },