Skip to content
Merged
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
61 changes: 57 additions & 4 deletions apps/web/src/chat/ChatSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
Expand Down Expand Up @@ -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<string | null>(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<string[]>([]);
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.
Expand All @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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 ? (
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/chat/bulkClose.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
32 changes: 32 additions & 0 deletions apps/web/src/chat/bulkClose.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { id: string }>(
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);
}
61 changes: 61 additions & 0 deletions apps/web/src/contextMenu/registrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
37 changes: 30 additions & 7 deletions apps/web/src/contextMenu/registrations.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -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: <Plus size={14} />, run: () => ctx?.onNew?.() },
Expand All @@ -240,6 +246,23 @@ registerContextMenu({
});
out.push({ id: "tab-div", divider: true });
out.push({ id: "close", label: "Close chat", icon: <X size={14} />, 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: <X size={14} />, danger: true, run: () => ctx.onCloseOthers?.() });
}
if (ctx.onCloseLeft) {
bulk.push({ id: "close-left", label: "Close left", icon: <ChevronsLeft size={14} />, danger: true, run: () => ctx.onCloseLeft?.() });
}
if (ctx.onCloseRight) {
bulk.push({ id: "close-right", label: "Close right", icon: <ChevronsRight size={14} />, danger: true, run: () => ctx.onCloseRight?.() });
}
if (bulk.length) {
out.push({ id: "bulk-div", divider: true });
out.push(...bulk);
}
}
return out;
},
Expand Down
Loading