mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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:
@@ -777,7 +777,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
||||
|
||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the harness mark (an agent CLI's `run-mode-dot` logo, a shell's `sh` chip) do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
|
||||
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // tab badge, e.g. 'CX'
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
|
||||
+10
-1
@@ -6614,6 +6614,15 @@ class CodemanApp {
|
||||
// (direct-PTY, remote SSH, docker). See paneExitLabel().
|
||||
const paneExitBadge = paneExitLabel(session.paneExit);
|
||||
|
||||
// Which harness runs here. A shell keeps its SH pill (it is not an agent);
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
@@ -6623,7 +6632,7 @@ class CodemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
${tabModeHtml}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
|
||||
@@ -692,6 +692,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="tab-number">${base + i + 1}</span>
|
||||
<span class="tab-status idle" aria-hidden="true"></span>
|
||||
<span class="tab-info"><span class="tab-name-row">
|
||||
<span class="tab-harness run-mode-dot claude" aria-hidden="true"></span>
|
||||
<span class="tab-name">w${base + i + 1}-demo</span>
|
||||
</span></span>`;
|
||||
container.appendChild(tab);
|
||||
|
||||
@@ -70,17 +70,13 @@ const HOME_SESSIONS_PILL_LABEL = {
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
|
||||
/**
|
||||
* Text badge per backend, mirroring the tab strip: only the shell has one. Every
|
||||
* agent CLI (claude included) shows its logo instead, through the same
|
||||
* `run-mode-dot <id>` slot the strip uses (see _buildHomeSessionRow).
|
||||
*/
|
||||
const HOME_SESSIONS_MODE_BADGE = {
|
||||
shell: 'sh',
|
||||
opencode: 'oc',
|
||||
codex: 'cx',
|
||||
gemini: 'gm',
|
||||
antigravity: 'ag',
|
||||
pi: 'pi',
|
||||
grok: 'gk',
|
||||
deepseek: 'ds',
|
||||
omp: 'om',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -430,6 +426,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = row.modeBadge;
|
||||
line1.appendChild(badge);
|
||||
} else {
|
||||
// The agent's logo: PR #532's slot, the mode id as data (an id with no
|
||||
// logo rule gets the slot's plain dot).
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
line1.appendChild(logo);
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
// .session-name is in the i18n skip list: a session name is user content.
|
||||
|
||||
@@ -354,12 +354,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(
|
||||
.search-filter-chip.active,
|
||||
.search-badge-session,
|
||||
.history-view-all-btn,
|
||||
.session-tab .tab-mode.gemini,
|
||||
.session-tab .tab-mode.antigravity,
|
||||
.session-tab .tab-mode.pi,
|
||||
.session-tab .tab-mode.grok,
|
||||
.session-tab .tab-mode.omp
|
||||
.history-view-all-btn
|
||||
) {
|
||||
color: var(--accent-d);
|
||||
}
|
||||
@@ -1911,6 +1906,7 @@ html[data-tab-orientation='vertical'].tab-rail-compact
|
||||
.session-tab:has(.tab-name-renaming)
|
||||
:is(
|
||||
.tab-mode,
|
||||
.tab-harness,
|
||||
.tab-exited-badge,
|
||||
.tab-detached-badge,
|
||||
.tab-badge,
|
||||
@@ -3008,49 +3004,10 @@ body.solo-mode .btn-lifecycle-log {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.opencode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.codex {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.gemini {
|
||||
background: rgba(138, 180, 248, 0.2);
|
||||
color: #8ab4f8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.antigravity {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
color: #22d3ee;
|
||||
}
|
||||
.session-tab .tab-mode.omp {
|
||||
background: rgba(129, 140, 248, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.pi {
|
||||
background: rgba(244, 114, 182, 0.2);
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.grok {
|
||||
background: rgba(212, 212, 216, 0.18);
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
|
||||
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
|
||||
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
|
||||
carries enough contrast on the light skins, and overriding it would throw away
|
||||
the one cue that separates a dsh tab from its neighbours. */
|
||||
.session-tab .tab-mode.deepseek {
|
||||
background: rgba(77, 107, 254, 0.18);
|
||||
color: #7c93ff;
|
||||
}
|
||||
/* Every agent CLI (claude included) shows its logo instead, in a
|
||||
`.tab-harness.run-mode-dot <id>` slot that the run-mode-dot rules draw: brand
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
@@ -16796,6 +16753,8 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* Only the shell has this text pill; an agent row shows its logo instead in a
|
||||
`.home-sessions-harness.run-mode-dot <id>` slot, as the tab strip does. */
|
||||
.home-sessions-mode {
|
||||
flex-shrink: 0;
|
||||
padding: 0 0.35em;
|
||||
|
||||
+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