feat(tiles): each tile header names its harness and model

The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request):

- The logo is PR #532's `run-mode-dot <cliId>` slot, so the logos, the skins
  and the plain dot of an id without a logo stay single-sourced in styles.css.
  The id is data (a class and a catalog lookup), never a branch; the frontend
  id-branching guard now also scans constants.js, terminal-split.js and
  tile-grid.js (the one existing shell branch in tile-grid.js, the attach
  route, is allowlisted with its reason).
- The model is the session's displayModel, as text in a data-i18n-skip span
  inside a box whose tooltip may translate. Unknown means the logo alone.
- The logo's tooltip and accessible name say "<harness> · <model>", plus where
  a model the CLI did not report came from ("set at launch", "custom
  endpoint"; zh-CN patterns for both, the names pass through). The model's box
  is aria-hidden so a screen reader hears the model once.
- One painter, _paintSessionHarness (terminal-split.js, shared with the split
  panes next), diffs against what it last wrote, never the DOM: an unchanged
  session writes nothing on a tab render.
- On a narrow header the model gives way first, then the name: the name does
  not shrink at all and is capped at its box, since any shrink factor takes a
  subpixel from a name that fits and ellipsizes it.

The chrome and zoom tests found header parts by child position; they now look
them up by class, with every assertion kept (the rename tests had been passing
against the new logo node by position). The i18n harvester files the logo's
labels as harness and model names that must stay as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 13:32:00 +02:00
parent 8392854619
commit 969f273fec
10 changed files with 401 additions and 28 deletions
+49
View File
@@ -2134,7 +2134,56 @@ function sessionIdFromFragment(hash) {
return id && id.trim() ? id.trim() : null; return id && id.trim() ? id.trim() : null;
} }
/** Longest model name a session header shows (the server caps it as well). */
const SESSION_MODEL_MAX_CHARS = 64;
/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
/**
* What a session's header says about its harness: the CLI id (the
* `run-mode-dot <id>` logo class), the registry's label for it, the model the
* session runs when the server knows it (`SessionState.displayModel`), and the
* tooltip naming both.
*
* The id is data: the label comes from the injected CLI catalog and falls back
* to the id, so a CLI added through clis.json still gets a name. The model is
* untrusted text (read off a pane, or a CLI's own report): control characters
* are dropped and the length capped here too, and callers render it with
* textContent. The tooltip says where a model that is not the CLI's own report
* came from, so it never claims more than the server knows: one the session
* was launched with may have been switched since, and a custom endpoint's
* model is the endpoint's, whatever the CLI calls it.
*
* @param {object} session - a session from app.sessions
* @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
* @returns {{id: string, label: string, model: string, title: string}}
*/
function describeSessionHarness(session, catalog) {
const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
const raw = session?.displayModel?.model;
const model =
typeof raw === 'string'
? raw
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
.trim()
.slice(0, SESSION_MODEL_MAX_CHARS)
: '';
const source = session?.displayModel?.source;
const qualifier = !model
? ''
: source === 'launch'
? ' (set at launch)'
: source === 'custom-endpoint'
? ' (custom endpoint)'
: '';
const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
return { id, label, model, title };
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine }; window.CodemanTerminalLines = { terminalLogicalLine };
+5
View File
@@ -1058,6 +1058,11 @@
], ],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`], [/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
// and model names pass through untranslated.
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry // The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP"). // CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`], [/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
+39 -2
View File
@@ -19477,7 +19477,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
background: transparent !important; background: transparent !important;
} }
/* Tile header: `● name ......... ⋯ ⤢ ×`. FIXED height (#464): the body below /* Tile header: `● [logo] name · model ..... ⋯ ⤢ ×`. FIXED height (#464): the body below
holds the xterm, so a header that grew (a wrapped name, the rename input) holds the xterm, so a header that grew (a wrapped name, the rename input)
would shrink the body with nothing refitting the xterm or its PTY. */ would shrink the body with nothing refitting the xterm or its PTY. */
.tile-header { .tile-header {
@@ -19496,14 +19496,51 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
user-select: none; user-select: none;
} }
.tile-name { /* The harness logo: PR #532's run-mode-dot slot (1.15em of this 13px, the
size of the header's text), with the logos, the skins and the plain dot of an
id without one all coming from the run-mode-dot rules. */
.tile-harness.run-mode-dot {
font-size: 13px;
}
/* Name and model. On a narrow header the model gives way first, then the name;
the logo and the buttons keep their size. The name does not shrink at all (a
shrink factor, however small, also takes a subpixel from a name that fits and
ellipsizes it) and is capped at this box's width instead. */
.tile-title {
display: flex;
align-items: baseline;
gap: 6px;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
}
.tile-name {
flex: 0 0 auto;
max-width: 100%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.tile-model {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-muted, #888);
font-size: 11px;
}
.tile-model::before {
content: '\00B7';
margin-right: 6px;
}
.tile-actions { .tile-actions {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
+33
View File
@@ -261,6 +261,39 @@ Object.assign(CodemanApp.prototype, {
// nothing on the button said which. `.split-open` + aria-pressed give it // nothing on the button said which. `.split-open` + aria-pressed give it
// the same active-state language as the codebase's other toggle buttons // the same active-state language as the codebase's other toggle buttons
// (keyboard-accessory's Ctrl key, the voice-input mic). // (keyboard-accessory's Ctrl key, the voice-input mic).
/**
* 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 <cliId>` slot (the id is
* data, never a branch), the model is text (describeSessionHarness,
* constants.js). Diffs against the values it last wrote, kept on `parts`,
* never against the DOM, which the translator may have rewritten: an
* unchanged session writes nothing, and this runs on every tab render.
*
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
* header's nodes (the model's box and the name inside it); the memo lives here too
* @param {object} session - the session the header shows
* @param {string} logoClass - the header's own class for its logo
*/
_paintSessionHarness(parts, session, logoClass) {
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
if (parts.harnessClass !== cls) {
parts.harnessClass = cls;
parts.harness.className = cls;
}
if (parts.harnessTitle !== harness.title) {
parts.harnessTitle = harness.title;
parts.harness.title = harness.title;
parts.harness.setAttribute('aria-label', harness.title);
parts.model.title = harness.title;
}
if (parts.modelValue !== harness.model) {
parts.modelValue = harness.model;
parts.modelName.textContent = harness.model;
parts.model.hidden = !harness.model;
}
},
_updateSplitButtonState(open) { _updateSplitButtonState(open) {
const btn = document.querySelector('.btn-split'); const btn = document.querySelector('.btn-split');
if (!btn) return; if (!btn) return;
+33 -7
View File
@@ -732,7 +732,10 @@ Object.assign(CodemanApp.prototype, {
overlay: null, overlay: null,
header: header.el, header: header.el,
dot: header.dot, dot: header.dot,
harness: header.harness,
name: header.name, name: header.name,
model: header.model,
modelName: header.modelName,
zoomBtn: header.zoomBtn, zoomBtn: header.zoomBtn,
renaming: false, renaming: false,
// The pid this tile last saw, so a pane that starts later is noticed. // The pid this tile last saw, so a pane that starts later is noticed.
@@ -1075,9 +1078,10 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* `● name ......... ⋯ ⤢ ×`: the status dot (the six-state classifier the tab * `● [logo] name · model ..... ⋯ ⤢ ×`: the status dot (the six-state
* rows and both home screens share), the session name (double-click * classifier the tab rows and both home screens share), the harness logo, the
* renames), the session menu (the tab rail's own), zoom and remove-tile. Its * session name (double-click renames), the model it runs when known, the
* session menu (the tab rail's own), zoom and remove-tile. Its
* buttons stop pointerdown, so acting on a tile that is not focused does not * buttons stop pointerdown, so acting on a tile that is not focused does not
* also focus it (and spend its idle alert). * also focus it (and spend its idle alert).
*/ */
@@ -1087,6 +1091,15 @@ Object.assign(CodemanApp.prototype, {
const dot = document.createElement('span'); const dot = document.createElement('span');
dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle'; dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle';
dot.setAttribute('aria-hidden', 'true'); dot.setAttribute('aria-hidden', 'true');
// The harness's logo: PR #532's `run-mode-dot <cliId>` slot, so the logos,
// the skins and the plain dot for an id without one stay in styles.css. Its
// tooltip and accessible name carry the harness and the model in full
// (_paintSessionHarness).
const harness = document.createElement('span');
harness.className = 'tile-harness run-mode-dot';
harness.setAttribute('role', 'img');
const title = document.createElement('span');
title.className = 'tile-title';
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'tile-name'; name.className = 'tile-name';
// A session literally named like a UI string ("Sessions") must not be translated. // A session literally named like a UI string ("Sessions") must not be translated.
@@ -1095,6 +1108,18 @@ Object.assign(CodemanApp.prototype, {
e.stopPropagation(); e.stopPropagation();
this.startTileRename(sessionId); this.startTileRename(sessionId);
}); });
// The model the session runs, when the server knows it: the name itself is
// never translated (data-i18n-skip on the inner span), the tooltip on the
// outer one may be. Hidden from screen readers: the logo's accessible name
// already says it.
const model = document.createElement('span');
model.className = 'tile-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);
const actions = document.createElement('span'); const actions = document.createElement('span');
actions.className = 'tile-actions'; actions.className = 'tile-actions';
const button = (cls, label, glyph, onClick) => { const button = (cls, label, glyph, onClick) => {
@@ -1122,13 +1147,13 @@ Object.assign(CodemanApp.prototype, {
// behind the menu's Close session and its confirm. // behind the menu's Close session and its confirm.
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId))
); );
el.append(dot, name, actions); el.append(dot, harness, title, actions);
return { el, dot, name, zoomBtn }; return { el, dot, harness, name, model, modelName, zoomBtn };
}, },
/** /**
* Refreshes one tile's header from the session: dot state, name, the hover * Refreshes one tile's header from the session: dot state, harness logo,
* label ("working 3m") and the `needs` border. Diffs on existing nodes only, * name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only,
* and cheap: it runs on every tab render (every status change). * and cheap: it runs on every tab render (every status change).
*/ */
_renderTileHeader(sessionId) { _renderTileHeader(sessionId) {
@@ -1152,6 +1177,7 @@ Object.assign(CodemanApp.prototype, {
const name = const name =
this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session'; this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session';
if (entry.name.textContent !== name) entry.name.textContent = name; if (entry.name.textContent !== name) entry.name.textContent = name;
this._paintSessionHarness(entry, session, 'tile-harness');
// A permission prompt is visible across the room. // A permission prompt is visible across the room.
entry.el.classList.toggle('tile--needs', state === 'needs'); entry.el.classList.toggle('tile--needs', state === 'needs');
this._renderTileOverlay(sessionId); this._renderTileOverlay(sessionId);
+13 -3
View File
@@ -1,6 +1,9 @@
/** /**
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched * @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
* (`session-ui.js`, `mobile-overview.js`), mirroring * (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session
* header's harness logo and model (`constants.js`, `terminal-split.js`,
* `tile-grid.js`: the logo's `run-mode-dot <cliId>` class is the id as DATA),
* mirroring
* `test/cli-registry-no-id-branching.test.ts` for the backend registry. * `test/cli-registry-no-id-branching.test.ts` for the backend registry.
* *
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`. * Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
@@ -21,7 +24,7 @@ import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url)); const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js']; const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js'];
/** /**
* Every currently-surviving branch, each with the COUNT of physical call * Every currently-surviving branch, each with the COUNT of physical call
@@ -90,6 +93,13 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI', reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
}, },
// tile-grid.js: a tile's Attach re-attaches through the same route pair the
// primary pane uses, and that pair is split by mode on the server side.
"tile-grid.js::mode === 'shell'": {
count: 1,
reason: 'attach route: a shell session attaches through /shell, an agent through /interactive',
},
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the // mobile-overview.js: shell is exempt from the isCliAvailable() gate the
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI). // same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
"mobile-overview.js::mode !== 'shell'": { "mobile-overview.js::mode !== 'shell'": {
@@ -177,7 +187,7 @@ function actualCounts(): Map<string, number> {
return counts; return counts;
} }
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => { describe('no NEW CLI-id branching in the scanned frontend files', () => {
it('scans both files (sanity)', () => { it('scans both files (sanity)', () => {
// If this drops to zero the scanner or the file list drifted and every // If this drops to zero the scanner or the file list drifted and every
// assertion below would pass vacuously. // assertion below would pass vacuously.
+2
View File
@@ -313,6 +313,8 @@ export function resetGridHarness() {
rafCallbacks.length = 0; rafCallbacks.length = 0;
localStore.clear(); localStore.clear();
windowStub.innerWidth = 2400; windowStub.innerWidth = 2400;
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
delete windowStub.__codemanCliCatalog;
section.children = []; section.children = [];
main.className = 'main'; 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.
+173 -14
View File
@@ -1,6 +1,11 @@
/** /**
* @fileoverview A tile's header: `● name ......... ⋯ ⤢ ×`, and the tab marker. * @fileoverview A tile's header: `● [logo] name · model ..... ⋯ ⤢ ×`, and the tab marker.
* *
* - The logo is PR #532's `run-mode-dot <cliId>` slot (the id is data); the
* logo's tooltip and accessible name carry the harness and the model, with
* where the model came from when the CLI did not report it. The model is
* text (never markup, never translated), and an unknown model shows nothing.
* An unchanged session writes nothing on a refresh.
* - The dot uses the six-state classifier the tab rows and both home screens * - The dot uses the six-state classifier the tab rows and both home screens
* share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*` * share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*`
* classes; a `needs` tile gets the pulsing red border; hovering shows the * classes; a `needs` tile gets the pulsing red border; hovering shows the
@@ -18,7 +23,15 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeEl, FakeTile, makeGridApp, resetGridHarness, type GridApp, tileEl } from './mocks/tile-grid-vm.js'; import {
FakeEl,
FakeTile,
makeGridApp,
resetGridHarness,
type GridApp,
tileEl,
windowStub,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c']; const IDS = ['s-a', 's-b', 's-c'];
@@ -39,8 +52,12 @@ function openGrid(): GridApp {
} }
const headerOf = (id: string) => tileEl(id).children[0]; const headerOf = (id: string) => tileEl(id).children[0];
/** A header part by its class, wherever the header nests it. */
const partOf = (id: string, cls: string) => headerOf(id).querySelector(`.${cls}`) as FakeEl;
const buttonOf = (id: string, cls: string) => const buttonOf = (id: string, cls: string) =>
headerOf(id).children[2].children.find((b) => b.className.includes(cls)) as FakeEl; partOf(id, 'tile-actions').children.find((b) => b.className.includes(cls)) as FakeEl;
/** Where the name sits: the name itself, or the rename input in its place. */
const nameSlotOf = (id: string) => partOf(id, 'tile-title').children[0];
beforeEach(() => { beforeEach(() => {
resetGridHarness(); resetGridHarness();
@@ -51,7 +68,7 @@ describe('the header', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<b>Sessions</b>'; app.sessions.get('s-b').name = '<b>Sessions</b>';
app.openTileGrid(IDS); app.openTileGrid(IDS);
const name = headerOf('s-b').children[1]; const name = partOf('s-b', 'tile-name');
expect(name.textContent).toBe('<b>Sessions</b>'); expect(name.textContent).toBe('<b>Sessions</b>');
expect(name.getAttribute('data-i18n-skip')).toBe(''); expect(name.getAttribute('data-i18n-skip')).toBe('');
expect(name.children).toHaveLength(0); expect(name.children).toHaveLength(0);
@@ -70,9 +87,9 @@ describe('the header', () => {
app.pendingHooks.set('s-c', new Set(['permission_prompt'])); app.pendingHooks.set('s-c', new Set(['permission_prompt']));
app._renderTileChrome(); app._renderTileChrome();
expect(headerOf('s-a').children[0].className).toContain('home-sessions-dot--idle'); expect(partOf('s-a', 'tile-dot').className).toContain('home-sessions-dot--idle');
expect(headerOf('s-b').children[0].className).toContain('home-sessions-dot--working'); expect(partOf('s-b', 'tile-dot').className).toContain('home-sessions-dot--working');
expect(headerOf('s-c').children[0].className).toContain('home-sessions-dot--needs'); expect(partOf('s-c', 'tile-dot').className).toContain('home-sessions-dot--needs');
expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true); expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true);
expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false); expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false);
}); });
@@ -95,6 +112,148 @@ describe('the header', () => {
}); });
}); });
describe('the harness logo and the model', () => {
const CATALOG = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
{ id: 'shell', label: 'Shell' },
];
/** s-a on dsh's route, s-b claude on its statusline's model, s-c a shell. */
function harnessGrid(): GridApp {
windowStub.__codemanCliCatalog = CATALOG;
const app = makeGridApp(IDS);
app.sessions.get('s-a').mode = 'deepseek';
app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'screen' };
app.sessions.get('s-b').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
app.sessions.get('s-c').mode = 'shell';
app.openTileGrid(IDS);
return app;
}
const logoOf = (id: string) => partOf(id, 'tile-harness');
const modelOf = (id: string) => partOf(id, 'tile-model');
const modelNameOf = (id: string) => modelOf(id).children[0];
it('sits between the dot and the name: `● [logo] name · model ... ⋯ ⤢ ×`', () => {
harnessGrid();
expect(headerOf('s-a').children.map((c) => c.className.split(' ')[0])).toEqual([
'tile-dot',
'tile-harness',
'tile-title',
'tile-actions',
]);
expect(partOf('s-a', 'tile-title').children.map((c) => c.className)).toEqual(['tile-name', 'tile-model']);
});
it("draws the session's CLI as PR #532's logo slot, the id as data", () => {
harnessGrid();
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot deepseek');
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot claude');
expect(logoOf('s-c').className).toBe('tile-harness run-mode-dot shell');
expect(logoOf('s-a').getAttribute('role')).toBe('img');
});
it('names the harness and the model in the tooltip and the accessible name', () => {
harnessGrid();
expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(logoOf('s-a').getAttribute('aria-label')).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(modelOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b');
expect(modelOf('s-a').hidden).toBe(false);
// Said once to a screen reader: the model's box is hidden from it.
expect(modelOf('s-a').getAttribute('aria-hidden')).toBe('true');
// The model name is never translated; its tooltip may be.
expect(modelNameOf('s-a').getAttribute('data-i18n-skip')).toBe('');
expect(modelOf('s-a').getAttribute('data-i18n-skip')).toBeNull();
});
it('an unknown model shows the logo alone: no text, no placeholder', () => {
harnessGrid();
expect(modelOf('s-c').hidden).toBe(true);
expect(modelNameOf('s-c').textContent).toBe('');
expect(logoOf('s-c').title).toBe('Shell');
});
it('says in the tooltip where a model came from when the CLI did not report it', () => {
const app = harnessGrid();
app.sessions.get('s-b').displayModel = { model: 'haiku', source: 'launch' };
app.sessions.get('s-c').mode = 'claude';
app.sessions.get('s-c').displayModel = { model: 'qwen3.8-27b', source: 'custom-endpoint' };
app._renderTileChrome();
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 haiku (set at launch)');
expect(logoOf('s-c').title).toBe('Claude Code \u00B7 qwen3.8-27b (custom endpoint)');
expect(modelNameOf('s-b').textContent).toBe('haiku');
});
it('a model change (session:updated) updates the header', () => {
const app = harnessGrid();
app.sessions.set('s-b', {
...app.sessions.get('s-b'),
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
});
app._renderTileChrome();
expect(modelNameOf('s-b').textContent).toBe('Sonnet 4.6');
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 Sonnet 4.6');
// And the model going away takes the text with it.
delete app.sessions.get('s-b').displayModel;
app._renderTileChrome();
expect(modelOf('s-b').hidden).toBe(true);
expect(modelNameOf('s-b').textContent).toBe('');
});
it('a model with markup stays text', () => {
const app = harnessGrid();
app.sessions.get('s-b').displayModel = { model: '<img src=x onerror=alert(1)>', source: 'statusline' };
app._renderTileChrome();
expect(modelNameOf('s-b').textContent).toBe('<img src=x onerror=alert(1)>');
expect(modelNameOf('s-b').children).toHaveLength(0);
expect(modelOf('s-b').children).toHaveLength(1);
});
it('an unchanged session writes nothing on a refresh', () => {
const app = harnessGrid();
const writes: string[] = [];
for (const id of IDS) {
for (const [node, props] of [
[logoOf(id), ['className', 'title']],
[modelOf(id), ['title', 'hidden']],
[modelNameOf(id), ['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(`${id} ${prop}`);
value = v;
},
});
}
const setAttribute = node.setAttribute.bind(node);
node.setAttribute = (k: string, v: string) => {
writes.push(`${id} @${k}`);
setAttribute(k, v);
};
}
}
app._renderTileChrome();
app._renderTileChrome();
expect(writes).toEqual([]);
});
it('an id that is not a CLI id is not a class name; without a catalog the label is the id', () => {
const app = harnessGrid();
delete windowStub.__codemanCliCatalog;
app.sessions.get('s-a').mode = 'bad id" onclick';
app.sessions.get('s-b').mode = 'my-cli';
app._renderTileChrome();
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot');
expect(logoOf('s-a').title).toBe('qwen3.8-27b');
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot my-cli');
expect(logoOf('s-b').title).toBe('my-cli \u00B7 Haiku 4.5');
});
});
describe('header buttons', () => { describe('header buttons', () => {
it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => { it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => {
const app = openGrid(); const app = openGrid();
@@ -216,8 +375,8 @@ describe('a translated label survives a refresh (zh-CN)', () => {
describe('rename', () => { describe('rename', () => {
function startRename(app: GridApp, id: string) { function startRename(app: GridApp, id: string) {
headerOf(id).children[1].dispatch('dblclick', { stopPropagation: vi.fn() }); nameSlotOf(id).dispatch('dblclick', { stopPropagation: vi.fn() });
return headerOf(id).children[1]; return nameSlotOf(id);
} }
it('double-click puts an input in place of the name; Enter renames through the write queue', () => { it('double-click puts an input in place of the name; Enter renames through the write queue', () => {
@@ -235,8 +394,8 @@ describe('rename', () => {
input.value = 'renamed'; input.value = 'renamed';
input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() }); input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() });
expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed'); expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed');
expect(headerOf('s-b').children[1].className).toBe('tile-name'); expect(nameSlotOf('s-b').className).toBe('tile-name');
expect(headerOf('s-b').children[1].textContent).toBe('renamed'); expect(nameSlotOf('s-b').textContent).toBe('renamed');
}); });
it('Escape cancels without a write', () => { it('Escape cancels without a write', () => {
@@ -246,7 +405,7 @@ describe('rename', () => {
input.value = 'nope'; input.value = 'nope';
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() }); input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
expect(app._queueInlineSessionName).not.toHaveBeenCalled(); expect(app._queueInlineSessionName).not.toHaveBeenCalled();
expect(headerOf('s-b').children[1].textContent).toBe('s-b'); expect(nameSlotOf('s-b').textContent).toBe('s-b');
}); });
it('a header refresh while renaming leaves the input alone', () => { it('a header refresh while renaming leaves the input alone', () => {
@@ -254,7 +413,7 @@ describe('rename', () => {
const input = startRename(app, 's-b'); const input = startRename(app, 's-b');
input.value = 'half-typed'; input.value = 'half-typed';
app._renderTileChrome(); app._renderTileChrome();
expect(headerOf('s-b').children[1]).toBe(input); expect(nameSlotOf('s-b')).toBe(input);
expect(input.value).toBe('half-typed'); expect(input.value).toBe('half-typed');
}); });
@@ -265,7 +424,7 @@ describe('rename', () => {
input.value = 'x'; input.value = 'x';
input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() }); input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() });
expect(app._queueInlineSessionName).not.toHaveBeenCalled(); expect(app._queueInlineSessionName).not.toHaveBeenCalled();
expect(headerOf('s-b').children[1]).toBe(input); expect(nameSlotOf('s-b')).toBe(input);
}); });
}); });
+51 -1
View File
@@ -72,6 +72,14 @@ const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g)
const seen = new Map<string, string>(); const seen = new Map<string, string>();
/** Text in a data-i18n-skip subtree (user text): must never be translated. */ /** Text in a data-i18n-skip subtree (user text): must never be translated. */
const userText = new Set<string>(); const userText = new Set<string>();
/**
* The harness logo's tooltip and accessible name (and the model box's
* tooltip): harness and model NAMES, which stay as they are, plus at most a
* note on where the model came from, which translates.
*/
const harnessLabels = new Map<string, string>();
/** The harness labels and models the exercise below gives its sessions. */
const HARNESS_NAMES = ['Claude Code', 'DeepSeek', 'Codex', 'qwen3.8-27b', 'Haiku 4.5', 'haiku', 'gpt-5.6-terra'];
/** /**
* A title or accessible name inside a skipped subtree: the translator skips * A title or accessible name inside a skipped subtree: the translator skips
* the element's attributes along with its text, so a UI label there stays * the element's attributes along with its text, so a UI label there stays
@@ -82,9 +90,11 @@ const labelsInSkip: string[] = [];
function harvest(root: FakeEl | null | undefined, where: string) { function harvest(root: FakeEl | null | undefined, where: string) {
const walk = (el: FakeEl, inSkip: boolean) => { const walk = (el: FakeEl, inSkip: boolean) => {
const skip = inSkip || 'data-i18n-skip' in el.attrs; const skip = inSkip || 'data-i18n-skip' in el.attrs;
const names = /\b(run-mode-dot|tile-model)\b/.test(el.className);
const add = (value: unknown, kind: string) => { const add = (value: unknown, kind: string) => {
if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return; if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return;
if (skip && kind === 'text') userText.add(value.trim()); if (names && !skip && kind !== 'text') harnessLabels.set(value.trim(), `${where} (${kind})`);
else if (skip && kind === 'text') userText.add(value.trim());
else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`); else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`);
else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`); else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`);
}; };
@@ -127,6 +137,22 @@ const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
async function exercise() { async function exercise() {
setUp(); setUp();
const app = makeGridApp(EIGHT); const app = makeGridApp(EIGHT);
// The harness logos: a CLI-reported model, a launch one, a custom endpoint's, none.
windowStub.__codemanCliCatalog = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
{ id: 'codex', label: 'Codex' },
];
Object.assign(app.sessions.get('s-1'), {
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
app.sessions.get('s-2').displayModel = { model: 'haiku', source: 'launch' };
Object.assign(app.sessions.get('s-3'), {
mode: 'codex',
displayModel: { model: 'qwen3.8-27b', source: 'custom-endpoint' },
});
app.sessions.get('s-6').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
let pill = 'idle'; let pill = 'idle';
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
app._mobileOverviewStampText = () => '3m'; app._mobileOverviewStampText = () => '3m';
@@ -291,6 +317,30 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
expect(bad).toEqual([]); expect(bad).toEqual([]);
}); });
it('the harness logo keeps harness and model names as they are and translates the rest', () => {
// Not vacuous: every form the logo's tooltip takes was seen.
expect([...harnessLabels.keys()]).toEqual(
expect.arrayContaining([
'DeepSeek \u00B7 qwen3.8-27b',
'Claude Code \u00B7 haiku (set at launch)',
'Codex \u00B7 qwen3.8-27b (custom endpoint)',
'Claude Code \u00B7 Haiku 4.5',
'Claude Code',
])
);
const bad: string[] = [];
for (const [label, where] of harnessLabels) {
const text = zh.api.t(label);
const kept = HARNESS_NAMES.filter((n) => label.includes(n));
let rest = text;
for (const n of [...kept].sort((a, b) => b.length - a.length)) rest = rest.split(n).join('');
if (!kept.length || kept.some((n) => !text.includes(n)) || leftover(rest).length || en.api.t(label) !== label) {
bad.push(`${where}: "${label}" -> "${text}"`);
}
}
expect(bad).toEqual([]);
});
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => { it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
expect(labelsInSkip).toEqual([]); expect(labelsInSkip).toEqual([]);
}); });
+3 -1
View File
@@ -28,7 +28,9 @@ import {
const IDS = ['s-a', 's-b', 's-c', 's-d']; const IDS = ['s-a', 's-b', 's-c', 's-d'];
const zoomButton = (id: string) => const zoomButton = (id: string) =>
tileEl(id).children[0].children[2].children.find((b) => b.className.includes('tile-zoom')) as FakeEl; tileEl(id)
.querySelector('.tile-actions')!
.children.find((b) => b.className.includes('tile-zoom')) as FakeEl;
function openGrid(ids = IDS, focus = ids[0]): GridApp { function openGrid(ids = IDS, focus = ids[0]): GridApp {
const app = makeGridApp(ids); const app = makeGridApp(ids);