mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
feat(tabs): every agent tab shows its CLI logo, claude included
A claude tab drew no harness mark at all and every other agent CLI a two-letter text pill (DS, CX, ...), so a claude tab read as "no harness" next to its neighbours. Session tabs (header strip, side rail, sidebar, phone chips) and the desktop home rail now draw the agent through PR #532's run-mode-dot <id> slot, the id as data, the same mark the Run menus and the tile and split headers use. The shell is not an agent and keeps its SH pill; a CLI added through clis.json gets the slot's plain dot instead of nothing. The per-CLI tab pill colours and their light-skin ink overrides are gone (the monochrome marks follow the tab's own text colour), and the logo steps aside with the other adornments while a compact rail row renames. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-13
@@ -155,7 +155,7 @@ describe('home sessions column: model', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a row with its case and a short backend badge', () => {
|
||||
it('labels a row with its case; an agent CLI carries its mode for the logo', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'a', name: 'w1-claudeman', mode: 'codex' }]),
|
||||
sessionOrder: ['a'],
|
||||
@@ -164,33 +164,39 @@ describe('home sessions column: model', () => {
|
||||
|
||||
const [row] = app.buildHomeSessionRows();
|
||||
expect(row.caseName).toBe('claudeman');
|
||||
expect(row.modeBadge).toBe('cx');
|
||||
// claude is the default backend and gets no badge — the strip does the same.
|
||||
// Agent CLIs show their logo (drawn from row.mode), not a text badge, the
|
||||
// same as the strip; test/tab-harness-logo.test.ts pins the markup.
|
||||
expect(row.mode).toBe('codex');
|
||||
expect(row.modeBadge).toBe('');
|
||||
const plain = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'a', mode: 'claude' }]),
|
||||
sessionOrder: ['a'],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(plain.buildHomeSessionRows()[0].modeBadge).toBe('');
|
||||
expect(plain.buildHomeSessionRows()[0].mode).toBe('claude');
|
||||
});
|
||||
|
||||
it('badges every non-claude backend, so a new run mode cannot read as claude here', () => {
|
||||
// The badge map is a per-mode lookup with a '' fallback, so a mode missing from it
|
||||
// is indistinguishable from claude in this rail while the tab strip badges it fine.
|
||||
it('gives only the shell a text badge, and every backend its own mode', () => {
|
||||
for (const [mode, badge] of [
|
||||
['shell', 'sh'],
|
||||
['opencode', 'oc'],
|
||||
['codex', 'cx'],
|
||||
['gemini', 'gm'],
|
||||
['antigravity', 'ag'],
|
||||
['pi', 'pi'],
|
||||
['claude', ''],
|
||||
['opencode', ''],
|
||||
['codex', ''],
|
||||
['gemini', ''],
|
||||
['antigravity', ''],
|
||||
['pi', ''],
|
||||
['grok', ''],
|
||||
['deepseek', ''],
|
||||
['omp', ''],
|
||||
] as const) {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'a', mode }]),
|
||||
sessionOrder: ['a'],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(app.buildHomeSessionRows()[0].modeBadge).toBe(badge);
|
||||
const [row] = app.buildHomeSessionRows();
|
||||
expect(row.modeBadge, mode).toBe(badge);
|
||||
expect(row.mode, mode).toBe(mode);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @fileoverview Every agent tab shows its harness logo (claude included).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - A session tab draws its CLI through PR #532's `run-mode-dot <id>` slot,
|
||||
* the mode id as data, so the tab, the tile and split headers and the Run
|
||||
* menus all draw the same mark. Claude used to get nothing at all and the
|
||||
* other CLIs a two-letter text pill, so a claude tab read as "no harness".
|
||||
* - The shell is not an agent and keeps its SH pill.
|
||||
* - A CLI added through ~/.codeman/clis.json still gets the slot (its plain
|
||||
* dot), and a mode string never reaches the markup unescaped.
|
||||
* - Every stock agent CLI has a logo rule, so a new stock CLI cannot ship a
|
||||
* tab that shows only the plain dot.
|
||||
* - The desktop home rail mirrors the strip.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
describe('session tab harness logo (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('home-sessions.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__HarnessCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__HarnessCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(sessions: Array<Record<string, unknown>>) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = 'classic';
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.cases = [];
|
||||
app.sessions = new Map(sessions.map((s) => [s.id as string, { status: 'idle', workingDir: '/c/x', ...s }]));
|
||||
app.sessionOrder = sessions.map((s) => s.id as string);
|
||||
app.pendingHooks = new Map();
|
||||
app.webviews = new Map();
|
||||
app.webviewOrder = [];
|
||||
app.activeSessionId = sessions[0]?.id ?? null;
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const tab = (id: string) => document.querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('draws the logo slot for every agent CLI, claude included, and no text pill', () => {
|
||||
const agents = STOCK_CLIS.map((c) => c.id).filter((id) => id !== 'shell');
|
||||
makeApp(agents.map((mode) => ({ id: `s-${mode}`, name: `w1-${mode}`, mode })))._fullRenderSessionTabs();
|
||||
for (const mode of agents) {
|
||||
const logo = tab(`s-${mode}`).querySelector('.tab-name-row > .tab-harness');
|
||||
expect(logo, mode).not.toBeNull();
|
||||
expect(logo!.className, mode).toBe(`tab-harness run-mode-dot ${mode}`);
|
||||
expect(logo!.getAttribute('aria-hidden'), mode).toBe('true');
|
||||
expect(logo!.textContent, mode).toBe('');
|
||||
expect(tab(`s-${mode}`).querySelector('.tab-mode'), mode).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a session with no mode as claude', () => {
|
||||
makeApp([{ id: 'legacy', name: 'w1-legacy' }])._fullRenderSessionTabs();
|
||||
expect(tab('legacy').querySelector('.tab-harness')?.className).toBe('tab-harness run-mode-dot claude');
|
||||
});
|
||||
|
||||
it('keeps the SH pill for a shell, with no logo slot', () => {
|
||||
makeApp([{ id: 'sh', name: 'w1-shell', mode: 'shell' }])._fullRenderSessionTabs();
|
||||
const pill = tab('sh').querySelector('.tab-mode.shell');
|
||||
expect(pill?.textContent).toBe('sh');
|
||||
expect(tab('sh').querySelector('.tab-harness')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a registry-added CLI the slot and escapes the mode string', () => {
|
||||
makeApp([
|
||||
{ id: 'custom', name: 'w1-custom', mode: 'mycli' },
|
||||
{ id: 'hostile', name: 'w1-hostile', mode: 'x"><img src=x onerror=1>' },
|
||||
])._fullRenderSessionTabs();
|
||||
expect(tab('custom').querySelector('.tab-harness')?.className).toBe('tab-harness run-mode-dot mycli');
|
||||
expect(tab('hostile').querySelector('img')).toBeNull();
|
||||
expect(tab('hostile').querySelectorAll('.tab-name-row > .tab-harness')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mirrors the strip on the desktop home rail', () => {
|
||||
const app = makeApp([
|
||||
{ id: 'c', name: 'w1-c', mode: 'claude' },
|
||||
{ id: 'd', name: 'w1-d', mode: 'deepseek' },
|
||||
{ id: 'sh', name: 'w1-sh', mode: 'shell' },
|
||||
]);
|
||||
app._mobileOverviewCaseFor = () => null;
|
||||
app._mobileOverviewState = () => 'idle';
|
||||
const rows = app.buildHomeSessionRows();
|
||||
const titleOf = (id: string) =>
|
||||
app._buildHomeSessionRow(rows.find((r: any) => r.id === id)).querySelector('.home-sessions-row-title');
|
||||
expect(titleOf('c').querySelector('.home-sessions-harness')?.className).toBe(
|
||||
'home-sessions-harness run-mode-dot claude'
|
||||
);
|
||||
expect(titleOf('c').querySelector('.home-sessions-mode')).toBeNull();
|
||||
expect(titleOf('d').querySelector('.home-sessions-harness')?.className).toBe(
|
||||
'home-sessions-harness run-mode-dot deepseek'
|
||||
);
|
||||
expect(titleOf('sh').querySelector('.home-sessions-mode')?.textContent).toBe('sh');
|
||||
expect(titleOf('sh').querySelector('.home-sessions-harness')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('harness logo rules (styles.css)', () => {
|
||||
const css = read('styles.css');
|
||||
|
||||
it('has a logo for every stock agent CLI', () => {
|
||||
for (const { id } of STOCK_CLIS) {
|
||||
expect(css, id).toMatch(new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\(`));
|
||||
}
|
||||
});
|
||||
|
||||
it('no longer colours a per-CLI tab pill, and hides the logo while renaming', () => {
|
||||
expect(css).not.toMatch(/\.session-tab \.tab-mode\.(?!shell\b)[a-z]+/);
|
||||
expect(css).toMatch(/\.tab-mode,\s*\.tab-harness,\s*\.tab-exited-badge,/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user