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
+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>();
/** Text in a data-i18n-skip subtree (user text): must never be translated. */
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
* 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) {
const walk = (el: FakeEl, inSkip: boolean) => {
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) => {
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 (!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() {
setUp();
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';
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
@@ -291,6 +317,30 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
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)', () => {
expect(labelsInSkip).toEqual([]);
});