diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 5b5bea72..2d9b5513 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -129,6 +129,32 @@ async def get_session(session_id: str): payload["event_seq"] = event_seq return payload +@agents.router.post("/sessions/{session_id}/model") +async def set_session_model(session_id: str, body: dict): + """Pin a session to a different model, on disk. + + The renderer already switched sessions off a retired model in its own store, but that write was + store-only, so the next metadata poll re-hydrated the dead model from disk and the "switched to + X" notice fired again, forever (the retired-Fable reports). Persisting it makes the heal stick + after one pass. + """ + model = str((body or {}).get("model") or "").strip() + if not model: + raise HTTPException(status_code=400, detail="model is required") + session = agent_manager.get_session(session_id) + if not session: + try: + session = await agent_manager.resume_session(session_id) + except ValueError: + raise HTTPException(status_code=404, detail="Session not found") + session.model = model + from backend.apps.agents.manager.session.session_store import save_session + doc_data = session.model_dump(mode="json") + doc_data["search_text"] = agent_manager.build_search_text(session) + save_session(session_id, doc_data) + return {"ok": True, "session_id": session_id, "model": model} + + @agents.router.post("/launch") async def launch_agent(config: AgentConfig): session = await agent_manager.launch_agent(config) diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index 12b998ad..fe1897a2 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -236,7 +236,7 @@ class SessionLifecycle(AgentManagerProtocol): return list(self.sessions.values()) # Memory first, then promote on-disk sessions for this dashboard, but ONLY ones the dashboard's layout still has a card for. A session keeps its dashboard_id when its card is deleted, so promoting by tag alone resurrected deleted chats on every reopen; the layout's cards are the real source of truth for what's on the board. Imported sessions ARE in the layout, so they still surface, and this bounds the disk read to once per session per run, like resume_session. result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] - card_ids = self.p_dashboard_card_ids(dashboard_id) + card_ids = self.dashboard_card_ids(dashboard_id) for sid, data in load_all_session_data(): # Skip anything already in memory (not just this dashboard's slice): promoting a stale disk copy over a live session would clobber its in-flight state. if sid in self.sessions or sid not in card_ids: @@ -254,7 +254,7 @@ class SessionLifecycle(AgentManagerProtocol): return result @typechecked - def p_dashboard_card_ids(self, dashboard_id: str) -> Set[str]: + def dashboard_card_ids(self, dashboard_id: str) -> Set[str]: """Session ids the dashboard's layout currently has agent cards for. Read straight off disk (no dashboards-module import, avoids a cycle). diff --git a/backend/apps/outputs/orphan_workspaces.py b/backend/apps/outputs/orphan_workspaces.py new file mode 100644 index 00000000..5f638a6f --- /dev/null +++ b/backend/apps/outputs/orphan_workspaces.py @@ -0,0 +1,111 @@ +"""Workspaces on disk that no app record points at, and what reclaiming them would free. + +Deleting an app used to leave its whole source tree behind (ENG-268), so installs carry orphans from +every app ever discarded: 9 of them, ~0.85GB, measured on one machine. That leak is fixed at the +delete path, but the existing pile is still there and nothing surfaces it. + +Deliberately a REPORT plus an explicit delete, never an unattended sweep. `recover_orphaned_apps` +re-registers orphans that still carry a real name, so a background cleaner racing it would be two +jobs disagreeing about the same folder, and one of them silently destroying work the other is trying +to restore. Listing is free and safe; deleting is the user's call, one id at a time. +""" + +import os +import shutil +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.config.json_store import read_json_or_none +from backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR + + +class OrphanWorkspace(BaseModel): + model_config = ConfigDict(validate_assignment=True) + workspace_id: str + name: str + bytes_on_disk: int + # node_modules is a symlink farm into the shared template cache, so a naive du triple-counts it + # across apps and reports a number that would not actually come back. + reclaimable_bytes: int + + +@typechecked +def p_tree_bytes(path: str, follow_symlinks: bool = False) -> int: + total = 0 + for dirpath, dirnames, filenames in os.walk(path, followlinks=follow_symlinks): + if not follow_symlinks and os.path.basename(dirpath) == "node_modules": + dirnames[:] = [] + continue + for fn in filenames: + fp = os.path.join(dirpath, fn) + try: + if not follow_symlinks and os.path.islink(fp): + continue + total += os.path.getsize(fp) + except OSError: + continue + return total + + +@typechecked +def p_referenced_workspace_ids() -> set: + """Every workspace some app record still points at.""" + out = set() + if not os.path.isdir(OUTPUTS_DIR): + return out + for fn in os.listdir(OUTPUTS_DIR): + if not fn.endswith(".json"): + continue + rec = read_json_or_none(os.path.join(OUTPUTS_DIR, fn)) + wsid = (rec or {}).get("workspace_id") + if isinstance(wsid, str) and wsid: + out.add(wsid) + return out + + +@typechecked +def p_workspace_name(workspace_dir: str) -> str: + meta = read_json_or_none(os.path.join(workspace_dir, "meta.json")) or {} + name = meta.get("name") + return name.strip() if isinstance(name, str) and name.strip() else "(unnamed)" + + +@typechecked +def list_orphan_workspaces() -> List[OrphanWorkspace]: + """Read-only. Never deletes, so it is safe to call from anywhere, including a status surface.""" + if not os.path.isdir(OUTPUTS_WORKSPACE_DIR): + return [] + referenced = p_referenced_workspace_ids() + out: List[OrphanWorkspace] = [] + for wsid in sorted(os.listdir(OUTPUTS_WORKSPACE_DIR)): + path = os.path.join(OUTPUTS_WORKSPACE_DIR, wsid) + if not os.path.isdir(path) or wsid in referenced: + continue + out.append(OrphanWorkspace( + workspace_id=wsid, + name=p_workspace_name(path), + bytes_on_disk=p_tree_bytes(path, follow_symlinks=False), + reclaimable_bytes=p_tree_bytes(path, follow_symlinks=False), + )) + return out + + +@typechecked +def delete_orphan_workspace(workspace_id: str) -> Optional[int]: + """Remove ONE orphan by id, refusing anything still referenced or outside the root. + + Returns the bytes freed, or None when the id is not a deletable orphan. Same realpath + + component-boundary guard the delete path uses: an id is stored data and rmtree is not a call to + take on trust. + """ + if workspace_id in p_referenced_workspace_ids(): + return None + root = os.path.realpath(OUTPUTS_WORKSPACE_DIR) + target = os.path.realpath(os.path.join(root, workspace_id)) + if target == root or not target.startswith(root + os.sep) or not os.path.isdir(target): + return None + freed = p_tree_bytes(target, follow_symlinks=False) + shutil.rmtree(target, ignore_errors=True) + return freed diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index b137ab21..1f1b1e74 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -648,6 +648,28 @@ async def update_output(output_id: str, body: OutputUpdate): return {"ok": True, "output": output.model_dump()} +@outputs.router.get("/orphan-workspaces") +async def list_orphans(): + """App folders on disk that no record points at, with what deleting them would free.""" + from backend.apps.outputs.orphan_workspaces import list_orphan_workspaces + items = list_orphan_workspaces() + return { + "items": [i.model_dump(mode="json") for i in items], + "total_reclaimable_bytes": sum(i.reclaimable_bytes for i in items), + } + + +@outputs.router.delete("/orphan-workspaces/{workspace_id}") +async def delete_orphan(workspace_id: str): + """Delete ONE orphan, by explicit id. Never a sweep: the orphan recoverer re-registers folders + that still carry a real name, so an unattended cleaner would race it and destroy real work.""" + from backend.apps.outputs.orphan_workspaces import delete_orphan_workspace + freed = delete_orphan_workspace(workspace_id) + if freed is None: + raise HTTPException(status_code=404, detail="Not a deletable orphan workspace") + return {"ok": True, "workspace_id": workspace_id, "freed_bytes": freed} + + @outputs.router.delete("/{output_id}") async def delete_output(output_id: str): output = load(output_id) diff --git a/backend/config/entity_references.py b/backend/config/entity_references.py index 856c1f76..f48a752c 100644 --- a/backend/config/entity_references.py +++ b/backend/config/entity_references.py @@ -90,6 +90,7 @@ CROSS_ENTITY_REFERENCES: List[EntityReference] = [ EntityReference(module="backend.apps.outputs.models", model="AgentCreateAppRequest", field="parent_session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.outputs.models", model="Output", field="session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.outputs.models", model="Output", field="workspace_id", target=EntityKind.WORKSPACE), + EntityReference(module="backend.apps.outputs.orphan_workspaces", model="OrphanWorkspace", field="workspace_id", target=EntityKind.WORKSPACE), EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="workspace_id", target=EntityKind.WORKSPACE), EntityReference(module="backend.apps.outputs.models", model="OutputExecute", field="output_id", target=EntityKind.OUTPUT), diff --git a/backend/tests/test_orphan_workspaces.py b/backend/tests/test_orphan_workspaces.py new file mode 100644 index 00000000..3dd70016 --- /dev/null +++ b/backend/tests/test_orphan_workspaces.py @@ -0,0 +1,102 @@ +"""Orphan app workspaces: list them honestly, delete only what is safe. + +ENG-268 stopped delete from LEAVING orphans, but the existing pile (9 folders, ~0.85GB measured on +one machine) is still on disk with nothing surfacing it. This is deliberately a report plus a +one-at-a-time delete rather than a sweep: recover_orphaned_apps re-registers orphans that still +carry a real name, so an unattended cleaner racing it would be two jobs fighting over the same +folder with one of them destroying work the other is restoring. +""" + +import json +import os +from typing import Any +import pytest +import backend.config.paths as config_paths + + +@pytest.fixture() +def dirs(tmp_path: Any, monkeypatch: pytest.MonkeyPatch): + outputs = tmp_path / "outputs" + ws = tmp_path / "outputs_workspace" + outputs.mkdir() + ws.mkdir() + monkeypatch.setattr(config_paths, "OUTPUTS_DIR", str(outputs)) + monkeypatch.setattr(config_paths, "OUTPUTS_WORKSPACE_DIR", str(ws)) + import backend.apps.outputs.orphan_workspaces as mod + monkeypatch.setattr(mod, "OUTPUTS_DIR", str(outputs)) + monkeypatch.setattr(mod, "OUTPUTS_WORKSPACE_DIR", str(ws)) + return outputs, ws + + +def p_make_ws(ws, wsid: str, name: str, payload_bytes: int = 4096) -> str: + d = ws / wsid + (d / "frontend" / "src").mkdir(parents=True) + (d / "frontend" / "src" / "App.tsx").write_text("x" * payload_bytes, encoding="utf-8") + (d / "meta.json").write_text(json.dumps({"name": name}), encoding="utf-8") + return str(d) + + +def p_make_record(outputs, output_id: str, wsid: str) -> None: + (outputs / f"{output_id}.json").write_text( + json.dumps({"id": output_id, "name": "Live app", "workspace_id": wsid}), encoding="utf-8") + + +def test_a_referenced_workspace_is_never_an_orphan(dirs) -> None: + outputs, ws = dirs + p_make_ws(ws, "ws-live", "Live app") + p_make_record(outputs, "out1", "ws-live") + from backend.apps.outputs.orphan_workspaces import list_orphan_workspaces + assert [o.workspace_id for o in list_orphan_workspaces()] == [] + + +def test_an_unreferenced_workspace_is_reported_with_its_size(dirs) -> None: + outputs, ws = dirs + p_make_ws(ws, "ws-dead", "Stopwatch", payload_bytes=8192) + from backend.apps.outputs.orphan_workspaces import list_orphan_workspaces + got = list_orphan_workspaces() + assert len(got) == 1 + assert got[0].workspace_id == "ws-dead" + assert got[0].name == "Stopwatch" + assert got[0].reclaimable_bytes >= 8192 + + +def test_node_modules_is_excluded_from_the_size(dirs) -> None: + """It is a symlink farm into a shared cache; counting it promises space that never comes back.""" + outputs, ws = dirs + d = p_make_ws(ws, "ws-heavy", "Heavy", payload_bytes=1024) + nm = os.path.join(d, "node_modules", "pkg") + os.makedirs(nm) + with open(os.path.join(nm, "big.js"), "w", encoding="utf-8") as f: + f.write("y" * 500_000) + from backend.apps.outputs.orphan_workspaces import list_orphan_workspaces + got = list_orphan_workspaces()[0] + assert got.reclaimable_bytes < 100_000, "node_modules leaked into the reclaimable figure" + + +def test_deleting_an_orphan_frees_it(dirs) -> None: + outputs, ws = dirs + d = p_make_ws(ws, "ws-dead", "Gone") + from backend.apps.outputs.orphan_workspaces import delete_orphan_workspace + freed = delete_orphan_workspace("ws-dead") + assert freed is not None and freed > 0 + assert not os.path.exists(d) + + +def test_deleting_a_REFERENCED_workspace_is_refused(dirs) -> None: + """The one that must never fire: a live app's folder is not an orphan.""" + outputs, ws = dirs + d = p_make_ws(ws, "ws-live", "Live app") + p_make_record(outputs, "out1", "ws-live") + from backend.apps.outputs.orphan_workspaces import delete_orphan_workspace + assert delete_orphan_workspace("ws-live") is None + assert os.path.isdir(d), "a referenced workspace was deleted" + + +def test_an_id_that_escapes_the_root_is_refused(dirs, tmp_path: Any) -> None: + outputs, ws = dirs + precious = tmp_path / "precious" + precious.mkdir() + (precious / "keep.txt").write_text("do not delete", encoding="utf-8") + from backend.apps.outputs.orphan_workspaces import delete_orphan_workspace + assert delete_orphan_workspace("../precious") is None + assert (precious / "keep.txt").is_file(), "delete escaped the workspace root" diff --git a/backend/tests/test_sessions_list_never_wipes_on_read_error.py b/backend/tests/test_sessions_list_never_wipes_on_read_error.py index 525fac08..c4310eab 100644 --- a/backend/tests/test_sessions_list_never_wipes_on_read_error.py +++ b/backend/tests/test_sessions_list_never_wipes_on_read_error.py @@ -3,7 +3,7 @@ The wipe chain, reproduced live on 2026-08-12 (it destroyed a real dev dashboard): the sessions list answering [] is treated as AUTHORITY by the renderer, which strips its store, deletes every card, and the debounced layout save persists the wipe. Card-less sessions are never promoted from -disk again, so one wrong [] is permanent. p_dashboard_card_ids used to swallow every exception into +disk again, so one wrong [] is permanent. dashboard_card_ids used to swallow every exception into an empty set, which turned a garbled or half-written dashboard file (a crash mid-save is exactly the world ENG-244/246 live in) into that wrong []. Missing file stays a real empty board. """ @@ -25,13 +25,13 @@ def dash_dir(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> str: def test_a_missing_dashboard_file_is_a_real_empty_board(dash_dir: str) -> None: - assert agent_manager.p_dashboard_card_ids("no-such-dashboard") == set() + assert agent_manager.dashboard_card_ids("no-such-dashboard") == set() def test_a_readable_dashboard_yields_its_card_ids(dash_dir: str) -> None: with open(os.path.join(dash_dir, "d1.json"), "w", encoding="utf-8") as f: json.dump({"layout": {"cards": {"s1": {"session_id": "s1"}, "s2": {"session_id": "s2"}}}}, f) - assert agent_manager.p_dashboard_card_ids("d1") == {"s1", "s2"} + assert agent_manager.dashboard_card_ids("d1") == {"s1", "s2"} def test_a_garbled_dashboard_file_fails_loud_instead_of_answering_empty(dash_dir: str) -> None: @@ -39,7 +39,7 @@ def test_a_garbled_dashboard_file_fails_loud_instead_of_answering_empty(dash_dir with open(os.path.join(dash_dir, "d2.json"), "w", encoding="utf-8") as f: f.write('{"layout": {"cards": {') with pytest.raises(Exception): - agent_manager.p_dashboard_card_ids("d2") + agent_manager.dashboard_card_ids("d2") def test_get_all_sessions_propagates_the_read_error(dash_dir: str) -> None: diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 2e2c75bc..f229bb6c 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -11,7 +11,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings, updateSettingsPatch, markFreeTrialArmSettled } from '@/shared/state/settingsSlice'; import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; -import { updateSessionModel } from '@/shared/state/agentsSlice'; +import { updateSessionModel, persistSessionModel } from '@/shared/state/agentsSlice'; import { API_BASE } from '@/shared/config'; import { setAppVersion, @@ -410,6 +410,8 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } switched = true; } dispatch(updateSessionModel({ sessionId: sess.id, model: target })); + // Write it through, or the poll re-hydrates the dead model and we are back here next tick. + void dispatch(persistSessionModel({ sessionId: sess.id, model: target })); } } if (switched) { diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index c355dfb4..c113b79f 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -209,6 +209,22 @@ export const fetchSessions = createAsyncThunk( }, ); +// Persist a model switch so the heal sticks: updateSessionModel is store-only, and the metadata +// poll re-hydrates the dead model from disk, which is why the "switched to X" notice repeated +// forever for anyone holding a chat pinned to a retired model. +export const persistSessionModel = createAsyncThunk( + 'agents/persistSessionModel', + async ({ sessionId, model }: { sessionId: string; model: string }) => { + const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model }), + }); + if (!res.ok) throw new Error(`model persist failed: ${res.status}`); + return { sessionId, model }; + }, +); + export const launchAgent = createAsyncThunk('agents/launchAgent', async (config: AgentConfig) => { const res = await fetch(`${AGENTS_API}/launch`, { method: 'POST', diff --git a/frontend/src/shared/state/browserCardIds.test.ts b/frontend/src/shared/state/browserCardIds.test.ts new file mode 100644 index 00000000..1b2dcece --- /dev/null +++ b/frontend/src/shared/state/browserCardIds.test.ts @@ -0,0 +1,37 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// addBrowserCard built its id from Date.now() alone, so two cards created inside the same +// millisecond got the SAME id and the second silently overwrote the first. Found by a fixture that +// added two browsers and only ever saw one; the earlier surface census confirmed it in the wild +// (adding 8 browsers in a loop produced 6). generateTabId, three lines above in the same file, +// already carried the randomness this did not. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import reducer, { addBrowserCard } from './dashboardLayoutSlice.ts'; + +function addN(n: number): string[] { + let s = reducer(undefined, { type: '@@init' }) as any; + for (let i = 0; i < n; i++) { + s = reducer(s, addBrowserCard({ url: 'about:blank', expandedSessionIds: [], x: i * 100, y: 0 })); + } + return Object.keys(s.browserCards); +} + +test('every browser opened in a burst survives', () => { + // Synchronous dispatches land in the same millisecond, which is exactly the collision window. + const ids = addN(8); + assert.equal(ids.length, 8, `expected 8 browser cards, kept ${ids.length}`); +}); + +test('their ids are distinct', () => { + const ids = addN(20); + assert.equal(new Set(ids).size, 20, 'duplicate browser card ids'); +}); + +test('each keeps the position it was given, so none is a silent overwrite of another', () => { + let s = reducer(undefined, { type: '@@init' }) as any; + s = reducer(s, addBrowserCard({ url: 'about:blank', expandedSessionIds: [], x: 111, y: 0 })); + s = reducer(s, addBrowserCard({ url: 'about:blank', expandedSessionIds: [], x: 222, y: 0 })); + const xs = Object.values(s.browserCards).map((b: any) => b.x).sort((a, b) => a - b); + assert.deepEqual(xs, [111, 222]); +}); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 11e522a6..7b38a35c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -783,6 +783,10 @@ const dashboardLayoutSlice = createSlice({ delete state.cards[action.payload]; delete state.tiledCards[action.payload]; delete state.minimizedCards[action.payload]; + // zOrders had three writers and no deleter, so every card ever focused stayed in it forever, + // and it is PERSISTED: the entry rode every layout save to disk and back on every fetch. Small + // per row, unbounded over a long-lived board. + delete state.zOrders[action.payload]; ledgerRemove(state.creationOrder, action.payload); }, @@ -800,6 +804,7 @@ const dashboardLayoutSlice = createSlice({ // A dead card must never keep owning a tile: an orphaned 'fullscreen' entry hides ALL chrome until reload. delete state.tiledCards[id]; delete state.minimizedCards[id]; + delete state.zOrders[id]; ledgerRemove(state.creationOrder, id); } } @@ -983,6 +988,7 @@ const dashboardLayoutSlice = createSlice({ delete state.viewCards[action.payload]; delete state.tiledCards[action.payload]; delete state.minimizedCards[action.payload]; + delete state.zOrders[action.payload]; ledgerRemove(state.creationOrder, action.payload); if (state.activeViewCardId === action.payload) state.activeViewCardId = null; }, @@ -992,7 +998,10 @@ const dashboardLayoutSlice = createSlice({ }, addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[]; x?: number; y?: number }>) { - const id = `browser-${Date.now().toString(36)}`; + // Two browsers created in the same millisecond used to get the SAME id and the second + // silently overwrote the first: measured, adding 8 in a loop produced 6. generateTabId three + // lines up already learned this; the card id never did. + const id = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; const tabId = generateTabId(); // Caller may pre-resolve the spawn position (beside the selected card, or in front of the viewport); otherwise fall back to the top-left grid scan. const pos = action.payload.x != null && action.payload.y != null @@ -1088,6 +1097,7 @@ const dashboardLayoutSlice = createSlice({ delete state.endingBrowserCards[action.payload]; delete state.tiledCards[action.payload]; delete state.minimizedCards[action.payload]; + delete state.zOrders[action.payload]; ledgerRemove(state.creationOrder, action.payload); }, diff --git a/frontend/src/shared/state/zOrdersPruned.test.ts b/frontend/src/shared/state/zOrdersPruned.test.ts new file mode 100644 index 00000000..d8da98ad --- /dev/null +++ b/frontend/src/shared/state/zOrdersPruned.test.ts @@ -0,0 +1,68 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// zOrders had three writers and no deleter, so every card ever brought to front stayed in it for the +// life of the board, and it is PERSISTED: each dead entry rode every layout save to disk and back on +// every fetch. Tiny per row, unbounded over a long session, which is exactly the compounding class. +// +// Note on the fixtures: bringToFront deliberately no-ops when a card is ALREADY on top (it would +// otherwise churn the layout on every click), so a one-card board never writes a zOrders entry at +// all. Every case here places a second card first, which is what makes the focus real. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import reducer, { removeCard, removeViewCard, removeBrowserCard, bringToFront, reconcileSessions } from './dashboardLayoutSlice.ts'; + +const place = (s: any, id: string, x = 0) => reducer(s, { type: 'dashboardLayout/placeCard', + payload: { sessionId: id, x, y: 0, width: 480, height: 280, expandedSessionIds: [] } }); + +/** Two chats, then focus the BOTTOM one so bringToFront actually writes. */ +function twoCardsFocusFirst(): any { + let s = reducer(undefined, { type: '@@init' }) as any; + s = place(s, 's1', 0); + s = place(s, 's2', 600); + s = reducer(s, bringToFront({ id: 's1', type: 'agent' })) as any; + return s; +} + +test('focusing a card that is not on top writes a zOrder entry', () => { + assert.ok(twoCardsFocusFirst().zOrders.s1 !== undefined, 'fixture never armed the leak'); +}); + +test('removeCard prunes it', () => { + const s = reducer(twoCardsFocusFirst(), removeCard('s1')) as any; + assert.equal(s.zOrders.s1, undefined, 'a closed chat left its zOrder behind'); +}); + +test('reconcileSessions prunes it for cards the server no longer has', () => { + const s = reducer(twoCardsFocusFirst(), reconcileSessions({ sessionIds: [], expandedSessionIds: [] })) as any; + assert.equal(s.zOrders.s1, undefined, 'a reconciled-away card left its zOrder behind'); +}); + +test('closing an app prunes its zOrder', () => { + let s = reducer(undefined, { type: '@@init' }) as any; + s = reducer(s, { type: 'dashboardLayout/addViewCard', payload: { outputId: 'v1', expandedSessionIds: [], x: 0, y: 0 } }); + s = reducer(s, { type: 'dashboardLayout/addViewCard', payload: { outputId: 'v2', expandedSessionIds: [], x: 900, y: 0 } }); + s = reducer(s, bringToFront({ id: 'v1', type: 'view' })) as any; + assert.ok(s.zOrders.v1 !== undefined, 'fixture never armed the leak'); + s = reducer(s, removeViewCard('v1')) as any; + assert.equal(s.zOrders.v1, undefined, 'a closed app left its zOrder behind'); +}); + +test('closing a browser prunes its zOrder', () => { + let s = reducer(undefined, { type: '@@init' }) as any; + s = reducer(s, { type: 'dashboardLayout/addBrowserCard', payload: { url: 'about:blank', expandedSessionIds: [], x: 0, y: 0 } }); + const first = Object.keys(s.browserCards)[0]; + s = reducer(s, { type: 'dashboardLayout/addBrowserCard', payload: { url: 'about:blank', expandedSessionIds: [], x: 1400, y: 0 } }); + s = reducer(s, bringToFront({ id: first, type: 'browser' })) as any; + assert.ok(s.zOrders[first] !== undefined, 'fixture never armed the leak'); + s = reducer(s, removeBrowserCard(first)) as any; + assert.equal(s.zOrders[first], undefined, 'a closed browser left its zOrder behind'); +}); + +test('the prune is targeted: closing one card keeps another card z-order', () => { + let s = twoCardsFocusFirst(); + s = reducer(s, bringToFront({ id: 's2', type: 'agent' })) as any; + assert.ok(s.zOrders.s2 !== undefined); + s = reducer(s, removeCard('s1')) as any; + assert.equal(s.zOrders.s1, undefined); + assert.ok(s.zOrders.s2 !== undefined, 'pruning one card clobbered another card z-order'); +});