mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`.
- The dot is the six-state classifier the tab rows and both home screens
share (_sidebarRichRow), with the existing .home-sessions-dot--* classes;
hovering the header says the state and for how long ("working 3m"). A
tile whose session waits on a permission prompt or question gets a pulsing
red border (box-shadow only, never layout; still under reduced motion).
- The name is text with data-i18n-skip; a double-click renames it through
the tab rename's own write queue (Enter or leaving the field commits,
Escape cancels, an IME composition owns Enter), and an in-flight name shows
as already applied, as on the tab.
- ⋯ is the tab rail's session menu (options, new window, close session with
its confirm); × removes the tile ONLY, the session keeps running. Neither
button focuses a tile that is not focused.
- The header is fixed at 24px so nothing in it can resize the body, and with
it the xterm and its PTY (#464).
Every tab render refreshes the headers, so they follow status and name
changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the
tab strip re-renders when tiles come and go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
213 lines
8.7 KiB
TypeScript
213 lines
8.7 KiB
TypeScript
/**
|
||
* @fileoverview A tile's header: `● name ......... ⋯ ×`, and the tab marker.
|
||
*
|
||
* - The dot uses the six-state classifier the tab rows and both home screens
|
||
* share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*`
|
||
* classes; a `needs` tile gets the pulsing red border; hovering shows the
|
||
* state and how long ("working 3m").
|
||
* - The name is text, never markup, and carries `data-i18n-skip`; a
|
||
* double-click renames through the tab rename's own write queue.
|
||
* - `⋯` is the tab rail's session menu; `×` removes the tile ONLY (the
|
||
* session keeps running), and neither button focuses a tile that is not
|
||
* focused (which would spend its idle alert).
|
||
* - Every tab render refreshes the headers, so they follow session changes.
|
||
* - Tabs of tiled sessions carry `.in-tiles`.
|
||
*
|
||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js';
|
||
|
||
const IDS = ['s-a', 's-b', 's-c'];
|
||
|
||
/** A grid on IDS with the shared classifier stand-ins mobile-overview.js would provide. */
|
||
function openGrid(): GridApp {
|
||
const app = makeGridApp(IDS);
|
||
app._mobileOverviewState = (session: { status?: string }, hooks?: Set<string>) =>
|
||
hooks?.has('permission_prompt') ? 'needs' : session.status === 'busy' ? 'working' : 'idle';
|
||
app._mobileOverviewSince = (_state: string, session: { lastActivityAt?: number }) => ({
|
||
key: 'x',
|
||
at: session.lastActivityAt || 0,
|
||
});
|
||
app._mobileOverviewExit = () => null;
|
||
app._mobileOverviewStampText = (at: number) => (at ? '3m' : '');
|
||
app.openTileGrid(IDS);
|
||
app.markIdleAlertSeen.mockClear();
|
||
return app;
|
||
}
|
||
|
||
const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
|
||
const headerOf = (id: string) => tileEl(id).children[0];
|
||
const buttonOf = (id: string, cls: string) =>
|
||
headerOf(id).children[2].children.find((b) => b.className.includes(cls)) as FakeEl;
|
||
|
||
beforeEach(() => {
|
||
resetGridHarness();
|
||
});
|
||
|
||
describe('the header', () => {
|
||
it('shows the session name as text, skipped by the translator', () => {
|
||
const app = makeGridApp(IDS);
|
||
app.sessions.get('s-b').name = '<b>Sessions</b>';
|
||
app.openTileGrid(IDS);
|
||
const name = headerOf('s-b').children[1];
|
||
expect(name.textContent).toBe('<b>Sessions</b>');
|
||
expect(name.getAttribute('data-i18n-skip')).toBe('');
|
||
expect(name.children).toHaveLength(0);
|
||
});
|
||
|
||
it('header and body are siblings, the body holding the terminal', () => {
|
||
openGrid();
|
||
const el = tileEl('s-a');
|
||
expect(el.children.map((c) => c.className)).toEqual(['tile-header', 'tile-body']);
|
||
expect(FakeTile.all.find((t) => t.sessionId === 's-a')?.mountEl).toBe(el.children[1]);
|
||
});
|
||
|
||
it('the dot follows the session state, a permission prompt marks the whole tile', () => {
|
||
const app = openGrid();
|
||
app.sessions.get('s-b').status = 'busy';
|
||
app.pendingHooks.set('s-c', new Set(['permission_prompt']));
|
||
app._renderTileChrome();
|
||
|
||
expect(headerOf('s-a').children[0].className).toContain('home-sessions-dot--idle');
|
||
expect(headerOf('s-b').children[0].className).toContain('home-sessions-dot--working');
|
||
expect(headerOf('s-c').children[0].className).toContain('home-sessions-dot--needs');
|
||
expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true);
|
||
expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false);
|
||
});
|
||
|
||
it('hovering says the state and for how long', () => {
|
||
const app = openGrid();
|
||
app.sessions.get('s-b').status = 'busy';
|
||
app.sessions.get('s-b').lastActivityAt = Date.now() - 180_000;
|
||
app._renderTileChrome();
|
||
expect(headerOf('s-b').title).toBe('working 3m');
|
||
});
|
||
|
||
it('every tab render refreshes the headers', () => {
|
||
const app = openGrid();
|
||
app._renderTileChrome = vi.fn();
|
||
// The original returns at once during an inline tab rename; the headers still refresh.
|
||
app._inlineRenameActive = true;
|
||
app._renderSessionTabsImmediate();
|
||
expect(app._renderTileChrome).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('header buttons', () => {
|
||
it('× removes the tile only: the session keeps running, a neighbour takes focus unacknowledged', () => {
|
||
const app = openGrid();
|
||
app._apiDelete = vi.fn();
|
||
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
|
||
|
||
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||
expect(app.sessions.has('s-a')).toBe(true);
|
||
expect(app._apiDelete).not.toHaveBeenCalled();
|
||
expect(app.activeSessionId).toBe('s-b');
|
||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('× on the last tile shows that session in the single view', () => {
|
||
const app = makeGridApp(['s-a']);
|
||
app.openTileGrid(['s-a']);
|
||
app.selectSession = vi.fn();
|
||
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
|
||
expect(app._tilesOwnTerminal()).toBe(false);
|
||
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
|
||
});
|
||
|
||
it('⋯ opens the tab rail session menu for that session', () => {
|
||
const app = openGrid();
|
||
app.openTabRailActionMenu = vi.fn();
|
||
const ev = { stopPropagation: vi.fn() };
|
||
buttonOf('s-c', 'tile-menu').dispatch('click', ev);
|
||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(ev, 's-c');
|
||
});
|
||
|
||
it('pressing a header button never focuses the tile (no selection, no acknowledgement)', () => {
|
||
const app = openGrid();
|
||
app.selectSession = vi.fn();
|
||
const stop = vi.fn();
|
||
buttonOf('s-c', 'tile-remove').dispatch('pointerdown', { stopPropagation: stop });
|
||
expect(stop).toHaveBeenCalled();
|
||
// The tile's own pointerdown (a focus) only runs if the event reaches it.
|
||
tileEl('s-c').dispatch('pointerdown', {});
|
||
expect(app.selectSession).toHaveBeenCalledWith('s-c');
|
||
});
|
||
});
|
||
|
||
describe('rename', () => {
|
||
function startRename(app: GridApp, id: string) {
|
||
headerOf(id).children[1].dispatch('dblclick', { stopPropagation: vi.fn() });
|
||
return headerOf(id).children[1];
|
||
}
|
||
|
||
it('double-click puts an input in place of the name; Enter renames through the write queue', () => {
|
||
const app = openGrid();
|
||
// The real queue records the name in flight before its PUT lands.
|
||
app._inlineRenamePending = new Map();
|
||
app._queueInlineSessionName = vi.fn(async (id: string, name: string) => {
|
||
app._inlineRenamePending.set(id, name);
|
||
return { status: 'confirmed' };
|
||
});
|
||
const input = startRename(app, 's-b');
|
||
expect(input.className).toBe('tile-rename-input');
|
||
expect(input.value).toBe('s-b');
|
||
|
||
input.value = 'renamed';
|
||
input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() });
|
||
expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed');
|
||
expect(headerOf('s-b').children[1].className).toBe('tile-name');
|
||
expect(headerOf('s-b').children[1].textContent).toBe('renamed');
|
||
});
|
||
|
||
it('Escape cancels without a write', () => {
|
||
const app = openGrid();
|
||
app._queueInlineSessionName = vi.fn();
|
||
const input = startRename(app, 's-b');
|
||
input.value = 'nope';
|
||
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
|
||
expect(headerOf('s-b').children[1].textContent).toBe('s-b');
|
||
});
|
||
|
||
it('a header refresh while renaming leaves the input alone', () => {
|
||
const app = openGrid();
|
||
const input = startRename(app, 's-b');
|
||
input.value = 'half-typed';
|
||
app._renderTileChrome();
|
||
expect(headerOf('s-b').children[1]).toBe(input);
|
||
expect(input.value).toBe('half-typed');
|
||
});
|
||
|
||
it('an IME composition owns Enter', () => {
|
||
const app = openGrid();
|
||
app._queueInlineSessionName = vi.fn();
|
||
const input = startRename(app, 's-b');
|
||
input.value = 'x';
|
||
input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() });
|
||
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
|
||
expect(headerOf('s-b').children[1]).toBe(input);
|
||
});
|
||
});
|
||
|
||
describe('the tab marker', () => {
|
||
// Tab rendering needs the whole strip; the class is pinned at both render paths.
|
||
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||
|
||
it('the full render puts .in-tiles on a tiled session tab', () => {
|
||
expect(app).toContain("${this._tileGrid?.has(id) ? ' in-tiles' : ''}");
|
||
});
|
||
|
||
it('the incremental render toggles it', () => {
|
||
expect(app).toContain("tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));");
|
||
});
|
||
|
||
it('it has a style', () => {
|
||
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||
expect(css).toMatch(/\.session-tab\.in-tiles/);
|
||
});
|
||
});
|