diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a0fe1843..9b5560cb 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19376,31 +19376,33 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle overflow: hidden; } +/* A split pane's header strip, Pane B's always and Pane A's while the split is + open: harness logo, name, model, and Pane B's close (a tile button, see + .tile-btn). The tile header's size and layout, FIXED height like it (#464): + the terminal below is fitted to what is left. */ .terminal-pane-b-header { display: flex; align-items: center; - justify-content: space-between; - padding: 4px 8px; + gap: 6px; + flex: 0 0 28px; + height: 28px; + box-sizing: border-box; + padding: 0 2px 0 8px; + overflow: hidden; font-size: 12px; background: var(--bg-secondary, #1a1a1a); border-bottom: 1px solid var(--border-color, #333); - flex: 0 0 auto; + user-select: none; } -.terminal-pane-b-close { - /* A native - -
- `; + // Pane B's header: the harness logo, the name and the model, as on a grid + // tile, and the close button at a tile button's size. + const headerB = this._buildSplitPaneHeader(); + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'tile-btn tile-remove terminal-pane-b-close'; + close.title = 'Close split'; + close.setAttribute('aria-label', 'Close split'); + close.textContent = '\u00D7'; + close.addEventListener('click', () => this.closeSplitPane()); + headerB.el.appendChild(close); + const bodyB = document.createElement('div'); + bodyB.className = 'terminal-pane-b-container'; + paneB.append(headerB.el, bodyB); + // Pane A is the main terminal, which has no header of its own: while the + // split is open it gets the same strip, so each session in the split view + // names its harness and model. It takes height from the main terminal, + // which the opening resize below fits through syncTerminalGeometry (#464); + // closeSplitPane gives it back. + const headerA = this._buildSplitPaneHeader(); + headerA.el.classList.add('terminal-pane-a-header'); + this._splitHeaders = { a: headerA, b: headerB }; parent.insertBefore(container, wrap); + wrap.insertBefore(headerA.el, wrap.firstChild); container.appendChild(wrap); wrap.style.flexBasis = '50%'; container.appendChild(divider); container.appendChild(paneB); paneB.style.flexBasis = '50%'; - this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), { + this._splitPane = new window.TerminalTile(sessionId, bodyB, { mode: session?.mode, fontSettings: this.loadAppSettingsFromStorage?.() || {}, detachedSessions: this.detachedSessions, @@ -209,6 +225,7 @@ Object.assign(CodemanApp.prototype, { initial load — live output still arrives once/if the socket connects. */ }); this._splitSessionId = sessionId; + this._renderSplitChrome(); // Pane A just went from full width to 50%, but nothing has told its // session's PTY/tmux window about it yet — the passive ResizeObserver in @@ -236,6 +253,9 @@ Object.assign(CodemanApp.prototype, { this._splitPane.destroy(); this._splitPane = null; this._splitSessionId = null; + // Before the refit below, so the main terminal gets its full height back. + this._splitHeaders?.a.el.remove(); + this._splitHeaders = null; this._updateSplitButtonState(false); const container = document.querySelector('.terminal-split-container'); @@ -246,14 +266,16 @@ Object.assign(CodemanApp.prototype, { parent.insertBefore(wrap, container); container.remove(); - if (this.fitAddon) this.fitAddon.fit(); + // Pane A takes the whole width back and, with its header strip gone, its + // whole height: through syncTerminalGeometry (#464), never a bare + // fitAddon.fit(). sendResize fits that way as its first step. // The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split // while activeSessionId is still the id the server just removed, so a // resize from here would be aimed at a session that no longer exists; - // the promoted session gets its own resize from selectSession(). - if (!options.skipPrimaryResize) { - this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); - } + // the promoted session gets its own resize from selectSession(), and the + // terminal is only refitted here. + if (options.skipPrimaryResize) this.syncTerminalGeometry?.(); + else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); }, // A click on .btn-split does one of two things — open the picker, or @@ -261,6 +283,59 @@ Object.assign(CodemanApp.prototype, { // nothing on the button said which. `.split-open` + aria-pressed give it // the same active-state language as the codebase's other toggle buttons // (keyboard-accessory's Ctrl key, the voice-input mic). + /** + * A split pane's header strip: the harness logo, the session name and the + * model, the three a grid tile's header shows. Built from nodes (the name + * and the model are untrusted text) and painted by _renderSplitChrome. + */ + _buildSplitPaneHeader() { + const el = document.createElement('div'); + el.className = 'terminal-pane-b-header'; + const harness = document.createElement('span'); + harness.className = 'split-harness run-mode-dot'; + harness.setAttribute('role', 'img'); + const title = document.createElement('span'); + title.className = 'split-title'; + const name = document.createElement('span'); + // `.session-name` is one of the translator's skipped surfaces (user text). + name.className = 'session-name'; + // As on a tile: the name inside is never translated, the tooltip may be, + // and screen readers hear the model once, in the logo's accessible name. + const model = document.createElement('span'); + model.className = 'split-model'; + model.setAttribute('aria-hidden', 'true'); + model.hidden = true; + const modelName = document.createElement('span'); + modelName.setAttribute('data-i18n-skip', ''); + model.appendChild(modelName); + title.append(name, model); + el.append(harness, title); + return { el, harness, name, model, modelName }; + }, + + /** + * Both split headers from their sessions: Pane A shows the active session, + * Pane B its own. Runs after every tab render, so a rename or a model + * change reaches them; unchanged values write nothing. + */ + _renderSplitChrome() { + const headers = this._splitHeaders; + if (!headers || !this._splitPane) return; + for (const [parts, id] of [ + [headers.a, this.activeSessionId], + [headers.b, this._splitSessionId], + ]) { + const session = id ? this.sessions.get(id) : null; + if (!session) continue; + const name = this.getSessionName?.(session) || session.name || 'Session'; + if (parts.nameValue !== name) { + parts.nameValue = name; + parts.name.textContent = name; + } + this._paintSessionHarness(parts, session, 'split-harness'); + } + }, + /** * Paints a session header's harness logo and model: a grid tile's, and the * split panes'. The logo is PR #532's `run-mode-dot ` slot (the id is @@ -452,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) { // this, Pane A rebinds to a session that Pane B's independent WebSocket is // still attached to — two live WebSockets to one session, each claiming PTY // dimensions via its own `{t:'z',...}` resize frame. +// Every tab render (any session change: a rename, a model switch) refreshes +// the split headers too, the way tile-grid.js refreshes the tile headers. +const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate; +CodemanApp.prototype._renderSessionTabsImmediate = function (...args) { + const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args); + this._renderSplitChrome?.(); + return result; +}; + const _originalSelectSession = CodemanApp.prototype.selectSession; CodemanApp.prototype.selectSession = function (sessionId, ...args) { if (this._splitPane && this._splitSessionId === sessionId) { diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d82fd062..7b818648 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -80,6 +80,9 @@ export class FakeEl { else this.children.splice(i, 0, child); return child; } + get firstChild() { + return this.children[0] ?? null; + } get nextSibling() { const siblings = this.parentElement?.children ?? []; return siblings[siblings.indexOf(this) + 1] ?? null; @@ -152,7 +155,8 @@ export class FakeTile { export const main = new FakeEl(); main.className = 'main'; -const wrap = new FakeEl(); +/** The main terminal's `.terminal-wrap` (Pane A while a split is open). */ +export const wrap = new FakeEl(); wrap.className = 'terminal-wrap'; main.appendChild(wrap); export const section = new FakeEl(); @@ -317,8 +321,10 @@ export function resetGridHarness() { delete windowStub.__codemanCliCatalog; section.children = []; main.className = 'main'; - // A split a test left open moved .terminal-wrap into its container. + // A split a test left open moved .terminal-wrap into its container, with + // Pane A's header strip in it. main.children = []; + wrap.children = []; main.appendChild(wrap); main.appendChild(section); bySelector.clear(); diff --git a/test/split-pane-headers.test.ts b/test/split-pane-headers.test.ts new file mode 100644 index 00000000..236d23d7 --- /dev/null +++ b/test/split-pane-headers.test.ts @@ -0,0 +1,230 @@ +/** + * @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 ` 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 = ''; + app.sessions.get('s-b').displayModel = { model: 'm', source: 'statusline' }; + app.syncTerminalGeometry = vi.fn(); + app.openSplitPane('s-b'); + expect(part(headerB(), 'session-name').textContent).toBe(''); + expect(part(headerB(), 'session-name').children).toHaveLength(0); + expect(modelName(headerB()).textContent).toBe('m'); + 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)[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(); + }); +});