mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
ac8e0bdcbb
commit
6844cccd18
7 files changed
+238
-14
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user