mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn. The pane then falls quiet, Claude Code's idle_prompt notification arrives a minute later, and every surface files the session under NEEDS YOU with nothing for a human to answer. Claude states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now `capabilities.workDetect.watchingLine` in the CLI registry, guarded by compileVersionRegex() like every other config regex, and the idle probe reads it off the capture it already takes: `watchingLabel()` in session-activity.ts searches the last five lines only, so a session that PRINTS "1 monitor" is not mistaken for one running it. The label lands on Session.watching and rides toLightDetailedState() out to every surface. The phone overview, the desktop home rail and the rich sidebar rows wear it as a `watching` badge in the accent colour, beside the state pill and never in place of it: an agent can arm a monitor and ask a question in the same breath, and only the pill says which. Verified end to end against a throwaway session on an isolated beta instance: the payload carried `watching: "1 monitor"` once the turn ended, the badge rendered next to a yellow `waiting` pill, and both cleared when the monitor died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
108 lines
5.7 KiB
TypeScript
108 lines
5.7 KiB
TypeScript
/** Structural and schema coverage for vertical session navigation density and actions. */
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
|
|
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
|
const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8');
|
|
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
|
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
|
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
|
const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8');
|
|
const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
|
|
|
|
describe('vertical session navigation UX contract', () => {
|
|
it('accepts only integer session-name sizes from 11 through 18 pixels', () => {
|
|
for (const value of [11, 14, 18]) {
|
|
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true);
|
|
}
|
|
for (const value of [10, 19, 14.5, '14']) {
|
|
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('renders one existing action cluster through a shared placement resolver', () => {
|
|
expect(app).toContain('shouldInlineSessionActions()');
|
|
expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();');
|
|
expect(app).toContain('const tabActionsHtml =');
|
|
expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}");
|
|
expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}");
|
|
expect(app.match(/class="tab-actions"/g)).toHaveLength(1);
|
|
expect(app).toContain("tab.querySelector(':scope > .tab-actions')");
|
|
});
|
|
|
|
it('limits inline actions to expanded sidebar and expanded non-compact rail', () => {
|
|
expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/);
|
|
expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/);
|
|
expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']");
|
|
expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)");
|
|
});
|
|
|
|
it('opens only the existing session actions from the overflow trigger', () => {
|
|
expect(railController).toContain('openTabRailActionMenu(event, sessionId)');
|
|
expect(railController).toContain("label: 'Session options'");
|
|
expect(railController).toContain("label: 'Open in a new window'");
|
|
expect(railController).toContain("label: 'Close session'");
|
|
expect(railController).not.toContain('Move to group');
|
|
});
|
|
|
|
it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => {
|
|
expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/);
|
|
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
|
|
expect(html).toContain('--session-sidebar-name-font-size');
|
|
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
|
|
// 12 = the sidebar's historical 0.75rem name size: the default must
|
|
// never restyle an install whose user never touched the slider.
|
|
expect(settingsUi).toContain('sessionSidebarFontSize: 12,');
|
|
expect(settingsUi).toContain("'sessionSidebarFontSize'");
|
|
expect(app).toContain('resolveSessionSidebarFontSize(value)');
|
|
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
|
|
expect(styles).toMatch(
|
|
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
|
|
);
|
|
expect(styles).toMatch(
|
|
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
|
|
);
|
|
});
|
|
|
|
it('labels and translates the name-only scope', () => {
|
|
expect(html).toContain('Session Name Font Size');
|
|
expect(html).toContain('Adjust only session names in the vertical sidebar.');
|
|
expect(i18n).toContain("'Session Name Font Size':");
|
|
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
|
|
});
|
|
});
|
|
|
|
describe('watching badge on a rich session row', () => {
|
|
it('reads the label off the session payload', () => {
|
|
expect(app).toContain("watching: typeof session.watching === 'string' ? session.watching : ''");
|
|
});
|
|
|
|
it('renders it beside the state pill rather than in place of it', () => {
|
|
// A session can be watching a monitor AND holding a question for the user, so the
|
|
// pill that says which one still decides the row; this badge only adds a fact.
|
|
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
|
|
const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {'));
|
|
expect(body).toContain('tab-pill tab-pill--${escapeHtml(row.state)}');
|
|
expect(body).toContain('tab-pill tab-pill--watching');
|
|
expect(body).toContain('Still running in the background:');
|
|
});
|
|
|
|
it('re-renders the row when the background work changes', () => {
|
|
// The meta line is rebuilt only when this signature moves, so a badge left out of
|
|
// it would appear and disappear a render late, or not at all.
|
|
expect(app).toContain(
|
|
'const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`'
|
|
);
|
|
});
|
|
|
|
it('colours it with the accent, never with the two colours that mean a human is needed', () => {
|
|
const rule = styles.slice(styles.indexOf('.tab-pill--watching'));
|
|
const block = rule.slice(0, rule.indexOf('}'));
|
|
expect(block).toContain('var(--accent)');
|
|
expect(block).not.toContain('var(--red)');
|
|
expect(block).not.toContain('var(--yellow)');
|
|
});
|
|
});
|