mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): tile header (status dot, name, menu, remove) and the in-tiles tab marker
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>
This commit is contained in:
@@ -5647,6 +5647,9 @@ class CodemanApp {
|
||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||
|
||||
// Tile grid membership (tile-grid.js): a tiled session's tab says so.
|
||||
tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));
|
||||
|
||||
// Inject tab-number badge if missing (added after initial render)
|
||||
if (!tab.querySelector('.tab-number')) {
|
||||
const idx = this.sessionOrder.indexOf(id);
|
||||
@@ -6000,7 +6003,7 @@ class CodemanApp {
|
||||
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>`;
|
||||
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
|
||||
@@ -19420,6 +19420,102 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Tile header: `● name ......... ⋯ ×`. FIXED height (#464): the body below
|
||||
holds the xterm, so a header that grew (a wrapped name, the rename input)
|
||||
would shrink the body with nothing refitting the xterm or its PTY. */
|
||||
.tile-header {
|
||||
flex: 0 0 24px;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 2px 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tile-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tile-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 18px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tile-btn:hover,
|
||||
.tile-btn:focus-visible {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--text-muted, #888) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Sized to sit inside the fixed header (see .tile-header). */
|
||||
.tile-rename-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A permission prompt or question, visible across the room. Only the border
|
||||
colour and a box-shadow animate: anything that moves layout would resize
|
||||
the tile's PTY every frame. */
|
||||
.tile.tile--needs {
|
||||
border-color: var(--red, #ef4444);
|
||||
animation: tile-needs-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tile-needs-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--red, #ef4444) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.tile--needs {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* The tab of a tiled session (the focused tile's tab is .active as usual). */
|
||||
.session-tab.in-tiles:not(.active) {
|
||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent, #4a9eff) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Quiet "loading" state while the tile's capture waits in the grid's one load
|
||||
queue (or runs). */
|
||||
.tile.tile--loading .tile-body::after {
|
||||
|
||||
+150
-2
@@ -252,6 +252,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._updateConnectionIndicator?.();
|
||||
this._updateSplitButtonForTiles();
|
||||
// The tabs drop their .in-tiles marker.
|
||||
this.renderSessionTabs?.();
|
||||
if (reselect) this._selectAfterTileGrid(focusedId);
|
||||
},
|
||||
|
||||
@@ -373,6 +375,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyTileLayout();
|
||||
this._connectTile(sessionId);
|
||||
this._scheduleTileGridRefit();
|
||||
this.renderSessionTabs?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -399,6 +402,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
grid.ids.splice(grid.ids.indexOf(sessionId), 1);
|
||||
this._applyTileLayout();
|
||||
this._scheduleTileGridRefit();
|
||||
this.renderSessionTabs?.();
|
||||
if (wasFocused) {
|
||||
grid.focusedId = null;
|
||||
if (refocus && neighbor) this._selectTiledSession(neighbor, { auto });
|
||||
@@ -413,9 +417,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile';
|
||||
el.dataset.sessionId = sessionId;
|
||||
// Header and body are siblings: the chrome is refreshed in place
|
||||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||||
// xterm in the body with it.
|
||||
const header = this._buildTileHeader(sessionId);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
el.appendChild(body);
|
||||
el.append(header.el, body);
|
||||
// Pressing a tile is a human selection: it focuses the tile and
|
||||
// acknowledges its idle alert (the already-focused tile hits
|
||||
// selectSession's early return, which acknowledges too). pointerdown, not
|
||||
@@ -436,11 +444,141 @@ Object.assign(CodemanApp.prototype, {
|
||||
boundedLoad: true,
|
||||
onExit: (code) => this._onTileExit(sessionId, tile, code),
|
||||
});
|
||||
grid.tiles.set(sessionId, { tile, el });
|
||||
grid.tiles.set(sessionId, { tile, el, header: header.el, dot: header.dot, name: header.name, renaming: false });
|
||||
grid.ids.push(sessionId);
|
||||
this._renderTileHeader(sessionId);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* `● name ......... ⋯ ×`: the status dot (the six-state classifier the tab
|
||||
* rows and both home screens share), the session name (double-click
|
||||
* renames), the session menu (the tab rail's own) and remove-tile. Its
|
||||
* buttons stop pointerdown, so acting on a tile that is not focused does not
|
||||
* also focus it (and spend its idle alert).
|
||||
*/
|
||||
_buildTileHeader(sessionId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile-header';
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tile-name';
|
||||
// A session literally named like a UI string ("Sessions") must not be translated.
|
||||
name.setAttribute('data-i18n-skip', '');
|
||||
name.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
this.startTileRename(sessionId);
|
||||
});
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'tile-actions';
|
||||
const button = (cls, label, glyph, onClick) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = `tile-btn ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute('aria-label', label);
|
||||
b.textContent = glyph;
|
||||
b.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
actions.append(
|
||||
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
|
||||
// Removes the tile ONLY: the session keeps running. Killing it stays
|
||||
// behind the menu's Close session and its confirm.
|
||||
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () =>
|
||||
this.removeTile(sessionId, { refocus: true, auto: true })
|
||||
)
|
||||
);
|
||||
el.append(dot, name, actions);
|
||||
return { el, dot, name };
|
||||
},
|
||||
|
||||
/**
|
||||
* Refreshes one tile's header from the session: dot state, name, the hover
|
||||
* label ("working 3m") and the `needs` border. Diffs on existing nodes only,
|
||||
* and cheap: it runs on every tab render (every status change).
|
||||
*/
|
||||
_renderTileHeader(sessionId) {
|
||||
const entry = this._tileGrid?.tiles.get(sessionId);
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!entry?.header || !session) return;
|
||||
const row = this._sidebarRichRow?.(sessionId, session) || null;
|
||||
const state = row?.state || 'idle';
|
||||
const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`;
|
||||
if (entry.dot.className !== dotClass) entry.dot.className = dotClass;
|
||||
const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : '';
|
||||
const label = row ? [row.pill, since].filter(Boolean).join(' ') : '';
|
||||
if (entry.header.title !== label) entry.header.title = label;
|
||||
// The input of a rename in progress has taken the name's place in the
|
||||
// header, so updating the detached name never touches what is being typed.
|
||||
// A rename still in flight shows as already done, as on the tab.
|
||||
const name =
|
||||
this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session';
|
||||
if (entry.name.textContent !== name) entry.name.textContent = name;
|
||||
// A permission prompt is visible across the room.
|
||||
entry.el.classList.toggle('tile--needs', state === 'needs');
|
||||
},
|
||||
|
||||
/** Every tile's header (after a tab render, i.e. any session change). */
|
||||
_renderTileChrome() {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return;
|
||||
for (const id of grid.tiles.keys()) this._renderTileHeader(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Double-click on a tile's name: an input in its place, Enter or leaving it
|
||||
* renames through the tab rename's own write queue, Escape cancels. The
|
||||
* header refresh leaves the name alone meanwhile.
|
||||
*/
|
||||
startTileRename(sessionId) {
|
||||
const entry = this._tileGrid?.tiles.get(sessionId);
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!entry || !session || entry.renaming) return;
|
||||
entry.renaming = true;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'tile-rename-input';
|
||||
input.setAttribute('aria-label', 'Session name');
|
||||
// A rename still in flight is the user's last word.
|
||||
input.value = this._inlineRenamePending?.get(sessionId) ?? session.name ?? '';
|
||||
let settled = false;
|
||||
const finish = (commit) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
input.replaceWith(entry.name);
|
||||
entry.renaming = false;
|
||||
const value = input.value.trim();
|
||||
if (commit && value && value !== session.name && this.sessions.has(sessionId)) {
|
||||
entry.name.textContent = value;
|
||||
void this._queueInlineSessionName?.(sessionId, value);
|
||||
}
|
||||
this._renderTileHeader(sessionId);
|
||||
};
|
||||
input.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
input.addEventListener('keydown', (e) => {
|
||||
// Enter and Escape during an IME composition belong to the IME.
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finish(true);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => finish(true));
|
||||
entry.name.replaceWith(input);
|
||||
input.focus();
|
||||
input.select?.();
|
||||
},
|
||||
|
||||
_connectTile(sessionId) {
|
||||
this._tileGrid?.tiles
|
||||
.get(sessionId)
|
||||
@@ -616,3 +754,13 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
}
|
||||
return _tileGridOriginalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
// Every tab render (any session change: status, hooks, name) refreshes the
|
||||
// tile headers too, so a tile's dot, name and needs border follow the same
|
||||
// state the tab shows.
|
||||
const _tileGridOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
||||
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
||||
const result = _tileGridOriginalRenderSessionTabsImmediate.apply(this, args);
|
||||
this._renderTileChrome?.();
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,24 @@ export class FakeEl {
|
||||
this.className = [...set].join(' ');
|
||||
return on;
|
||||
}
|
||||
append(...children: FakeEl[]) {
|
||||
for (const child of children) this.appendChild(child);
|
||||
}
|
||||
replaceWith(other: FakeEl) {
|
||||
const parent = this.parentElement;
|
||||
if (!parent) return;
|
||||
other.remove();
|
||||
const i = parent.children.indexOf(this);
|
||||
parent.children.splice(i, 1, other);
|
||||
other.parentElement = parent;
|
||||
this.parentElement = null;
|
||||
}
|
||||
title = '';
|
||||
textContent = '';
|
||||
value = '';
|
||||
type = '';
|
||||
focus = vi.fn();
|
||||
select = vi.fn();
|
||||
appendChild(child: FakeEl) {
|
||||
child.remove();
|
||||
child.parentElement = this;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user