summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md60
-rw-r--r--notes/assumptions-log.md12
-rw-r--r--src/app/App.test.ts115
-rw-r--r--src/features/tabs/tab-groups.test.ts9
-rw-r--r--src/features/tabs/ui.test.ts71
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte44
-rw-r--r--src/features/tabs/ui/TabList.svelte12
-rw-r--r--vitest-setup.ts12
8 files changed, 324 insertions, 11 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 0878b93..e6da7b9 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -18,6 +18,66 @@
> ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists
> rebuilt). Recorded below.
+## Milestone: Close-tab cascade subagents (`feature/close-tab-cascade-subagents`, 2026-10-24)
+
+**Slice status: COMPLETE across the isolated frontend + backend feature branches.**
+
+- Frontend integrated local `dev` at `913cc7b` (merge `b462f17`), preserving the new
+ tab colors, 70vh grid, FLIP close/reflow animations and current reliability fixes.
+- Closing a tab with a nonempty **visible tab-group** subtree prompts once:
+ **Yes, close all N** closes the snapshotted transitive subtree; **No, keep them open**
+ closes only the parent; Cancel/Escape/backdrop close nothing. Leaf tabs close directly.
+ New tabs arriving after confirmation opens are not silently added to its close set.
+ This is visual `tabGroupParentId` grouping, NOT backend lineage traversal or an implicit
+ server cascade. Other workspaces and unopened conversations are outside this close set.
+- Native modal dialog renders in the browser top layer (not clipped by animated/sidebar
+ containers), traps keyboard focus, starts on Cancel, restores focus on dismissal, and
+ wraps actions at mobile widths. Remote target closure/workspace switch dismisses stale UI.
+- Each id uses the existing `AppStore.closeTab` → single `POST /conversations/:id/close`
+ route; real-store component regressions cover persistence + workspace isolation for
+ all/parent/cancel. No double-close workaround or new FE cross-unit port.
+
+**Backend race discovered and FIXED, not waived:** explicit Close aborted the turn, but
+its finalization unconditionally persisted/published `idle` afterward, reopening the tab.
+The original companion symlink to main was replaced (with user authorization) by a real
+backend `feature/close-tab-cascade-subagents` worktree at local `dev` `4bf4dd9`.
+Commit **`fd07259`** preserves Close through deferred lifecycle work and settlement,
+keeps Stop → idle + explicit reopen behavior, suppresses stale broadcasts/auto-compaction,
+and cleans per-turn lifecycle tracking. Five real-store regressions cover the race.
+See backend `notes/close-tab-cascade-subagents.md` for reproducible direct HTTP steps.
+Neither main checkout nor the user's running backend process was edited/restarted.
+
+**Contract pins/mirrors:** unchanged `file:../backend/packages/*` deps; `[email protected]`,
+`[email protected]`, `[email protected]` (existing additive mirrors retained).
+Dependencies were refreshed after replacing the companion with its real worktree.
+No API/type/version delta to mirror; no remaining backend asks.
+
+**Verification:**
+- FE: `bun run typecheck` **0 errors/0 warnings**; full suite **2,516 passed ×2**
+ (127 files; an earlier pass before the final focus regression was 2,515);
+ `bun run check` clean; `bun run build` succeeds (existing Svelte capture/chunk-size warnings).
+- BE: typecheck clean; Vitest **4,214 passed / 10 skipped**; Biome clean;
+ Bun suite final combined run **188 passed**, also all seven groups pass independently.
+ Two earlier combined Bun runs hit native Bun 1.3.14 crashes; subsequent combined run passed.
+- Writable live verification used ONLY a private source backend at **24303/24305**, with
+ fresh SQLite/trace/journal/image/plugin paths under `/tmp/close-tab-isolated` and
+ inherited real credentials (never printed or replaced). Standard live probe **22/22**.
+- Direct HTTP active-turn close now returns `abortedTurn: true`, seals/ends aborted and
+ stays absent from active/idle list after stream completion. Real Chromium end-to-end
+ cascade does the same for the nested active child, leaves unrelated tabs and stays
+ closed after reload. Desktop 1280px + mobile 420px checks pass: all/parent-only,
+ Cancel/Escape/backdrop, leaf close, remote target close, top-layer modal, focus containment
+ and restoration, mobile bounds; no uncaught browser exceptions.
+- Fixture conversations closed/workspaces archived. Reports, screenshots and probe harnesses:
+ `reports/close-tab-cascade/` (gitignored). Private server + collector and browser/Vite
+ processes are cleaned up after verification. No merge back or push.
+
+**Human smoke check still requested:** open a real parent with nested subagent tabs;
+close → Cancel/Escape, close → No (children stay), then close another group → Yes
+(all descendants stop/close). At mobile width open the sidebar first; confirm all three
+buttons remain reachable and the dialog is not clipped. Browser automation passed this
+flow, but theme/layout should still be confirmed in the user's own browser.
+
## Milestone: reasoning block boundaries on the live stream (2026-10-22)
FE slice consuming a new ADDITIVE backend wire event. The kernel now emits one payload-free
diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md
index c6daf59..89b057a 100644
--- a/notes/assumptions-log.md
+++ b/notes/assumptions-log.md
@@ -2,6 +2,18 @@
> Recorded while working autonomously (user away). Raise these when the user returns.
+## 2026-10-24 — Close-tab cascade completion
+
+- Retained the original visible-group + snapshot semantics below, including one close
+ request per id; no backend lineage cascade and no close-all retry workaround.
+- User explicitly authorized replacing the companion backend symlink with an isolated
+ feature worktree to fix the reproducible active-turn Close → idle finalization race.
+ Backend commit `fd07259` fixes this and adds regression coverage. Verification writes
+ for the fixed backend use fresh private runtime paths, never the user's instance.
+- Native modal lifecycle (top layer/focus containment/safe initial focus) replaces the
+ original CSS-only overlay; stale target dialogs dismiss on workspace/remote removal.
+- Final verification and cross-repo handoff: `backend-handoff.md` close-tab milestone.
+
## 2026-10-16 — Close-tab cascade confirm dialog (subagent groups)
1. **"Subagents" of a tab = its VISIBLE subagent group (open tabs only).** The confirm dialog
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 9c5f5cc..ebc8004 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -2,7 +2,7 @@ import type { LayoutTree } from "@dispatch/layout-contract";
import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
@@ -180,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
}
describe("App component interaction tests", () => {
+ it.each([
+ "all",
+ "parent",
+ "cancel",
+ ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const closed: string[] = [];
+ const base = fakeFetchWithLayout(["tabs"]);
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ { id: "p", workspaceId: "default" },
+ { id: "c1", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "c2", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "g", workspaceId: "default", tabGroupParentId: "c1" },
+ { id: "solo", workspaceId: "default" },
+ { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" },
+ ].map((conversation) => ({
+ ...conversation,
+ title: conversation.id,
+ status: "idle",
+ lastActivityAt: 0,
+ })),
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/close")) {
+ expect(init?.method).toBe("POST");
+ const id = url.split("/").at(-2) as string;
+ closed.push(id);
+ return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ },
+ localStorage: storage,
+ });
+ try {
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ await screen.findByRole("tablist");
+ for (const [id, parent] of [
+ ["p", null],
+ ["c1", "p"],
+ ["c2", "p"],
+ ["g", "c1"],
+ ["solo", null],
+ ] as const) {
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: id,
+ workspaceId: "default",
+ ...(parent === null ? {} : { tabGroupParentId: parent }),
+ });
+ }
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "other-workspace",
+ workspaceId: "other",
+ tabGroupParentId: "p",
+ });
+ await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5));
+ store.selectTab("p");
+ const parent = await waitFor(() => {
+ const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]');
+ expect(tab).not.toBeNull();
+ return tab;
+ });
+ if (parent === null) throw new Error("Parent tab missing");
+ await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" }));
+ const dialog = screen.getByRole("dialog", { name: "Close subagents too?" });
+ expect(dialog).toHaveTextContent("3 subagent tabs");
+ expect(closed).toEqual([]);
+ await fireEvent.click(
+ within(dialog).getByRole("button", {
+ name:
+ choice === "all"
+ ? /Yes, close all 4/
+ : choice === "parent"
+ ? /No, keep them open/
+ : "Cancel",
+ }),
+ );
+ const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : [];
+ expect(closed).toEqual(expected);
+ expect(store.tabs.map((tab) => tab.conversationId)).toEqual(
+ ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ if (choice !== "cancel") {
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as {
+ tabs: { conversationId: string }[];
+ };
+ expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([
+ ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ "other-workspace",
+ ]);
+ expect(store.activeConversationId).not.toBe("p");
+ }
+ } finally {
+ store.dispose();
+ }
+ });
+
it("renders the model selector and composer in draft mode", () => {
const ws = fakeSocket();
const store = createAppStore({
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index 67150ff..46b04e7 100644
--- a/src/features/tabs/tab-groups.test.ts
+++ b/src/features/tabs/tab-groups.test.ts
@@ -127,6 +127,15 @@ describe("cascadeCloseIds (the ids a cascading tab close removes)", () => {
expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]);
});
+ it("matches the rendered forest for cycles and manual regrouping", () => {
+ const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")];
+ expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]);
+ expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]);
+ const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")];
+ expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]);
+ expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]);
+ });
+
it("ignores tabs whose group parent is no longer open", () => {
const stale = [tab("p"), tab("orphan", "gone")];
expect(cascadeCloseIds(stale, "p")).toEqual(["p"]);
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 88b89bf..8f775a1 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -744,10 +744,79 @@ describe("TabList", () => {
expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]);
});
+ it("restores keyboard focus to the opener after cancellation", async () => {
+ renderClosable();
+ const user = userEvent.setup();
+ const opener = within(card("p")).getByRole("button", { name: "Close tab" });
+ await user.click(opener);
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(opener).toHaveFocus();
+ });
+
+ it("Cancel and backdrop dismiss without closing any tab", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
+ await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await clickCloseOn("p");
+ await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("keeps the confirmed subtree snapshot when new tabs arrive", async () => {
+ const onClose = vi.fn();
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose,
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await clickCloseOn("p");
+ await rerender({
+ ...props,
+ tabs: [
+ ...grouped,
+ {
+ conversationId: "late",
+ model: "m",
+ title: "Late",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ ],
+ });
+ expect(dialog()).toHaveTextContent("3 subagent tabs");
+ await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ }));
+ expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]);
+ });
+
+ it("dismisses when the target disappears (remote close or workspace switch)", async () => {
+ const onClose = vi.fn();
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose,
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await clickCloseOn("p");
+ await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") });
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await rerender(props);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
it("Escape dismisses the dialog without closing anything", async () => {
const onClose = renderClosable();
await clickCloseOn("p");
- await fireEvent.keyDown(window, { key: "Escape" });
+ await fireEvent(dialog(), new Event("cancel", { cancelable: true }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
index cbbec79..7ffda6c 100644
--- a/src/features/tabs/ui/CloseSubagentsConfirm.svelte
+++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
@@ -7,7 +7,8 @@
* the whole subtree — "Yes" cascades the close to every transitive
* descendant (sub-sub-agents included, no further prompts); "No" closes
* only the tab itself. Escape / backdrop dismisses without closing anything.
- * daisyUI `modal` (controlled: `modal-open`).
+ * Native modal dialog with daisyUI chrome: top-layer rendering, focus
+ * containment, and inert background even inside the animated sidebar.
*/
let {
tab,
@@ -27,14 +28,31 @@
/** Escape / backdrop — close nothing. */
onDismiss: () => void;
} = $props();
+ function openModal(node: HTMLDialogElement) {
+ const opener = document.activeElement;
+ node.showModal();
+ return {
+ destroy() {
+ node.close();
+ if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
+ },
+ };
+ }
</script>
-<svelte:window onkeydown={(e) => e.key === "Escape" && onDismiss()} />
-
-<div class="modal modal-open" role="dialog" aria-label="Close subagents too?">
+<dialog
+ use:openModal
+ class="modal"
+ aria-label="Close subagents too?"
+ aria-describedby="close-subagents-description"
+ oncancel={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+>
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Close subagents too?</h3>
- <div class="flex flex-col gap-1 py-3">
+ <div id="close-subagents-description" class="flex flex-col gap-1 py-3">
<p class="text-sm">
"{tab.title}" has {subagentCount}
{subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones).
@@ -44,7 +62,7 @@
stopped.
</p>
</div>
- <div class="modal-action">
+ <div class="modal-action flex-wrap">
<button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button>
<button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button>
<button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}>
@@ -52,6 +70,14 @@
</button>
</div>
</div>
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
- <div class="modal-backdrop" onclick={onDismiss}></div>
-</div>
+ <form
+ method="dialog"
+ class="modal-backdrop"
+ onsubmit={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+ >
+ <button type="submit" aria-label="Dismiss close confirmation">close</button>
+ </form>
+</dialog>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index 0493b5c..8b388f0 100644
--- a/src/features/tabs/ui/TabList.svelte
+++ b/src/features/tabs/ui/TabList.svelte
@@ -98,7 +98,19 @@
// in the meantime is a no-op for `onClose`. A plain tab closes directly.
let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null);
+ // A remote close or workspace switch must not leave an actionable dialog
+ // for a tab that is no longer in this list.
+ $effect(() => {
+ if (
+ closeConfirm !== null &&
+ !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId)
+ ) {
+ closeConfirm = null;
+ }
+ });
+
function requestClose(conversationId: string): void {
+ menu = null;
const cascadeIds = cascadeCloseIds(tabs, conversationId);
if (cascadeIds.length <= 1) {
onClose(conversationId);
diff --git a/vitest-setup.ts b/vitest-setup.ts
index a0ace27..94aa5e8 100644
--- a/vitest-setup.ts
+++ b/vitest-setup.ts
@@ -42,3 +42,15 @@ if (typeof globalThis.PointerEvent === "undefined") {
}
globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent;
}
+
+// jsdom has no native modal dialog lifecycle. Fake only this browser edge;
+// real-browser checks cover top-layer rendering and native focus containment.
+if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) {
+ HTMLDialogElement.prototype.showModal = function () {
+ this.open = true;
+ this.querySelector<HTMLElement>("button")?.focus();
+ };
+ HTMLDialogElement.prototype.close = function () {
+ this.open = false;
+ };
+}