Files
Codeman/test/split-pane-headers.test.ts
T
Codeman maintainer c6e13e4fcf feat(split): both split panes name their harness and model
"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.

- Pane B's header is built from nodes now (it was innerHTML with the name
  escaped) and follows renames and model changes on every tab render, like a
  tile header; its close button is a tile button (26px target, 19px glyph).
- Pane A is the main terminal, which has no header of its own: while the
  split is open it gets the same strip, minus the close, as the first child of
  .terminal-wrap, and it names the active session. The strip takes 28px from
  the main terminal, so the opening resize fits it with the strip already in
  place, and closing removes the strip before giving the height back. Both go
  through sendResize / syncTerminalGeometry (#464): the close no longer calls
  a bare fitAddon.fit(), and a close that skips the server resize (Pane A's
  session ended) still refits through syncTerminalGeometry.
- The partial-history banner, which overlays the top of .terminal-wrap,
  starts below Pane A's strip while it is there.

test/split-pane-headers.test.ts drives the real split code on the grid's vm
harness: both headers, text-only names and models, refresh on a tab render,
no writes for an unchanged session, and the opening/closing fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:32:00 +02:00

231 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Each session in the split view names its harness and model.
*
* - Pane B's header is the tile header's strip: the harness logo (PR #532's
* `run-mode-dot <cliId>` slot), the name and the model, and the close button
* at a tile button's size.
* - Pane A is the main terminal, which has no header of its own: while the
* split is open it gets the same strip (minus the close), as the FIRST child
* of `.terminal-wrap`, and loses it when the split closes. That strip takes
* height from the main terminal, so opening fits it through sendResize (its
* first step is syncTerminalGeometry, #464) with the strip already in place,
* and closing gives the height back the same way, never with a bare
* `fitAddon.fit()`.
* - Every tab render refreshes both headers (a rename, a model switch, the
* active session changing under Pane A); an unchanged session writes nothing.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
main,
makeGridApp,
resetGridHarness,
windowStub,
wrap,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
beforeEach(() => {
resetGridHarness();
windowStub.__codemanCliCatalog = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
];
});
/** s-a (dsh on its route) in the main pane, s-b (claude) in Pane B. */
function openSplit(): GridApp {
const app = makeGridApp(IDS);
Object.assign(app.sessions.get('s-a'), {
name: 'review-api',
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
Object.assign(app.sessions.get('s-b'), {
name: 'docs-pass',
displayModel: { model: 'Haiku 4.5', source: 'statusline' },
});
app.syncTerminalGeometry = vi.fn();
app.fitAddon = { fit: vi.fn() };
app.openSplitPane('s-b');
// closeSplitPane finds its container by selector.
const container = main.querySelector('.terminal-split-container');
if (container) bySelector.set('.terminal-split-container', container);
return app;
}
const headerB = () => main.querySelector('.terminal-pane-b')!.querySelector('.terminal-pane-b-header') as FakeEl;
const headerA = () => wrap.querySelector('.terminal-pane-a-header');
const part = (header: FakeEl, cls: string) => header.querySelector(`.${cls}`) as FakeEl;
const modelName = (header: FakeEl) => part(header, 'split-model').children[0];
/** A tab render, through the real wrapper chain (the original returns early mid-rename). */
function renderTabs(app: GridApp) {
app._inlineRenameActive = true;
app._renderSessionTabsImmediate();
}
describe('the split headers', () => {
it("Pane B's header: logo, name, model, and the close button at a tile button's size", () => {
openSplit();
const b = headerB();
expect(b.children.map((c) => c.className.split(' ')[0])).toEqual(['split-harness', 'split-title', 'tile-btn']);
expect(part(b, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(b, 'session-name').textContent).toBe('docs-pass');
expect(modelName(b).textContent).toBe('Haiku 4.5');
expect(part(b, 'split-harness').title).toBe('Claude Code · Haiku 4.5');
expect(part(b, 'split-harness').getAttribute('aria-label')).toBe('Claude Code · Haiku 4.5');
const close = part(b, 'terminal-pane-b-close');
expect(close.className).toBe('tile-btn tile-remove terminal-pane-b-close');
expect(close.getAttribute('aria-label')).toBe('Close split');
expect(close.textContent).toBe('×');
});
it("Pane A gets the same strip, first in the main terminal's wrap, without a close", () => {
openSplit();
const a = headerA()!;
expect(wrap.children[0]).toBe(a);
expect(a.className).toBe('terminal-pane-b-header terminal-pane-a-header');
expect(part(a, 'split-harness').className).toBe('split-harness run-mode-dot deepseek');
expect(part(a, 'session-name').textContent).toBe('review-api');
expect(modelName(a).textContent).toBe('qwen3.8-27b');
expect(part(a, 'split-harness').title).toBe('DeepSeek · qwen3.8-27b');
expect(part(a, 'terminal-pane-b-close')).toBeNull();
});
it('names and models are text, and the model is never translated', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<img src=x onerror=alert(1)>';
app.sessions.get('s-b').displayModel = { model: '<b>m</b>', source: 'statusline' };
app.syncTerminalGeometry = vi.fn();
app.openSplitPane('s-b');
expect(part(headerB(), 'session-name').textContent).toBe('<img src=x onerror=alert(1)>');
expect(part(headerB(), 'session-name').children).toHaveLength(0);
expect(modelName(headerB()).textContent).toBe('<b>m</b>');
expect(modelName(headerB()).getAttribute('data-i18n-skip')).toBe('');
});
it('an unknown model shows the logo alone', () => {
const app = openSplit();
delete app.sessions.get('s-b').displayModel;
renderTabs(app);
expect(part(headerB(), 'split-model').hidden).toBe(true);
expect(modelName(headerB()).textContent).toBe('');
expect(part(headerB(), 'split-harness').title).toBe('Claude Code');
});
it('a tab render carries a rename and a model switch into both headers', () => {
const app = openSplit();
app.sessions.set('s-b', {
...app.sessions.get('s-b'),
name: 'docs-pass-2',
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
});
app.sessions.get('s-a').displayModel = { model: 'deepseek-v4-flash', source: 'screen' };
renderTabs(app);
expect(part(headerB(), 'session-name').textContent).toBe('docs-pass-2');
expect(modelName(headerB()).textContent).toBe('Sonnet 4.6');
expect(modelName(headerA()!).textContent).toBe('deepseek-v4-flash');
});
it("Pane A's header follows the active session", () => {
const app = openSplit();
app.activeSessionId = 's-c';
renderTabs(app);
expect(part(headerA()!, 'session-name').textContent).toBe('s-c');
expect(part(headerA()!, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(headerA()!, 'split-model').hidden).toBe(true);
});
it('an unchanged session writes nothing on a refresh', () => {
const app = openSplit();
const writes: string[] = [];
for (const [which, header] of [
['a', headerA()!],
['b', headerB()],
] as Array<[string, FakeEl]>) {
for (const [node, props] of [
[part(header, 'split-harness'), ['className', 'title']],
[part(header, 'session-name'), ['textContent']],
[part(header, 'split-model'), ['title', 'hidden']],
[modelName(header), ['textContent']],
] as Array<[FakeEl, string[]]>) {
for (const prop of props) {
let value = (node as unknown as Record<string, unknown>)[prop];
Object.defineProperty(node, prop, {
get: () => value,
set: (v) => {
writes.push(`${which} ${prop}`);
value = v;
},
});
}
const setAttribute = node.setAttribute.bind(node);
node.setAttribute = (k: string, v: string) => {
writes.push(`${which} @${k}`);
setAttribute(k, v);
};
}
}
renderTabs(app);
renderTabs(app);
expect(writes).toEqual([]);
});
});
describe("the main terminal's height", () => {
it('opening fits Pane A with its header strip already in place, through sendResize', () => {
const app = makeGridApp(IDS);
app.fitAddon = { fit: vi.fn() };
let stripInPlace = false;
app.sendResize = vi.fn(() => {
stripInPlace = wrap.children[0]?.classList.contains('terminal-pane-a-header') ?? false;
return Promise.resolve(true);
});
app.openSplitPane('s-b');
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripInPlace).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it('closing takes the strip away and gives the height back through sendResize, never a bare fit', () => {
const app = openSplit();
app.sendResize.mockClear();
let stripGone = false;
app.sendResize = vi.fn(() => {
stripGone = headerA() === null;
return Promise.resolve(true);
});
app.closeSplitPane();
expect(headerA()).toBeNull();
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripGone).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
expect(app._splitHeaders).toBeNull();
});
it('a close that skips the resize (Pane A ended) still refits through syncTerminalGeometry', () => {
const app = openSplit();
app.sendResize.mockClear();
app.closeSplitPane({ skipPrimaryResize: true });
expect(headerA()).toBeNull();
expect(app.sendResize).not.toHaveBeenCalled();
expect(app.syncTerminalGeometry).toHaveBeenCalledTimes(1);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it("Pane B's close button closes the split", () => {
const app = openSplit();
const tile = FakeTile.all.at(-1) as FakeTile;
part(headerB(), 'terminal-pane-b-close').dispatch('click');
expect(app._splitPane).toBeNull();
expect(tile.destroy).toHaveBeenCalledTimes(1);
expect(headerA()).toBeNull();
});
});