mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(rail): sort the vertical tab rail by activity, and give its rows the home screen's card
The vertical rail lists exactly the sessions both home screens list, so it now answers their question the same way instead of showing the raw tab order. Order: new per-device `tabRailSort` (App Settings -> Appearance -> Tabs -> Vertical Rail Order, default "By activity"). It runs `CodemanSessionOrder` over rows classified by `_mobileOverviewState`, i.e. literally the home screens' comparator, `lastSubmitAt`-anchored running group included. It is applied as the flex `order` property, never by reordering the DOM. `#sessionTabs` stays in `sessionOrder`, which is what keeps the Alt+N badge honest (it names a shortcut, not a row position, so it deliberately does NOT run 1,2,3 down a sorted rail), and keeps drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all reading the list they always read. A session changing state then moves one inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path re-applies it, since a state flip adds no tab and never reaches the full rebuild, and an empty string is what clears it when sorting stops. Web tabs are pinned past the cards by a CSS `order: 9999`, since `renderWebviewTabs()` emits the same markup for every layout and the flex default of 0 would interleave them. Drag is switched off while sorting (the drop rewrites `sessionOrder` correctly and the sort puts the card straight back, so the affordance would be a lie); 'manual' is the way back. Cards: detailed rail rows become bordered cards on `--bg-card`, with the stamps line on its own full-width row and the pill at its right end. The state dot goes 6px to 9px, keeps its orbiting ring while working and gains the green halo; idle mutes toward `--text-muted` as the home rail does. Needs/error/ waiting reuse `home-sessions-blink-red`/`-yellow` rather than a second copy. These card rules are RAIL-SCOPED and deliberately absent from the comma-grouped selectors that carry both vertical surfaces: the rail is an occasional, resizable list you scan, while the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall. Every state-dot rule also excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and these are (0,5,1)+. Lines: the lineage bracket already drew in the rail, but its track sat 6px from the left edge, so half of its 11px outer glow was clipped by the window frame and it read as a thread pinned to the frame. It now runs at 10px, mid-channel in the gutter the rail already reserves. Tests: test/tab-rail-order.test.ts (17) drives the real `isTabRailSorted()` and `_tabRailSortOrder()` out of app.js, covering the row model (a WORKING row ranked by `lastSubmitAt`, which would otherwise rank every running turn as freshly started and fail no rendering test), the Alt+N badge, and the opt-out. Verified in Chromium across sorted/manual/simple/header-strip/sidebar with the setting flipped at runtime: no page errors, and the header strip and sidebar render byte-identically to before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+94
-1
@@ -4027,6 +4027,71 @@ class CodemanApp {
|
|||||||
return this.isSessionSidebarRich() || this.isTabRailRich();
|
return this.isSessionSidebarRich() || this.isTabRailRich();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the VERTICAL TAB RAIL orders its cards the way both home screens
|
||||||
|
* do — blocked on you first, then running longest-first, then quiet
|
||||||
|
* most-recently-quiet first (`CodemanSessionOrder`, constants.js) — instead of
|
||||||
|
* leaving them in the user's tab order.
|
||||||
|
*
|
||||||
|
* Read off <html> like the other two rail gates, because the render loop asks
|
||||||
|
* it once per pass and getSessionListLayout() re-parses localStorage.
|
||||||
|
* `tabRailSort: 'manual'` is the opt-out, and it is what a user who reorders
|
||||||
|
* by hand wants: a self-sorting list cannot also be drag-reorderable, so
|
||||||
|
* setupTabDragHandlers() drops the drag affordance while this is on rather
|
||||||
|
* than letting a card snap back to where the sort puts it.
|
||||||
|
*
|
||||||
|
* Deliberately NOT gated on `isTabRailRich()`: a simple rail lists the same
|
||||||
|
* sessions and answers the same question, it just says less about each one.
|
||||||
|
*/
|
||||||
|
isTabRailSorted() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
return root.getAttribute('data-tab-orientation') === 'vertical' && root.dataset.tabRailSort === 'activity';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual position per session id for the sorted rail, or null when the rail is
|
||||||
|
* not sorting.
|
||||||
|
*
|
||||||
|
* The sort is applied as the flex `order` property, NOT by reordering the DOM.
|
||||||
|
* That is the whole design: `#sessionTabs` stays in `sessionOrder`, so
|
||||||
|
* drag-and-drop, the Alt+N badges, the arrow-key walk, the sidebar filter and
|
||||||
|
* `_scrollActiveTabIntoView()` all keep reading the list they have always
|
||||||
|
* read, and a session changing state moves one inline style instead of
|
||||||
|
* forcing the full rebuild that would restart every card's animation.
|
||||||
|
*
|
||||||
|
* Rows are classified by `_mobileOverviewState()` and compared by
|
||||||
|
* `CodemanSessionOrder` — the same two helpers both home screens use, so the
|
||||||
|
* rail cannot disagree with them about what "working" means or what sorts
|
||||||
|
* first. `orderIndex` is the tab-strip position, which the comparator uses as
|
||||||
|
* its deterministic final tiebreak.
|
||||||
|
*
|
||||||
|
* Guarded like every other cross-file consumer: a stale cached constants.js or
|
||||||
|
* mobile-overview.js degrades to tab order rather than taking the strip down.
|
||||||
|
*
|
||||||
|
* @param {Array<string>} ids live session ids, in tab order
|
||||||
|
* @returns {Map<string, number>|null}
|
||||||
|
*/
|
||||||
|
_tabRailSortOrder(ids) {
|
||||||
|
if (!this.isTabRailSorted()) return null;
|
||||||
|
if (!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function') return null;
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const session = this.sessions.get(ids[i]);
|
||||||
|
if (!session) continue;
|
||||||
|
rows.push({
|
||||||
|
id: ids[i],
|
||||||
|
state: this._mobileOverviewState(session, this.pendingHooks?.get(ids[i])),
|
||||||
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
|
orderIndex: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sorted = window.CodemanSessionOrder.sort(rows);
|
||||||
|
const out = new Map();
|
||||||
|
for (let i = 0; i < sorted.length; i++) out.set(sorted[i].id, i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||||
* instead of a docked column.
|
* instead of a docked column.
|
||||||
@@ -4648,11 +4713,21 @@ class CodemanApp {
|
|||||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||||
// the DOM and the loop below runs for every session on every SSE tick.
|
// the DOM and the loop below runs for every session on every SSE tick.
|
||||||
const richRows = this.isRichTabRows();
|
const richRows = this.isRichTabRows();
|
||||||
|
// Sorted vertical rail: a state change moves a card, and this is the path
|
||||||
|
// that sees one — a session going working→idle never adds or removes a
|
||||||
|
// tab, so the full rebuild below is not reached. Recomputed per pass for
|
||||||
|
// the same reason the rich meta line is: the order IS the state.
|
||||||
|
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
|
||||||
// Incremental update - only modify changed properties
|
// Incremental update - only modify changed properties
|
||||||
for (const [id, session] of this.sessions) {
|
for (const [id, session] of this.sessions) {
|
||||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||||
if (!tab) continue;
|
if (!tab) continue;
|
||||||
|
|
||||||
|
// An empty string clears the property, which is also what un-sorts the
|
||||||
|
// rail when the setting (or the layout) flips without a full rebuild.
|
||||||
|
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
|
||||||
|
if (tab.style.order !== railOrder) tab.style.order = railOrder;
|
||||||
|
|
||||||
// A web tab owns the active state while one is open. activeSessionId stays
|
// A web tab owns the active state while one is open. activeSessionId stays
|
||||||
// set (the terminal keeps streaming underneath, and switching back is
|
// set (the terminal keeps streaming underneath, and switching back is
|
||||||
// instant): only the highlight moves. Without this the debounced render
|
// instant): only the highlight moves. Without this the debounced render
|
||||||
@@ -4959,10 +5034,17 @@ class CodemanApp {
|
|||||||
// Read once, not per session: isRichTabRows() touches the DOM and
|
// Read once, not per session: isRichTabRows() touches the DOM and
|
||||||
// this loop runs for every tab on every full rebuild.
|
// this loop runs for every tab on every full rebuild.
|
||||||
const richRows = this.isRichTabRows();
|
const richRows = this.isRichTabRows();
|
||||||
|
// The sorted vertical rail (tabRailSort) moves cards with the flex `order`
|
||||||
|
// property and leaves this loop iterating tab order, so the Alt+N badge
|
||||||
|
// below still counts the strip, not the sorted list. Null in every other
|
||||||
|
// layout, and the tabs then carry no inline order at all — the header
|
||||||
|
// strip's markup is byte-identical to before.
|
||||||
|
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||||
let _tabIdx = 0;
|
let _tabIdx = 0;
|
||||||
for (const id of tabOrder) {
|
for (const id of tabOrder) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
if (!session) continue; // Skip if session was removed
|
if (!session) continue; // Skip if session was removed
|
||||||
|
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
|
||||||
|
|
||||||
// See the note in the incremental path: a web tab owns the active highlight
|
// See the note in the incremental path: a web tab owns the active highlight
|
||||||
// while one is open, even though activeSessionId stays set.
|
// while one is open, even though activeSessionId stays set.
|
||||||
@@ -5016,7 +5098,7 @@ class CodemanApp {
|
|||||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
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>`;
|
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>`;
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} 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(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${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(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
@@ -5227,6 +5309,17 @@ class CodemanApp {
|
|||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||||
|
|
||||||
|
// A self-sorting list cannot also be hand-ordered: the drop below rewrites
|
||||||
|
// sessionOrder correctly, the sort then puts the card straight back where it
|
||||||
|
// was, and the user is left dragging a row that refuses to move. Drop the
|
||||||
|
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||||
|
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||||
|
// order this list is no longer showing.
|
||||||
|
if (this.isTabRailSorted()) {
|
||||||
|
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.setAttribute('draggable', 'true');
|
tab.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,13 @@ const LINEAGE_DIP_MAX_PX = 64;
|
|||||||
// apart bled into one thick band instead of reading as three separate lines.
|
// apart bled into one thick band instead of reading as three separate lines.
|
||||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
|
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||||
|
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||||
|
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||||
|
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||||
|
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||||
|
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||||
|
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||||
here and boot will contradict this value, animating the drawer open by
|
here and boot will contradict this value, animating the drawer open by
|
||||||
itself on every load between 768 and 1023px. -->
|
itself on every load between 768 and 1023px. -->
|
||||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
|
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
<style>
|
<style>
|
||||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||||
@@ -1944,6 +1944,16 @@
|
|||||||
<option value="simple">Simple (name only)</option>
|
<option value="simple">Simple (name only)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Vertical Rail Order</span>
|
||||||
|
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsTabRailSort" class="set-select">
|
||||||
|
<option value="activity">By activity (home screen order)</option>
|
||||||
|
<option value="manual">Manual (drag to reorder)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Vertical Rail Width</span>
|
<span class="set-row-label">Vertical Rail Width</span>
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.syncTabRailWidthSetting?.(tabRailWidth);
|
this.syncTabRailWidthSetting?.(tabRailWidth);
|
||||||
document.getElementById('appSettingsTabRailDetail').value =
|
document.getElementById('appSettingsTabRailDetail').value =
|
||||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||||
|
document.getElementById('appSettingsTabRailSort').value =
|
||||||
|
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||||
document.getElementById('appSettingsSessionListLayout').value =
|
document.getElementById('appSettingsSessionListLayout').value =
|
||||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||||
@@ -2097,6 +2099,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||||
|
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||||
@@ -2500,6 +2503,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabOrientation: 'horizontal',
|
tabOrientation: 'horizontal',
|
||||||
tabRailWidth: 256,
|
tabRailWidth: 256,
|
||||||
tabRailDetail: 'rich',
|
tabRailDetail: 'rich',
|
||||||
|
tabRailSort: 'activity',
|
||||||
sessionListLayout: 'header',
|
sessionListLayout: 'header',
|
||||||
sessionSidebarFontSize: 12,
|
sessionSidebarFontSize: 12,
|
||||||
cjkInputEnabled: false,
|
cjkInputEnabled: false,
|
||||||
@@ -2766,6 +2770,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
||||||
root.dataset.tabRailDetail = detail;
|
root.dataset.tabRailDetail = detail;
|
||||||
|
|
||||||
|
// Row ORDER rides on a third attribute, for the same reason detail rides on
|
||||||
|
// its own: a sort flip leaves orientation on 'vertical' both times, and the
|
||||||
|
// order is applied as an inline `order` the render paths emit, not by CSS
|
||||||
|
// that could just re-match. `isTabRailSorted()` (app.js) reads this.
|
||||||
|
const previousSort = root.dataset.tabRailSort || 'activity';
|
||||||
|
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||||
|
root.dataset.tabRailSort = sort;
|
||||||
|
|
||||||
const tabsEl = document.getElementById('sessionTabs');
|
const tabsEl = document.getElementById('sessionTabs');
|
||||||
const rail = document.getElementById('tabRail');
|
const rail = document.getElementById('tabRail');
|
||||||
const headerHost = document.getElementById('sessionTabsHost');
|
const headerHost = document.getElementById('sessionTabsHost');
|
||||||
@@ -2789,7 +2801,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||||
// detail half in applySessionListLayout(). Taller rows also move every
|
// detail half in applySessionListLayout(). Taller rows also move every
|
||||||
// connector anchored to a tab rect.
|
// connector anchored to a tab rect.
|
||||||
const changed = orientationChanged || previousDetail !== detail;
|
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||||
if (orientationChanged) {
|
if (orientationChanged) {
|
||||||
this.updateTabOverflowMode?.();
|
this.updateTabOverflowMode?.();
|
||||||
if (!settleRailWidth) this.fitAddon?.fit();
|
if (!settleRailWidth) this.fitAddon?.fit();
|
||||||
@@ -3062,7 +3074,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
'terminalFontFamily',
|
'terminalFontFamily',
|
||||||
'language',
|
'language',
|
||||||
|
|||||||
@@ -17754,6 +17754,172 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
|||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Detailed rail rows wear the HOME SCREEN's card language -------------- */
|
||||||
|
/* The desktop home rail (.home-sessions, home-sessions.js) and this rail list
|
||||||
|
the same sessions with the same three lines — name, project, "created 3d ago
|
||||||
|
· working 12m" + pill — so they now LOOK the same too: a bordered card per
|
||||||
|
session instead of a flat strip row, a 9px state dot (pulsing and ringed
|
||||||
|
while the session works), the name at full weight, and the stamps line
|
||||||
|
wrapped onto its own full-width row with the pill parked at its right end.
|
||||||
|
|
||||||
|
⚠ RAIL ONLY, never the shared comma-grouped selectors above. The detailed
|
||||||
|
SIDEBAR draws the same meta line and is deliberately left flat: it is a
|
||||||
|
permanently-docked navigation column that sits next to the terminal all day,
|
||||||
|
and 20 stacked cards there read as a wall. Widening one of those grouped
|
||||||
|
rules instead of adding this block is what would silently restyle it.
|
||||||
|
|
||||||
|
⚠ The paint rules here are (0,5,1)-(0,6,1), so they outrank the plain
|
||||||
|
`.session-tab .tab-status.idle` class rules (0,2,0) — which is the point —
|
||||||
|
but they must NOT outrank the alert rules that turn a dot red or yellow when
|
||||||
|
a session is blocked on a human. Those are (0,3,0), so every dot rule below
|
||||||
|
excludes the two alert classes by hand rather than relying on the cascade.
|
||||||
|
State and alert normally agree (_mobileOverviewState reads the same pending
|
||||||
|
hooks the alert does), so this is a guard against them drifting, not a fix
|
||||||
|
for a known disagreement. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tabs {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||||
|
/* The stamps line rides a wrapped full-width row, exactly as on the home rail:
|
||||||
|
that hands the whole width of the card to the session name, which is what
|
||||||
|
stops a `w34-claudeman: mindreading` ellipsizing into `w34-claudeman: …`. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The name is what tells one card from another, so it carries the weight the
|
||||||
|
home rail gives it rather than the strip's dim 0.75rem label. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
/* One step up from the shared vertical-list size, which is 12px because that
|
||||||
|
is what the SIDEBAR has always been; a card row has the height for the
|
||||||
|
~13px the home rail renders at. Still driven by the same setting (App
|
||||||
|
Settings → Session Name Font Size), so it moves with it. */
|
||||||
|
font-size: calc(var(--session-sidebar-name-font-size, 12px) + 1px);
|
||||||
|
/* Three lines rather than two: the card is wrapped, so a long name costs
|
||||||
|
height instead of pushing the pill or the stamps off the row. */
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The state line gets the full width of the card (`flex: 0 0 100%` wraps it),
|
||||||
|
so the stamps stop competing with the gear/close column for the sliver of
|
||||||
|
room left beside the name. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bigger than the strip's 6px pip, because a card row has the room and the dot
|
||||||
|
is the one thing on it readable at a glance. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle is green, but a MUTED green — same mix the home rail uses, for the same
|
||||||
|
reason: a glance down the rail must separate "running right now" from
|
||||||
|
"sitting there" without reading a word. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||||
|
.tab-rail
|
||||||
|
.session-tab.tab-state-idle:not(.tab-alert-action):not(.tab-alert-idle)
|
||||||
|
.tab-status {
|
||||||
|
background: color-mix(in srgb, var(--green) 42%, var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||||
|
.tab-rail
|
||||||
|
.session-tab.tab-state-done:not(.tab-alert-action):not(.tab-alert-idle)
|
||||||
|
.tab-status {
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The working halo. The orbiting ring is the shared `.tab-status.busy::after`
|
||||||
|
already declared above — only its inset moves, to clear the wider dot. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||||
|
.tab-rail
|
||||||
|
.session-tab.tab-state-working:not(.tab-alert-action):not(.tab-alert-idle)
|
||||||
|
.tab-status {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||||
|
.tab-rail
|
||||||
|
.session-tab
|
||||||
|
.tab-status.busy::after {
|
||||||
|
inset: -4px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card accents: the same three colours as every other session surface, and the
|
||||||
|
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
|
||||||
|
on top of this for the sessions that are genuinely blocked on a human; these
|
||||||
|
border accents are the calmer, always-on half. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-needs,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-error {
|
||||||
|
border-color: color-mix(in srgb, var(--red) 50%, transparent);
|
||||||
|
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-waiting {
|
||||||
|
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
|
||||||
|
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working {
|
||||||
|
border-color: color-mix(in srgb, var(--green) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No `.active` rule here on purpose: `.session-tab.active` (and its per-skin
|
||||||
|
twin) already paints background, border and box-shadow with !important, so a
|
||||||
|
card rule would be dead weight that reads as if it were doing something. The
|
||||||
|
selected card keeps the accent its skin gives every selected tab, which is
|
||||||
|
what makes it legible against the card background the state accents above
|
||||||
|
paint on. */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Activity-sorted rail (tabRailSort) ---------------------------------- */
|
||||||
|
/* Visual order only: the DOM stays in the user's tab order, so drag-reorder,
|
||||||
|
Alt+N badges, the arrow-key walk and the sidebar filter all keep reading the
|
||||||
|
list they always read. `order` is set inline per tab by the render paths
|
||||||
|
(app.js `_tabRailSortOrder`).
|
||||||
|
|
||||||
|
Web tabs carry no session state to sort by and keep their place at the end of
|
||||||
|
the list, so they are pinned past every session card rather than being given
|
||||||
|
an inline order of their own — `renderWebviewTabs()` emits the same markup for
|
||||||
|
every layout and must stay that way. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {
|
||||||
|
order: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Collapsed rail ---------------------------------------------------- */
|
/* --- Collapsed rail ---------------------------------------------------- */
|
||||||
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
||||||
task/subagent/ultracode badges) is the whole point of mission control and
|
task/subagent/ultracode badges) is the whole point of mission control and
|
||||||
|
|||||||
@@ -1255,6 +1255,15 @@ export const SettingsUpdateSchema = z
|
|||||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||||
|
/**
|
||||||
|
* Vertical rail row order. Display key (per-device).
|
||||||
|
* 'activity' = the home screens' order (CodemanSessionOrder): blocked on a
|
||||||
|
* human first, then running longest-first, then quiet
|
||||||
|
* most-recently-quiet first.
|
||||||
|
* 'manual' = the user's tab order, and the only value that leaves the
|
||||||
|
* rail drag-reorderable.
|
||||||
|
*/
|
||||||
|
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||||
/**
|
/**
|
||||||
* Session list layout. Display key (per-device).
|
* Session list layout. Display key (per-device).
|
||||||
* 'header' = horizontal tab strip
|
* 'header' = horizontal tab strip
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The vertical tab rail's row ORDER and its card styling.
|
||||||
|
*
|
||||||
|
* The rail lists exactly the sessions both home screens list (the phone
|
||||||
|
* overview and the desktop home rail), so it now answers their question the
|
||||||
|
* same way: `CodemanSessionOrder` (constants.js) puts whatever is blocked on a
|
||||||
|
* human first, then whatever has been running longest, then the most recently
|
||||||
|
* quiet. Three things about that can go wrong silently and are pinned here.
|
||||||
|
*
|
||||||
|
* 1. THE ROW MODEL. The comparator reads `state` plus two raw stamps, and a
|
||||||
|
* WORKING row is ranked by `lastSubmitAt` rather than `lastActivityAt`
|
||||||
|
* (a working pane repaints about once a second, so its last-activity stamp
|
||||||
|
* is always "now"). Dropping `lastSubmitAt` from the row would not throw and
|
||||||
|
* would not fail a rendering test — every running turn would just quietly
|
||||||
|
* rank as freshly started.
|
||||||
|
*
|
||||||
|
* 2. THE ALT+N BADGE. The sort is applied as the flex `order` property while
|
||||||
|
* the DOM stays in `sessionOrder`, which is what keeps the number badge
|
||||||
|
* honest: it names the Alt+N key, not the row's position, so it deliberately
|
||||||
|
* does NOT run 1,2,3 down a sorted rail.
|
||||||
|
*
|
||||||
|
* 3. THE OPT-OUT. A self-sorting list cannot also be drag-reorderable, so the
|
||||||
|
* drag affordance is dropped while sorting is on and `tabRailSort: 'manual'`
|
||||||
|
* is the way back. That decision lives in one place and must stay wired to
|
||||||
|
* the attribute the render paths read.
|
||||||
|
*
|
||||||
|
* Port: none (vm-loaded app.js + static source/markup assertions).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||||
|
const settingsUi = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
|
||||||
|
const styles = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||||
|
|
||||||
|
/** The rail's rich-row scope, spelled exactly as styles.css must spell it. */
|
||||||
|
const RAIL_RICH = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
|
||||||
|
|
||||||
|
/** The <html> element the vm's app.js reads. Mutable, so the REAL gate runs. */
|
||||||
|
type Root = { dataset: Record<string, string>; getAttribute: (name: string) => string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load app.js + mobile-overview.js in one vm context, so `_tabRailSortOrder()`
|
||||||
|
* runs against the REAL `_mobileOverviewState()` rather than a copy of it that
|
||||||
|
* could drift. Same stubbing technique as session-close-fallback.test.ts, plus a
|
||||||
|
* writable `document.documentElement`: `isTabRailSorted()` reads the layout off
|
||||||
|
* <html>, and stubbing THAT out on the instance would leave the shipped gate
|
||||||
|
* untested while these assertions kept passing.
|
||||||
|
*/
|
||||||
|
function loadCodemanAppClass(): { CodemanApp: new () => unknown; root: Root } {
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
|
const root: Root = { dataset: {}, getAttribute: (name: string) => attrs[name] ?? null };
|
||||||
|
Object.defineProperty(root, '__attrs', { value: attrs });
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document: {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelector: () => null,
|
||||||
|
documentElement: root,
|
||||||
|
},
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
|
});
|
||||||
|
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('mobile-overview.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return { CodemanApp: (context as { __CodemanApp: new () => unknown }).__CodemanApp, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CodemanApp, root: railRoot } = loadCodemanAppClass();
|
||||||
|
|
||||||
|
/** Point the shared <html> stub at one rail configuration. */
|
||||||
|
function setLayout(attrs: Record<string, string>): void {
|
||||||
|
const raw = (railRoot as unknown as { __attrs: Record<string, string> }).__attrs;
|
||||||
|
for (const key of Object.keys(raw)) delete raw[key];
|
||||||
|
for (const key of Object.keys(railRoot.dataset)) delete railRoot.dataset[key];
|
||||||
|
raw['data-tab-orientation'] = attrs['data-tab-orientation'] ?? 'vertical';
|
||||||
|
railRoot.dataset.tabRailSort = attrs.tabRailSort ?? 'activity';
|
||||||
|
railRoot.dataset.tabRailDetail = attrs.tabRailDetail ?? 'rich';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
lastSubmitAt?: number;
|
||||||
|
hooks?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RailApp = {
|
||||||
|
isTabRailSorted: () => boolean;
|
||||||
|
_tabRailSortOrder: (ids: string[]) => Map<string, number> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance with the two inputs the order needs real (`sessions`,
|
||||||
|
* `pendingHooks`); the rail gate answers from the shared <html> stub, so the
|
||||||
|
* shipped `isTabRailSorted()` is what decides.
|
||||||
|
*/
|
||||||
|
function makeApp(rows: Row[], attrs: Record<string, string> = {}): RailApp {
|
||||||
|
setLayout(attrs);
|
||||||
|
const app = Object.create((CodemanApp as { prototype: object }).prototype) as RailApp & Record<string, unknown>;
|
||||||
|
app.sessions = new Map(rows.map((r) => [r.id, { ...r, mode: 'claude' }]));
|
||||||
|
app.pendingHooks = new Map(rows.filter((r) => r.hooks).map((r) => [r.id, new Set(r.hooks)]));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual positions keyed by id, for readable assertions. */
|
||||||
|
function positions(app: RailApp, ids: string[]): Record<string, number> | null {
|
||||||
|
const map = app._tabRailSortOrder(ids);
|
||||||
|
if (!map) return null;
|
||||||
|
return Object.fromEntries(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
|
const minsAgo = (m: number) => NOW - m * 60_000;
|
||||||
|
|
||||||
|
describe('vertical tab rail row order', () => {
|
||||||
|
it('puts a blocked session first, longest-blocked ahead of the newest block', () => {
|
||||||
|
const ids = ['fresh-block', 'old-block', 'quiet'];
|
||||||
|
const app = makeApp([
|
||||||
|
{ id: 'fresh-block', status: 'idle', lastActivityAt: minsAgo(1), hooks: ['permission_prompt'] },
|
||||||
|
{ id: 'old-block', status: 'idle', lastActivityAt: minsAgo(30), hooks: ['permission_prompt'] },
|
||||||
|
{ id: 'quiet', status: 'idle', lastActivityAt: minsAgo(2) },
|
||||||
|
]);
|
||||||
|
expect(positions(app, ids)).toEqual({ 'old-block': 0, 'fresh-block': 1, quiet: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks a running session by its last Enter, not by the repaint it just did', () => {
|
||||||
|
// Both panes printed a byte a moment ago, which is what a working pane does
|
||||||
|
// about once a second. Only lastSubmitAt says which turn actually started
|
||||||
|
// first, so reading lastActivityAt here would call this a tie and fall
|
||||||
|
// through to tab order — silently, and in the wrong direction.
|
||||||
|
const ids = ['short-turn', 'long-turn'];
|
||||||
|
const app = makeApp([
|
||||||
|
{ id: 'short-turn', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(2) },
|
||||||
|
{ id: 'long-turn', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(40) },
|
||||||
|
]);
|
||||||
|
expect(positions(app, ids)).toEqual({ 'long-turn': 0, 'short-turn': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips the tiebreak for quiet sessions: most recently quiet first', () => {
|
||||||
|
const ids = ['yesterday', 'just-finished'];
|
||||||
|
const app = makeApp([
|
||||||
|
{ id: 'yesterday', status: 'idle', lastActivityAt: minsAgo(1440) },
|
||||||
|
{ id: 'just-finished', status: 'idle', lastActivityAt: minsAgo(1) },
|
||||||
|
]);
|
||||||
|
expect(positions(app, ids)).toEqual({ 'just-finished': 0, yesterday: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders the whole fleet blocked → running → quiet', () => {
|
||||||
|
const ids = ['idle-a', 'working', 'needs', 'waiting', 'idle-b'];
|
||||||
|
const app = makeApp([
|
||||||
|
{ id: 'idle-a', status: 'idle', lastActivityAt: minsAgo(2) },
|
||||||
|
{ id: 'working', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(12) },
|
||||||
|
{ id: 'needs', status: 'idle', lastActivityAt: minsAgo(5), hooks: ['permission_prompt'] },
|
||||||
|
{ id: 'waiting', status: 'idle', lastActivityAt: minsAgo(3), hooks: ['idle_prompt'] },
|
||||||
|
{ id: 'idle-b', status: 'idle', lastActivityAt: minsAgo(90) },
|
||||||
|
]);
|
||||||
|
expect(positions(app, ids)).toEqual({ needs: 0, waiting: 1, working: 2, 'idle-a': 3, 'idle-b': 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no order at all when the rail is horizontal or set to manual', () => {
|
||||||
|
const rows: Row[] = [{ id: 'a', status: 'idle', lastActivityAt: minsAgo(1) }];
|
||||||
|
const horizontal = makeApp(rows, { 'data-tab-orientation': 'horizontal' });
|
||||||
|
expect(horizontal.isTabRailSorted()).toBe(false);
|
||||||
|
expect(horizontal._tabRailSortOrder(['a'])).toBeNull();
|
||||||
|
const manual = makeApp(rows, { tabRailSort: 'manual' });
|
||||||
|
expect(manual.isTabRailSorted()).toBe(false);
|
||||||
|
expect(manual._tabRailSortOrder(['a'])).toBeNull();
|
||||||
|
expect(makeApp(rows).isTabRailSorted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts a SIMPLE rail too — it lists the same sessions, it just says less', () => {
|
||||||
|
const ids = ['quiet', 'blocked'];
|
||||||
|
const app = makeApp(
|
||||||
|
[
|
||||||
|
{ id: 'quiet', status: 'idle', lastActivityAt: minsAgo(1) },
|
||||||
|
{ id: 'blocked', status: 'idle', lastActivityAt: minsAgo(9), hooks: ['permission_prompt'] },
|
||||||
|
],
|
||||||
|
{ tabRailDetail: 'simple' }
|
||||||
|
);
|
||||||
|
expect(positions(app, ids)).toEqual({ blocked: 0, quiet: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
|
||||||
|
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||||
|
// moves the card — so the badge names a shortcut, not a row position.
|
||||||
|
expect(appJs).toContain(
|
||||||
|
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||||
|
);
|
||||||
|
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||||
|
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||||
|
// The loop itself still walks the user's order, which is what makes the
|
||||||
|
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||||
|
expect(appJs).toContain('const tabOrder = this.sessionOrder;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-applies the order from the incremental path, since a state change adds no tab', () => {
|
||||||
|
// A session going working→idle never adds or removes a tab, so the full
|
||||||
|
// rebuild is not reached — and a rebuild here would restart every card's
|
||||||
|
// animation on every SSE tick anyway.
|
||||||
|
expect(appJs).toContain(
|
||||||
|
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||||
|
);
|
||||||
|
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
|
||||||
|
// An empty string is what clears the property when the rail stops sorting.
|
||||||
|
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
|
||||||
|
const drag = appJs.slice(appJs.indexOf('setupTabDragHandlers() {'));
|
||||||
|
expect(drag.indexOf('if (this.isTabRailSorted()) {')).toBeLessThan(
|
||||||
|
drag.indexOf("tab.setAttribute('draggable', 'true')")
|
||||||
|
);
|
||||||
|
expect(drag).toContain("tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies and compares through the shared helpers, never a local copy', () => {
|
||||||
|
const fn = appJs.slice(appJs.indexOf('_tabRailSortOrder(ids) {'), appJs.indexOf('_tabRailSortOrder(ids) {') + 1200);
|
||||||
|
expect(fn).toContain('window.CodemanSessionOrder.sort(rows)');
|
||||||
|
expect(fn).toContain('this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]))');
|
||||||
|
// Both raw stamps, or the comparator silently ranks every running turn as
|
||||||
|
// freshly started (see the file header).
|
||||||
|
expect(fn).toContain('lastActivityAt:');
|
||||||
|
expect(fn).toContain('lastSubmitAt:');
|
||||||
|
expect(fn).toContain('orderIndex: i');
|
||||||
|
// Degrades to tab order rather than throwing when a cached constants.js or
|
||||||
|
// mobile-overview.js is stale.
|
||||||
|
expect(fn).toContain("!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vertical tab rail sort setting', () => {
|
||||||
|
it('accepts only the two documented values', () => {
|
||||||
|
for (const v of ['activity', 'manual']) {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ tabRailSort: v }).success).toBe(true);
|
||||||
|
}
|
||||||
|
for (const v of ['', 'auto', 'Activity', 1, true]) {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ tabRailSort: v }).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a per-device display key with a control the load/save path can find', () => {
|
||||||
|
expect(settingsUi).toContain("'tabRailSort'");
|
||||||
|
expect(settingsUi).toContain("tabRailSort: 'activity',");
|
||||||
|
expect(settingsUi).toContain("document.getElementById('appSettingsTabRailSort').value");
|
||||||
|
expect(settingsUi).toContain("tabRailSort: document.getElementById('appSettingsTabRailSort').value,");
|
||||||
|
expect(html).toContain('id="appSettingsTabRailSort"');
|
||||||
|
expect(html).toContain('<option value="activity">');
|
||||||
|
expect(html).toContain('<option value="manual">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the attribute before first paint and on every settings apply', () => {
|
||||||
|
// Without the pre-paint stamp the rail renders unsorted for a frame and
|
||||||
|
// then reshuffles, which is exactly the flash the other two rail attributes
|
||||||
|
// are stamped there to avoid.
|
||||||
|
expect(html).toContain(
|
||||||
|
"document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';"
|
||||||
|
);
|
||||||
|
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||||
|
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||||
|
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||||
|
// count as a change of its own or nothing re-renders.
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vertical tab rail card styling', () => {
|
||||||
|
it('gives detailed rail rows the home screen card, and only the rail', () => {
|
||||||
|
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||||
|
expect(block).toContain(`${RAIL_RICH} .session-tab {`);
|
||||||
|
for (const decl of ['border-radius: 10px;', 'background: var(--bg-card);', 'flex-wrap: wrap;']) {
|
||||||
|
expect(block.slice(0, 2000)).toContain(decl);
|
||||||
|
}
|
||||||
|
// The detailed SIDEBAR shares the meta line and must stay flat: every rule
|
||||||
|
// in the card block is rail-scoped, never added to the comma-grouped
|
||||||
|
// selectors that carry both surfaces.
|
||||||
|
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||||
|
expect(cardBlock).not.toContain('.session-sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('states read in the same three colours as both home screens', () => {
|
||||||
|
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||||
|
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||||
|
expect(cardBlock).toContain('.session-tab.tab-state-needs');
|
||||||
|
expect(cardBlock).toContain('.session-tab.tab-state-waiting');
|
||||||
|
expect(cardBlock).toContain('.session-tab.tab-state-working');
|
||||||
|
// Reuses the home rail's keyframes rather than declaring a second pair that
|
||||||
|
// could drift out of step with it.
|
||||||
|
expect(cardBlock).toContain('home-sessions-blink-red');
|
||||||
|
expect(cardBlock).toContain('home-sessions-blink-yellow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a state dot outrank the red/yellow alert dot', () => {
|
||||||
|
// The dot rules are (0,5,1)+; the alert rules that mark a session blocked on
|
||||||
|
// a human are only (0,3,0), so each state rule excludes them by hand.
|
||||||
|
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||||
|
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||||
|
const dotRules = cardBlock.match(/\.session-tab\.tab-state-\w+[^{]*\.tab-status\b/g) ?? [];
|
||||||
|
expect(dotRules.length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const rule of dotRules) {
|
||||||
|
expect(rule).toContain(':not(.tab-alert-action):not(.tab-alert-idle)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins web tabs past the sorted cards instead of interleaving them at order 0', () => {
|
||||||
|
expect(styles).toContain(
|
||||||
|
"html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user