mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
feat(tabs,header): group tabs by state and add header stats styles (#426)
Two directions from Discussion #426, each a per-device setting and the new default. Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C): tabs are grouped needs you (red, plus failed sessions), waiting (yellow), working and idle (also ended, exited panes and web tabs), most urgent on top. The desktop header strip draws a row per group with its label and count in a left gutter; the flat vertical rail and the sidebar draw a section per group; tablets keep their scrolling row with inline dividers; phones keep the chip row in group order without headings. Classification is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the fold into four groups is pure in CodemanTabTriage (constants.js). It is flex `order` plus aria-hidden heading/break elements reconciled in place after both render paths, never a DOM reorder, so Alt+N, the keyboard walk and drag keep reading tab order; a drop is refused across groups. Named groups in the vertical rail take precedence. Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles', default 'tiles', option G): tiles give WS, CPU, MEM and each plan window a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM pill with sparklines plus a plan-ring pill; classic is the header as before. Desktop only (classic below 768px and in solo windows). The clustered styles move #connectionIndicator into #headerSystemStats and WS stays out of a hidden System Stats pill. The extra parts are always rendered and hidden by default in CSS, so classic is unchanged. Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three source pins in test/tab-rail-order.test.ts follow renamed lines. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+204
-8
@@ -3229,14 +3229,20 @@ class CodemanApp {
|
||||
// broken feature rather than as an idle window (reported 2026-09-01). A
|
||||
// missing CODEX bucket means the opposite — that plan has no such limit —
|
||||
// so those stay omitted rather than showing a dash forever.
|
||||
// Every window also carries a ring (the Compact header style) and a meter
|
||||
// (Tiles). styles.css hides both in the classic style, so the chip there
|
||||
// reads exactly as before. `fill` is clamped for the two graphics only; the
|
||||
// label keeps the real number.
|
||||
const seg = (label, p, idle) => {
|
||||
if (p === null) {
|
||||
if (!idle) return '';
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-ring" style="--pu:0"></span><span class="pu-label">${label}</span><span class="pu-val">—</span><span class="pu-meter"><i style="width:0%"></i></span></span>`;
|
||||
}
|
||||
const n = Math.round(Number(p));
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
const fill = Math.min(100, Math.max(0, n));
|
||||
const cls = colorClass(n);
|
||||
return `<span class="pu-win"><span class="pu-ring ${cls}" style="--pu:${fill}"></span><span class="pu-label">${label}</span><span class="pu-val ${cls}">${n}%</span><span class="pu-meter ${cls}"><i style="width:${fill}%"></i></span></span>`;
|
||||
};
|
||||
// The provider label only earns its space when there is more than one
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
@@ -4021,6 +4027,36 @@ class CodemanApp {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two words the Tiles header style shows for the connection indicator
|
||||
* (label over value), derived from the descriptor rather than added to it,
|
||||
* so the descriptor and its pinned strings stay exactly what they were. The
|
||||
* classic text line (queued bytes and all) stays in the DOM and the full
|
||||
* detail stays in the tooltip.
|
||||
* @param {{dotClass: string, text: string}} desc
|
||||
* @returns {{label: string, value: string, state: string}}
|
||||
*/
|
||||
_connectionTileWords(desc) {
|
||||
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
|
||||
const text = desc.text || '';
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return { label: 'WS', value: 'live', state };
|
||||
case 'fallback':
|
||||
return { label: 'HTTP', value: 'fallback', state };
|
||||
case 'offline':
|
||||
return { label: 'NET', value: 'offline', state };
|
||||
case 'draining':
|
||||
return { label: 'SEND', value: 'queued', state };
|
||||
case 'reconnecting':
|
||||
// The same dot covers the terminal WebSocket and, with no session
|
||||
// open, the SSE event stream; the classic text already tells them apart.
|
||||
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
|
||||
default:
|
||||
return { label: '', value: '', state };
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4048,6 +4084,14 @@ class CodemanApp {
|
||||
dot.className = next.dotClass;
|
||||
text.textContent = next.text;
|
||||
indicator.title = next.title;
|
||||
const tileLabel = this.$('connectionTileLabel');
|
||||
const tileValue = this.$('connectionTileValue');
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4752,6 +4796,128 @@ class CodemanApp {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tab list groups by state (`tabGrouping: 'state'`, the default;
|
||||
* Discussion #426 option C): a row per state in the header strip, a section
|
||||
* per state in the flat side rail and the sidebar, most urgent on top.
|
||||
*
|
||||
* Read off <html> like the rail gates (applyTabOrientation() owns the
|
||||
* attribute). Named groups in the vertical rail still win, because they are
|
||||
* the user's own structure: `_tabTriageLayout()` returns null while the
|
||||
* grouped projection is on, and the grouped tree renders as it always did.
|
||||
*/
|
||||
isTabTriage() {
|
||||
return document.documentElement.dataset.tabGrouping === 'state';
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one render pass, or null when the list
|
||||
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
|
||||
* (constants.js); this only feeds it the same classification both home
|
||||
* screens and the sorted rail use.
|
||||
*
|
||||
* Inside a group a row keeps its tab order on the header strip, so the strip
|
||||
* only moves a tab when its state changes. A sorted rail ranks rows inside
|
||||
* each section the way it ranks the whole flat rail (`railSortOrder`).
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
|
||||
*/
|
||||
_tabTriageLayout(ids, groupProjection, railSortOrder) {
|
||||
if (groupProjection || !this.isTabTriage()) return null;
|
||||
if (!window.CodemanTabTriage || 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;
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
|
||||
rows.push({
|
||||
id: ids[i],
|
||||
state,
|
||||
exited: !!this._mobileOverviewExit?.(state, session),
|
||||
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
|
||||
});
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the state headings, the row breaks and the web tabs' `order` in step
|
||||
* with one pass's triage layout. Runs after BOTH render paths, because a
|
||||
* session changing state is an incremental pass (no tab is added or removed)
|
||||
* and can still empty a group or fill a new one.
|
||||
*
|
||||
* Headings and breaks are keyed by group and reconciled in place, never
|
||||
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
|
||||
* direct children of #sessionTabs placed purely by `order`, so where they sit
|
||||
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
|
||||
* whose children must all be tabs. (A tab's state is not announced either way:
|
||||
* its status dot is aria-hidden, as before.) With `triage` null this removes
|
||||
* them all and clears the web tabs' inline order, which is what leaves the
|
||||
* ungrouped strip exactly as it was.
|
||||
*/
|
||||
_syncTabTriageChrome(container, triage) {
|
||||
if (!container) return;
|
||||
container.classList.toggle('tabs-triage', !!triage);
|
||||
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
|
||||
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
|
||||
if (!wanted.has(el.dataset.triageGroup)) el.remove();
|
||||
}
|
||||
const ensure = (className, key) => {
|
||||
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
|
||||
el.dataset.triageGroup = key;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
container.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
for (const group of wanted.values()) {
|
||||
const head = ensure('tab-triage-head', group.key);
|
||||
if (!head.firstElementChild) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tab-triage-label';
|
||||
label.textContent = group.label;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tab-triage-count';
|
||||
head.append(label, count);
|
||||
}
|
||||
const count = String(group.count);
|
||||
if (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
|
||||
const headOrder = String(group.headOrder);
|
||||
if (head.style.order !== headOrder) head.style.order = headOrder;
|
||||
const brk = ensure('tab-triage-break', group.key);
|
||||
const breakOrder = String(group.breakOrder);
|
||||
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
|
||||
}
|
||||
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
|
||||
const wid = web.dataset.webviewId;
|
||||
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
|
||||
if (web.style.order !== value) web.style.order = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a state-grouped strip may only reorder WITHIN a group. Inside a
|
||||
* group the rows sit in tab order, so a drop there moves the tab exactly where
|
||||
* it was dropped; across groups the dragged tab would stay in its own group
|
||||
* (its state did not change) and land somewhere the user did not put it.
|
||||
* Groups are bands of `order` values, so comparing bands is enough.
|
||||
*/
|
||||
_isTabDropAcrossTriageGroups(targetTab) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
|
||||
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
|
||||
if (!dragged) return false;
|
||||
const stride = window.CodemanTabTriage?.STRIDE || 10000;
|
||||
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
|
||||
return band(dragged) !== band(targetTab);
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -5004,6 +5170,9 @@ class CodemanApp {
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', !!needle);
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
@@ -5425,7 +5594,13 @@ class CodemanApp {
|
||||
// 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)));
|
||||
const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state: same reasoning, a state change moves a tab between
|
||||
// rows. Its order values replace the rail sort's (which it already folded
|
||||
// in as the rank inside each section).
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
// Incremental update - only modify changed properties
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
@@ -5433,7 +5608,7 @@ class CodemanApp {
|
||||
|
||||
// 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)) : '';
|
||||
const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
|
||||
if (tab.style.order !== railOrder) tab.style.order = railOrder;
|
||||
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
@@ -5611,6 +5786,7 @@ class CodemanApp {
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5679,6 +5855,15 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped by state, the header strip IS rows (one per state), so it always
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. Narrower
|
||||
// screens keep the single scrolling row above, where the headings read as
|
||||
// inline dividers instead.
|
||||
if (container.classList.contains('tabs-triage')) {
|
||||
container.classList.add('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the natural one-row overflow, then enable wrapping only if needed.
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
|
||||
@@ -5771,7 +5956,14 @@ class CodemanApp {
|
||||
// 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)));
|
||||
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state (tabGrouping, the default): the same `order` mechanism,
|
||||
// one band of values per state. Null in the grouped rail and with grouping
|
||||
// off, and the rows then carry exactly the inline order they did before.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
@@ -5780,7 +5972,7 @@ class CodemanApp {
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
|
||||
const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
|
||||
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
@@ -5862,7 +6054,6 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
@@ -5897,6 +6088,7 @@ class CodemanApp {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5971,7 +6163,7 @@ class CodemanApp {
|
||||
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
|
||||
// rather than an inline style) read as 0 and the walk starts on them. Array
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
}
|
||||
@@ -7172,6 +7364,9 @@ class CodemanApp {
|
||||
});
|
||||
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
// Grouped by state: a tab in another group is not a drop target, and
|
||||
// leaving the event alone (no preventDefault) is what shows "no drop".
|
||||
if (this._isTabDropAcrossTriageGroups(tab)) return;
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
@@ -7201,6 +7396,7 @@ class CodemanApp {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
if (this._isTabDropAcrossTriageGroups(tab)) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
@@ -658,6 +658,123 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Tab grouping by state (`tabGrouping: 'state'`, Discussion #426 option C).
|
||||
//
|
||||
// The tab list answers "who wants me?" the way the home screens do: a row (the
|
||||
// header strip) or a section (the flat side rail, the sidebar) per state, most
|
||||
// urgent on top. The states are the home screens' own, from
|
||||
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
|
||||
// four groups a strip can hold:
|
||||
//
|
||||
// needs red: a permission or question dialog is blocking the agent. A
|
||||
// failed session joins it, since it also needs a human and the home
|
||||
// screens rank it right below.
|
||||
// waiting yellow: the agent finished its turn and is waiting on you.
|
||||
// working a turn is running.
|
||||
// idle everything quiet: idle, ended, and an agent that exited inside a
|
||||
// live pane (#446), which may still read as working on screen but is
|
||||
// running nothing. Web tabs close the group.
|
||||
//
|
||||
// Applied as the flex `order` property, never by reordering the DOM, the same
|
||||
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
|
||||
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
|
||||
// the list they always read, and a state change moves one inline style instead
|
||||
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
|
||||
// values: its heading at the start of the band, its rows after it, its web tabs
|
||||
// after those and the line break that ends the header row at the very end.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
|
||||
const TAB_TRIAGE_GROUPS = [
|
||||
{ key: 'needs', label: 'Needs you' },
|
||||
{ key: 'waiting', label: 'Waiting' },
|
||||
{ key: 'working', label: 'Working' },
|
||||
{ key: 'idle', label: 'Idle' },
|
||||
];
|
||||
|
||||
const TAB_TRIAGE_GROUP_OF_STATE = {
|
||||
needs: 'needs',
|
||||
error: 'needs',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'idle',
|
||||
};
|
||||
|
||||
const TAB_TRIAGE_STRIDE = 10000;
|
||||
/** Offset of a group's web tabs inside its band, past any plausible session count. */
|
||||
const TAB_TRIAGE_WEB_OFFSET = 5000;
|
||||
|
||||
/**
|
||||
* Which group a session belongs to.
|
||||
* @param {string} state a `_mobileOverviewState()` value
|
||||
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
|
||||
* @returns {'needs'|'waiting'|'working'|'idle'}
|
||||
*/
|
||||
function tabTriageGroupFor(state, exited) {
|
||||
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
|
||||
return exited && group === 'working' ? 'idle' : group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one pass.
|
||||
*
|
||||
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
|
||||
* live sessions; `pos` ranks a row inside its group (tab order on the header
|
||||
* strip, the activity sort's position on a sorted rail). Rows without one keep
|
||||
* the order they were passed in.
|
||||
* @param {Array<string>} webviewIds open web tabs, in their own tab order
|
||||
* @returns {{
|
||||
* order: Map<string, number>,
|
||||
* webOrder: Map<string, number>,
|
||||
* groups: Array<{key: string, label: string, count: number, headOrder: number, breakOrder: number}>
|
||||
* }} `groups` lists only the non-empty groups, most urgent first.
|
||||
*/
|
||||
function computeTabTriageLayout(rows, webviewIds) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const webs = Array.isArray(webviewIds) ? webviewIds : [];
|
||||
const baseOf = {};
|
||||
const counts = {};
|
||||
TAB_TRIAGE_GROUPS.forEach((group, i) => {
|
||||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||||
counts[group.key] = 0;
|
||||
});
|
||||
|
||||
const placed = list
|
||||
.filter((row) => row && typeof row.id === 'string')
|
||||
.map((row, i) => ({
|
||||
id: row.id,
|
||||
group: tabTriageGroupFor(row.state, !!row.exited),
|
||||
pos: Number.isFinite(row.pos) ? row.pos : i,
|
||||
index: i,
|
||||
}));
|
||||
const byGroup = {};
|
||||
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
|
||||
|
||||
const order = new Map();
|
||||
for (const key of Object.keys(byGroup)) {
|
||||
// Stable: equal positions keep the order the caller passed.
|
||||
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
|
||||
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
|
||||
counts[key] = byGroup[key].length;
|
||||
}
|
||||
|
||||
const webOrder = new Map();
|
||||
webs.forEach((id, i) => {
|
||||
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
|
||||
});
|
||||
counts.idle += webOrder.size;
|
||||
|
||||
const groups = TAB_TRIAGE_GROUPS.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
count: counts[group.key],
|
||||
headOrder: baseOf[group.key],
|
||||
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
|
||||
}));
|
||||
|
||||
return { order, webOrder, groups };
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -983,6 +1100,12 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanTabTriage = {
|
||||
GROUPS: TAB_TRIAGE_GROUPS,
|
||||
STRIDE: TAB_TRIAGE_STRIDE,
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
Running: '运行中',
|
||||
Idle: '空闲',
|
||||
Working: '工作中',
|
||||
Waiting: '等待中',
|
||||
Today: '今天',
|
||||
Home: '主页',
|
||||
Local: '本地',
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
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
|
||||
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';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>
|
||||
<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';document.documentElement.dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';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';document.documentElement.dataset.tabGrouping='state';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -154,6 +154,8 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile-label" id="connectionTileLabel"></span>
|
||||
<span class="connection-tile-value" id="connectionTileValue"></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
@@ -166,6 +168,7 @@
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
||||
</div>
|
||||
<span class="stat-spark" id="statCpuSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||
<span class="stat-value" id="statCpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
@@ -173,6 +176,7 @@
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
||||
</div>
|
||||
<span class="stat-spark" id="statMemSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1918,6 +1922,17 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings sparkline">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Header Stats Style <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">How WS, CPU, MEM and the plan-usage windows are drawn. Compact is one system pill with sparklines plus usage rings; Tiles put each label over its value with a bar underneath.</span>
|
||||
</div>
|
||||
<select id="appSettingsHeaderStatsStyle" class="set-select">
|
||||
<option value="classic">As before (bars)</option>
|
||||
<option value="compact">Compact (pill + rings)</option>
|
||||
<option value="tiles">Tiles (default)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2080,6 +2095,16 @@
|
||||
<option value="vertical">Vertical (side rail)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab grouping group by state triage needs you waiting working idle rows sections">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Grouping</span>
|
||||
<span class="set-row-desc">By state puts what needs you on top: a row each for needs you (red), waiting (yellow), working and idle in the header, and the same sections in the side rail and sidebar. Tabs move between groups as their state changes; Alt+1..9 keeps the tab order. Named tab groups in the side rail take precedence.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabGrouping" class="set-select">
|
||||
<option value="state">By state (default)</option>
|
||||
<option value="none">None (tab order)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Rows</span>
|
||||
@@ -2093,7 +2118,7 @@
|
||||
<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>
|
||||
<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. With Tab Grouping by state it orders the rows inside each section. 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>
|
||||
|
||||
@@ -755,6 +755,14 @@ html.mobile-init .file-browser-panel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grouped by state (tabGrouping), the phone strip keeps its one scrolling
|
||||
row: the tabs still come in state order, most urgent first, but the
|
||||
headings would cost chips and the dots already say which state is which.
|
||||
The phone overview is where the labelled sections live. */
|
||||
:where(.header) .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
|
||||
@@ -6028,6 +6028,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Sparklines for the Compact header style. Always recorded (four numbers a
|
||||
// poll), so switching styles shows history at once instead of empty bars.
|
||||
this._pushStatSpark('cpu', stats.cpu);
|
||||
this._pushStatSpark('mem', stats.memory?.percent);
|
||||
|
||||
if (memEl && memBar) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
memEl.textContent = `${memGB}G`;
|
||||
@@ -6045,6 +6050,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Record one percentage sample and redraw that stat's sparkline: one bar per
|
||||
* sample, newest on the right, as many samples as the template has bars.
|
||||
* Bars stay 10% tall at minimum so an idle machine still draws a baseline.
|
||||
*/
|
||||
_pushStatSpark(key, percent) {
|
||||
const history = (this._statSparkHistory ||= { cpu: [], mem: [] })[key];
|
||||
if (!history) return;
|
||||
const el = this.$(key === 'cpu' ? 'statCpuSpark' : 'statMemSpark');
|
||||
const size = el?.children.length || 4;
|
||||
const value = Number(percent);
|
||||
history.push(Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0);
|
||||
while (history.length > size) history.shift();
|
||||
if (!el) return;
|
||||
const offset = size - history.length;
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
const sample = history[i - offset];
|
||||
el.children[i].style.height = sample === undefined ? '' : `${Math.max(10, Math.round(sample))}%`;
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _onClipboardWrite(data) {
|
||||
|
||||
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
@@ -500,6 +501,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsTabGrouping').value =
|
||||
(settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -2475,6 +2478,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
@@ -2524,6 +2528,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
tabGrouping: document.getElementById('appSettingsTabGrouping').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -3448,6 +3453,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
tabGrouping: 'state',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -3549,6 +3555,63 @@ Object.assign(CodemanApp.prototype, {
|
||||
return now === before ? undefined : now;
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored header-stats style, or the default. Anything but the three
|
||||
* known values (an absent key, a value from a newer build) reads as 'tiles',
|
||||
* the default (the tile variant of Discussion #426's option G).
|
||||
*/
|
||||
resolveHeaderStatsStyle(settings) {
|
||||
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
|
||||
return value === 'classic' || value === 'compact' ? value : 'tiles';
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a header-stats style: the `data-header-stats` attribute every rule in
|
||||
* the "Header stats styles" block of styles.css keys on, plus the two DOM
|
||||
* moves the clustered styles need.
|
||||
*
|
||||
* The template keeps the classic order, where the connection indicator sits
|
||||
* before the font controls and the plan-usage chip near the end of the header.
|
||||
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
|
||||
* windows), so the indicator moves into #headerSystemStats as its first child
|
||||
* and the chip moves right after it. Comment anchors left at the template
|
||||
* positions are what 'classic' moves them back to, so switching back restores
|
||||
* the header exactly.
|
||||
*
|
||||
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
|
||||
* is hidden with `display: none`, and the WS readout must not disappear with
|
||||
* it. Both elements keep their ids, so every writer (setConnectionStatus,
|
||||
* updatePlanUsageChip) finds them wherever they sit.
|
||||
*
|
||||
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
|
||||
*/
|
||||
applyHeaderStatsStyle({ style, showSystemStats }) {
|
||||
document.documentElement.dataset.headerStats = style;
|
||||
const stats = document.getElementById('headerSystemStats');
|
||||
const conn = document.getElementById('connectionIndicator');
|
||||
const plan = document.getElementById('planUsageChip');
|
||||
if (!stats || !conn || !plan) return;
|
||||
if (!this._headerStatsAnchors) {
|
||||
const connAnchor = document.createComment(' connection indicator (classic position) ');
|
||||
const planAnchor = document.createComment(' plan usage chip (classic position) ');
|
||||
conn.before(connAnchor);
|
||||
plan.before(planAnchor);
|
||||
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
|
||||
}
|
||||
const anchors = this._headerStatsAnchors;
|
||||
const clustered = style !== 'classic';
|
||||
if (clustered && showSystemStats) {
|
||||
if (stats.firstElementChild !== conn) stats.prepend(conn);
|
||||
} else if (anchors.conn.nextSibling !== conn) {
|
||||
anchors.conn.after(conn);
|
||||
}
|
||||
if (clustered) {
|
||||
if (stats.nextElementSibling !== plan) stats.after(plan);
|
||||
} else if (anchors.plan.nextSibling !== plan) {
|
||||
anchors.plan.after(plan);
|
||||
}
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3579,6 +3642,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tokenCountEl) {
|
||||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
||||
}
|
||||
// After the System Stats visibility above: whether WS joins the stats pill
|
||||
// depends on the pill being shown.
|
||||
this.applyHeaderStatsStyle({
|
||||
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
|
||||
showSystemStats,
|
||||
});
|
||||
|
||||
// Hide lifecycle log button when setting is disabled
|
||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||
@@ -3746,6 +3815,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
// Grouping rides on a fourth attribute, for the same reason: it is applied
|
||||
// as inline `order` plus heading elements the render paths emit, so a flip
|
||||
// has to re-render, and `isTabTriage()` (app.js) reads one attribute per
|
||||
// pass instead of re-parsing localStorage. Anything but an explicit 'none'
|
||||
// is 'state', the default (Discussion #426, option C).
|
||||
const previousGrouping = root.dataset.tabGrouping || 'state';
|
||||
const grouping = (settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
|
||||
root.dataset.tabGrouping = grouping;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -3769,7 +3847,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
@@ -4039,10 +4117,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabGrouping', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -19788,3 +19788,373 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabGrouping: 'state', the default;
|
||||
Discussion #426 option C)
|
||||
============================================================
|
||||
app.js writes a flex `order` per tab plus one heading and one row break per
|
||||
non-empty state, all direct children of #sessionTabs (_syncTabTriageChrome).
|
||||
The DOM stays in tab order; computeTabTriageLayout() in constants.js owns the
|
||||
bands. With grouping off none of this matches anything. */
|
||||
.tab-triage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-triage-count {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-triage-head--needs .tab-triage-label {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.tab-triage-head--waiting .tab-triage-label {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.tab-triage-head--working .tab-triage-label {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.tab-triage-break {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Counts describe the whole group, so they step aside while the sidebar
|
||||
filter narrows the rows under them. */
|
||||
.session-tabs.tabs-filtering > .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header strip, wrapping (desktop): one row per state. Each heading sits in a
|
||||
left gutter, so a group too long for one line continues under its own tabs
|
||||
rather than under its heading, and each row ends with a full-width break. */
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
|
||||
--tab-triage-gutter: 92px;
|
||||
padding-left: calc(0.25rem + var(--tab-triage-gutter));
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
|
||||
width: var(--tab-triage-gutter);
|
||||
margin-left: calc(-1 * var(--tab-triage-gutter));
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Side rail and sidebar: each heading is a section row above its rows. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-triage > .tab-triage-head,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-triage > .tab-triage-head {
|
||||
align-self: auto;
|
||||
width: 100%;
|
||||
padding: 8px 6px 3px;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
|
||||
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Header stats styles (headerStatsStyle; Discussion #426 option G)
|
||||
============================================================
|
||||
html[data-header-stats] picks how the WS / CPU / MEM / plan-usage cluster
|
||||
is drawn. 'classic' matches nothing below except the hidden parts, so the
|
||||
header looks exactly as before. 'compact' and 'tiles' are desktop only
|
||||
(settings-ui.js resolves anything narrower, and a solo window, to
|
||||
'classic'), and applyHeaderStatsStyle() has already moved
|
||||
#connectionIndicator into #headerSystemStats and #planUsageChip right after
|
||||
it, so the cluster is contiguous. */
|
||||
|
||||
/* Parts only the two new styles draw. */
|
||||
.stat-spark,
|
||||
.connection-tile-label,
|
||||
.connection-tile-value,
|
||||
.header-plan-usage .pu-ring,
|
||||
.header-plan-usage .pu-meter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Compact: one system pill with sparklines, one plan-ring pill ------- */
|
||||
html[data-header-stats='compact'] .header-system-stats {
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats > .connection-indicator {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats > .connection-indicator::after,
|
||||
html[data-header-stats='compact'] .header-system-stats > .stat-item + .stat-item::before {
|
||||
content: '·';
|
||||
margin: 0 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-label {
|
||||
min-width: 0;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-value {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .stat-spark {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .stat-spark i {
|
||||
width: 3px;
|
||||
height: 10%;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-row {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-provider {
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-windows {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-win {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-label {
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* A ring per window: a conic fill masked down to a 2px stroke, coloured like
|
||||
the value next to it (the pu-green/-yellow/-red class sits on both). */
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-ring {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(currentColor calc(var(--pu, 0) * 1%), var(--control-border) 0);
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
|
||||
}
|
||||
|
||||
/* --- Tiles: label over value, a bar underneath, no icons ----------------- */
|
||||
html[data-header-stats='tiles'] .header-system-stats {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
padding: 0 8px 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
/* The tile label and value: the stat items already carry a label and a value,
|
||||
the connection indicator gets its own pair (its text line keeps the classic
|
||||
wording, queued bytes included, and stays the accessible name via title). */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-label,
|
||||
html[data-header-stats='tiles'] .connection-tile-label,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value,
|
||||
html[data-header-stats='tiles'] .connection-tile-value,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-val {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Plan values keep their green/yellow/red; the stat values read as plain text
|
||||
unless they are running high. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value:not(.high),
|
||||
html[data-header-stats='tiles'] .connection-tile-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-dot,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.fallback,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.reconnecting,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.draining {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.offline {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* The bar runs along the bottom edge of the tile. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-bar,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 3px;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-row {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-provider {
|
||||
width: auto;
|
||||
flex: none;
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-windows {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* An idle window keeps its tile and dims the whole thing. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Before the first plan reading the chip holds a bare em dash; give it a tile
|
||||
of its own so it does not read as a stray mark between the tiles. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1403,6 +1403,14 @@ export const SettingsUpdateSchema = z
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Tab grouping. Display key (per-device).
|
||||
* 'state' = the strip is split into state rows (needs you, waiting,
|
||||
* working, idle; Discussion #426 option C), and the flat side
|
||||
* rail and the sidebar get the same sections. The default.
|
||||
* 'none' = one flat list in tab order, as before.
|
||||
*/
|
||||
tabGrouping: z.enum(['state', 'none']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
@@ -1434,6 +1442,14 @@ export const SettingsUpdateSchema = z
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
/**
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = one system pill with sparklines plus a plan-usage ring pill
|
||||
* 'tiles' = label over value with a bar underneath, no icons. The default.
|
||||
*/
|
||||
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
|
||||
showTokenCount: z.boolean().optional(),
|
||||
showCost: z.boolean().optional(),
|
||||
showLifecycleLog: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user