fix(tabs): no connector from a parent row the search hid (#580)

A floating subagent or ultracode window whose parent session's row the
rail search hid drew its connector from the viewport's top-left corner.
The hidden row is display:none, and getBoundingClientRect() still
answers it with an all-zero DOMRect, which is truthy, so the
`if (!tabRect) continue` paths never skipped it and _tabAnchor() put the
line at (0, 0) ("M 0 0 C 350 0, 350 400, 700 400" in Chromium). The
sidebar filter already had the same defect; #580 brought it to the rail,
and the keystroke redraw from 5f373a93 now ran it on every keystroke that
hides the parent.

A new _paintedSessionTab() in app.js returns the parent's row only while
getClientRects() is non-empty, and every place a floating window
measures its parent tab goes through it:

- the shared `tab:<id>` rect cache, filled by the subagent connectors
  and by both ultracode connector layers (all three fill it, so guarding
  only the first would let the next one cache the zero rect itself);
- the ultracode window's spawn position, which now cascades;
- the subagent window's fly-from-tab spawn, the same defect, not in the
  report, now positioned as a window without a tab;
- the ultracode genie on minimize, which now tears down at once.

Lineage lines need nothing: they measure on a cache miss and computeTree
already drops zero-size rects.

Tests: in the gate, with the page laid out by hand (jsdom has no
layout), a subagent window, an ultracode run window and an ultracode
agent window from a hidden row draw no line and draw it again from the
row once the search is cleared, an ultracode window spawns from a
painted row and cascades from a hidden one, and the genie is skipped for
a hidden row. In the browser suite, real Chromium shows the hidden row's
rect is all zero and the connector is gone, then back after Clear. All
four fail on the previous commit. CLAUDE.md and the invariants'
Connectors bullet record the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 04:37:49 +02:00
parent ac8e0bdcbb
commit 6844cccd18
7 changed files with 238 additions and 14 deletions
+1 -1
View File
@@ -337,7 +337,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing. While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched, and neither rail drag starts (owner decision: a drop is saved for every device, relative to rows the search hides; refused in `dragstart` and `_onTabLayoutPointerDown`, never via `draggable`, since a keystroke does not re-render); `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched, and neither rail drag starts (owner decision: a drop is saved for every device, relative to rows the search hides; refused in `dragstart` and `_onTabLayoutPointerDown`, never via `draggable`, since a keystroke does not re-render); `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
+1 -1
View File
@@ -800,7 +800,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
- **A row with a tab alert is never hidden** (owner decision on #580, the collapsed-header promise again: a prompt waiting on you is never hidden by a view filter). The row goes into the core as `keep: true` when `tabAlerts` holds anything for it (red `action` or yellow `idle`); a kept row counts toward its section, so its group or case box stays on screen, but not toward `matchCount`, so "No sessions match" can show above a lone alerted row. Alerts come and go through `renderSessionTabs()`, whose tail re-runs the filter, so nothing else has to.
- **Counts and the tree.** A section's count shows the rows left showing during a search and is restored from the `data-total` it stashed on first touch. In the grouped tree `_tabTreeItems()` skips a hidden group whole (header included), the filter tail re-runs `_applyTabTreePositions()` and moves the one roving stop off a hidden item, but only while a search hides something or right after one changed what shows (both render paths already set them over an unfiltered tree). Only the grouped rail is a tree; the flat rail stays a tablist.
- **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes.
- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful.
- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects.
- **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler.
- **No drag while searching** (owner decision): a drop is saved for every device, relative to rows the search hides. Both rail drags refuse in their start handlers (`_onTabLayoutPointerDown`, the flat rail's `dragstart`), never by flipping `draggable`, which only a render would restore.
- **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`.
+14
View File
@@ -6282,6 +6282,20 @@ class CodemanApp {
};
}
/**
* The session's tab row when it is painted, else null: not rendered (a
* collapsed group) or hidden by the rail search or the sidebar filter. A
* display:none row still answers getBoundingClientRect() with an all-zero
* rect, which is truthy, so a connector, a spawn or a genie measured from it
* would start at the viewport's top-left corner. Every floating window that
* anchors to its parent tab measures through this.
*/
_paintedSessionTab(sessionId) {
if (!sessionId) return null;
const tab = document.querySelector(`.session-tab[data-id="${sessionId}"]`);
return tab && tab.getClientRects().length > 0 ? tab : null;
}
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
+7 -5
View File
@@ -283,12 +283,14 @@ Object.assign(CodemanApp.prototype, {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed)
// Read tab rects for normal mode (only tabs that are actually needed).
// Only a painted row is cached: a row a search or filter hid has no line
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
@@ -397,7 +399,7 @@ Object.assign(CodemanApp.prototype, {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not in DOM (might be scrolled out or session closed)
// Tab not painted (session closed, collapsed group, or hidden by a search)
continue;
}
@@ -691,8 +693,8 @@ Object.assign(CodemanApp.prototype, {
}
}
// Get parent TAB element for spawn animation
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Get parent TAB element for spawn animation (a hidden row spawns normally)
const parentTab = this._paintedSessionTab(parentSessionId);
// Create window element
const win = document.createElement('div');
+9 -6
View File
@@ -194,8 +194,8 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Position: spawn from the parent tab if it is painted, else cascade.
const parentTab = this._paintedSessionTab(parentSessionId);
if (parentTab) {
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
@@ -312,9 +312,12 @@ Object.assign(CodemanApp.prototype, {
});
},
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
/**
* Genie the window toward the center of its tab, then invoke `done` to tear it
* down. A tab that is not painted (hidden by a search) tears down at once.
*/
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
const tab = this._paintedSessionTab(sessionId);
if (!tab || !element) {
done();
return;
@@ -781,7 +784,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
@@ -830,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
+53 -1
View File
@@ -7,7 +7,10 @@
* oninput/onkeydown/onclick handlers in index.html reach the app, that a match
* inside a collapsed group can be clicked, and that Escape in the box clears
* the search WITHOUT the global key handler (installed for real, capture
* phase, so it runs before the box's own onkeydown) also closing every panel.
* phase, so it runs before the box's own onkeydown) also closing every panel,
* and that a subagent connector whose parent row the search hid is not drawn
* from the viewport's corner (a display:none row's rect is all zero, but
* truthy, and only real layout says so).
* The real #tabRail markup is lifted from index.html, and the shipping
* constants.js, tab-layout-browser.js, app.js, webview-tabs.js and styles.css
* are loaded into a page.
@@ -82,6 +85,7 @@ describe('vertical rail session search in Chromium', () => {
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.addScriptTag({ content: read('subagent-windows.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
@@ -250,6 +254,54 @@ describe('vertical rail session search in Chromium', () => {
expect(await page.evaluate(() => (window as any).__redraws)).toBe(1);
});
it('draws no subagent connector from a parent row the search hid, and draws it again when cleared', async () => {
const connector = () =>
page.evaluate(() => {
(window as any).__app._updateConnectionLinesImmediate();
const path = document.querySelector('#connectionLines path[data-parent-tab="alpha"]');
return path ? path.getAttribute('d')!.split(' C ')[0] : null;
});
const rowAnchor = () =>
page.evaluate(() => {
const r = document.querySelector('#sessionTabs [data-id="alpha"]')!.getBoundingClientRect();
return `M ${r.right} ${r.top + r.height / 2}`;
});
await page.evaluate(() => {
const app = (window as any).__app;
document.body.insertAdjacentHTML(
'beforeend',
'<svg id="connectionLines"></svg>' +
'<div id="subWin" style="position:fixed;left:700px;top:300px;width:320px;height:200px"></div>'
);
app.subagentWindows = new Map([
['ag1', { element: document.getElementById('subWin'), minimized: false, hidden: false }],
]);
app.subagentParentMap = new Map([['ag1', 'alpha']]);
app.planSubagents = new Map();
});
try {
const anchor = await rowAnchor();
expect(anchor).not.toBe('M 0 0');
expect(await connector()).toBe(anchor);
await page.getByRole('searchbox', { name: 'Search sessions' }).fill('notes');
expect(await paintedRows()).toEqual(['notes']);
// The trap: Chromium still answers the hidden row with a rect, all zero.
expect(await rowAnchor()).toBe('M 0 0');
expect(await connector()).toBeNull();
await page.getByRole('button', { name: 'Clear search' }).click();
expect(await connector()).toBe(await rowAnchor());
} finally {
await page.evaluate(() => {
const app = (window as any).__app;
app.subagentWindows = new Map();
document.getElementById('connectionLines')?.remove();
document.getElementById('subWin')?.remove();
});
}
});
it('hides a case box the sidebar filter emptied, in the sidebar layout only', async () => {
const painted = await page.evaluate(() => {
const root = document.documentElement;
+153
View File
@@ -25,6 +25,10 @@
* - A change in what shows redraws the connector lines, which are anchored to
* row positions; an unchanged re-apply does not.
* - The sidebar hides a case box its filter emptied, like the rail.
* - A floating window whose parent row the search hid draws no connector,
* spawns where it would without a tab and tears down without a genie: a
* hidden row measures as an all-zero rect, which is truthy, so it used to
* anchor them at the viewport's top-left corner.
* - The input is labelled, sits at the top of #tabRail, and every new string
* reads in zh-CN.
*
@@ -185,6 +189,10 @@ beforeAll(async () => {
read('app.js') +
'\n' +
read('webview-tabs.js') +
'\n' +
read('subagent-windows.js') +
'\n' +
read('ultracode-windows.js') +
'\n;window.__RailSearchCodemanApp = CodemanApp;'
);
CodemanApp = window.__RailSearchCodemanApp;
@@ -796,6 +804,151 @@ describe('connector lines follow the rows a search moves', () => {
});
});
describe('floating windows whose parent row the search hid', () => {
type Box = { left: number; top: number; width: number; height: number };
const rect = ({ left, top, width, height }: Box) => ({
left,
top,
width,
height,
right: left + width,
bottom: top + height,
x: left,
y: top,
});
/**
* jsdom has no layout: every rect is zero and getClientRects() is always
* empty. Lay the page out by hand the way a browser would: painted rows
* stack 40px apart down the rail, a window takes the box in its
* data-box, and anything a filter hid (`tab-filtered-out` on it or an
* ancestor) has no client rects and an all-zero, still truthy, rect.
*/
function layOut() {
const hidden = (el: Element) => !el.isConnected || !!el.closest('.tab-filtered-out');
const box = (el: Element) => {
if (hidden(el)) return rect({ left: 0, top: 0, width: 0, height: 0 });
if (el.classList.contains('session-tab')) {
const rows = [...document.querySelectorAll('#sessionTabs .session-tab')].filter((row) => !hidden(row));
return rect({ left: 0, top: 100 + rows.indexOf(el) * 40, width: 240, height: 32 });
}
const [left, top, width, height] = ((el as HTMLElement).dataset?.box ?? '0,0,0,0').split(',').map(Number);
return rect({ left, top, width, height });
};
vi.spyOn(window.Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
return box(this);
});
vi.spyOn(window.Element.prototype, 'getClientRects').mockImplementation(function (this: Element) {
return (hidden(this) ? [] : [box(this)]) as never;
});
}
/** A subagent window, an ultracode run window and an ultracode agent window, all from "API Review". */
function openWindows(app: Record<string, any>) {
document.body.insertAdjacentHTML(
'beforeend',
'<svg id="connectionLines"></svg>' +
'<div id="subWin" data-box="600,300,320,200"></div>' +
'<div id="runWin" data-box="600,560,320,160"></div>' +
'<div id="agentWin" data-box="960,300,280,200"></div>'
);
app.subagentWindows = new Map([
['ag1', { element: document.getElementById('subWin'), minimized: false, hidden: false }],
]);
app.subagentParentMap = new Map([['ag1', 'review']]);
app.planSubagents = new Map();
app.ultracodeWindows = new Map([
['run1', { element: document.getElementById('runWin'), parentSessionId: 'review' }],
]);
// An agent window whose run has no window of its own anchors to the run's tab.
app.ultracodeAgentWindows = new Map([['ua1', { element: document.getElementById('agentWin'), runId: 'run2' }]]);
app.workflowRuns = new Map([['run2', { runId: 'run2' }]]);
app._resolveUltracodeParentSession = () => 'review';
}
/** What the shared SVG pass drew: which window each line serves, and where it starts. */
const lines = () =>
[...document.querySelectorAll('#connectionLines path')].map((path) => ({
win: path.getAttribute('data-run-id') || path.getAttribute('data-agent-id'),
from: (path.getAttribute('d') || '').split(' C ')[0],
}));
it('draws no connector from a hidden parent row, and draws it again once the search is cleared', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
layOut();
openWindows(app);
// review is the third row: top 180, so its right edge's midpoint is (240, 196).
const fromReview = [
{ win: 'ag1', from: 'M 240 196' },
{ win: 'run1', from: 'M 240 196' },
{ win: 'ua1', from: 'M 240 196' },
];
app._updateConnectionLinesImmediate();
expect(lines()).toEqual(fromReview);
app.setTabRailSearch('notes');
// The trap: the hidden row still answers with a rect, all zero but truthy.
const row = document.querySelector('#sessionTabs [data-id="review"]')!;
expect(row.getBoundingClientRect()).toMatchObject({ left: 0, top: 0, width: 0 });
app._updateConnectionLinesImmediate();
expect(lines()).toEqual([]);
app.clearTabRailSearch();
app._updateConnectionLinesImmediate();
expect(lines()).toEqual(fromReview);
});
it('spawns an ultracode window from a painted parent row, and cascades instead when the row is hidden', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
layOut();
app.ultracodeWindows = new Map();
app.ultracodeWindowZIndex = 0;
app._resolveUltracodeParentSession = () => 'review';
app.makeWindowDraggable = () => ({});
app.renderUltracodeWindowContent = () => {};
app._fetchWorkflowRunDetail = () => {};
const spawn = (runId: string) => {
app.createUltracodeWindow({ runId, workflowName: runId });
const win = document.getElementById(`ultracode-window-${runId}`) as HTMLElement;
return { left: win.style.left, top: win.style.top };
};
app.setTabRailSearch('notes');
// The cascade's first slot, not the corner a zero rect would give.
expect(spawn('hidden-parent')).toEqual({ left: '24px', top: '96px' });
app.clearTabRailSearch();
// To the right of the row (rail layout): right edge 240 + 14, at the row's top.
expect(spawn('painted-parent')).toEqual({ left: '254px', top: '180px' });
});
it('tears an ultracode window down at once rather than flying it to a hidden row', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
layOut();
const win = document.createElement('div');
win.dataset.box = '600,300,320,200';
document.body.appendChild(win);
app.setTabRailSearch('notes');
const gone = vi.fn();
app._animateUltracodeWindowToTab(win, 'review', gone);
expect(gone).toHaveBeenCalledTimes(1);
expect(win.style.transition).toBe('');
app.clearTabRailSearch();
const flown = vi.fn();
app._animateUltracodeWindowToTab(win, 'review', flown);
expect(flown).not.toHaveBeenCalled();
expect(win.style.transition).toContain('transform');
win.dispatchEvent(new window.Event('transitionend'));
expect(flown).toHaveBeenCalledTimes(1);
});
});
// ─── Markup + zh-CN ───────────────────────────────────────────────────────────
describe('markup and zh-CN', () => {