mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
508 lines
21 KiB
JavaScript
508 lines
21 KiB
JavaScript
/**
|
|
* @fileoverview Desktop home screen session list: the open tabs as a rail docked
|
|
* down the left edge of the welcome overlay.
|
|
*
|
|
* The welcome screen centers ~560px of content in a window that is usually
|
|
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
|
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
|
* one row per live tab.
|
|
*
|
|
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
|
|
* the phone overview uses: blocked on you first, then running longest-first,
|
|
* then quiet most-recently-quiet first. The number badge stays the tab-strip
|
|
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
|
|
* it names a shortcut, not a row position.
|
|
*
|
|
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
|
* positioned so the centered welcome content never moves, which means it can
|
|
* only exist where the gutter is genuinely wider than the rail. Below
|
|
* `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns
|
|
* the home screen entirely and this surface stays out of its way. Width and type
|
|
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
|
* a fixed 256px card looks abandoned on a 2560px display.
|
|
*
|
|
* Each row carries when the session was FIRST CREATED and how long it has been
|
|
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
|
|
* the value the order above is computed from, so the rail explains itself
|
|
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
|
|
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
|
|
* from the epoch-ms values parked on the elements, rather than re-rendering: a
|
|
* re-render would restart every row's blink animation and its working ring.
|
|
*
|
|
* The working state is deliberately identical to the phone's: a pulsing green
|
|
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
|
* from styles.css), plus a green halo. Same signal, same motion, both surfaces.
|
|
*
|
|
* Everything renders from state the page already holds (`this.sessions`,
|
|
* `this.cases`, `this.pendingHooks`, `this.webviews`) — no endpoint, no SSE
|
|
* event, no schema. State classification and case matching are reused from
|
|
* mobile-overview.js rather than re-derived, so the two home screens can never
|
|
* disagree about what "working" means.
|
|
*
|
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
|
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
|
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
|
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
|
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
|
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
|
* @dependency mobile-handlers.js (MobileDetection)
|
|
* @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js
|
|
*/
|
|
|
|
/**
|
|
* Narrowest window that gets the rail. The welcome content is 560px wide and
|
|
* centered, so at 1180px each gutter is 310px, enough for the rail at its
|
|
* 250px floor and still a visible gap. Anything narrower would overlap the
|
|
* search panel, which is why this is a width gate and not a device-type gate.
|
|
*/
|
|
const HOME_SESSIONS_MIN_WIDTH = 1180;
|
|
|
|
/** How often the relative stamps are rewritten while the home screen is up. */
|
|
const HOME_SESSIONS_CLOCK_MS = 20000;
|
|
|
|
/** Pill copy per state. Same words as the phone overview, same reasons. */
|
|
const HOME_SESSIONS_PILL_LABEL = {
|
|
needs: 'needs you',
|
|
error: 'error',
|
|
waiting: 'waiting',
|
|
working: 'working',
|
|
idle: 'idle',
|
|
done: 'done',
|
|
};
|
|
|
|
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
|
|
const HOME_SESSIONS_MODE_BADGE = {
|
|
shell: 'sh',
|
|
opencode: 'oc',
|
|
codex: 'cx',
|
|
gemini: 'gm',
|
|
antigravity: 'ag',
|
|
pi: 'pi',
|
|
grok: 'gk',
|
|
deepseek: 'ds',
|
|
omp: 'om',
|
|
};
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Gate + visibility
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Width-driven, like every other layout decision in the app. Explicitly yields
|
|
* to the phone overview and persistent vertical tab rail: those surfaces already
|
|
* list the same sessions, and two lists of the same thing on one screen is worse
|
|
* than none.
|
|
*/
|
|
shouldShowHomeSessions() {
|
|
if (this.isSoloWindow) return false;
|
|
if (this.shouldUseMobileOverview?.()) return false;
|
|
if (document.documentElement.getAttribute('data-tab-orientation') === 'vertical') return false;
|
|
// The sidebar layout already docks the full session list flush left at full
|
|
// height — the rail would render the same list right next to it (and z-wise
|
|
// UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
|
|
if (this.isSessionSidebarActive?.()) return false;
|
|
return window.innerWidth >= HOME_SESSIONS_MIN_WIDTH;
|
|
},
|
|
|
|
/** True while the column is the visible home surface. */
|
|
isHomeSessionsVisible() {
|
|
const el = document.getElementById('homeSessions');
|
|
return !!el && !el.hidden;
|
|
},
|
|
|
|
showHomeSessions() {
|
|
const el = document.getElementById('homeSessions');
|
|
if (!el) return;
|
|
this._wireHomeSessions(el);
|
|
if (!this.shouldShowHomeSessions()) {
|
|
el.hidden = true;
|
|
this._stopHomeSessionsClock();
|
|
return;
|
|
}
|
|
el.hidden = false;
|
|
this.renderHomeSessions();
|
|
},
|
|
|
|
hideHomeSessions() {
|
|
const el = document.getElementById('homeSessions');
|
|
if (el) el.hidden = true;
|
|
this._stopHomeSessionsClock();
|
|
},
|
|
|
|
/** Re-render only when showing (called from the tab renderer's tail). */
|
|
_refreshHomeSessionsIfVisible() {
|
|
if (!this.isHomeSessionsVisible()) return;
|
|
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
|
},
|
|
|
|
/**
|
|
* One delegated click listener for every row, plus a width listener so
|
|
* resizing the window while on the home screen adds or drops the column
|
|
* instead of leaving it overlapping the content it was sized to clear.
|
|
*/
|
|
_wireHomeSessions(el) {
|
|
if (this._homeSessionsWired) return;
|
|
this._homeSessionsWired = true;
|
|
|
|
el.addEventListener('click', (event) => {
|
|
const target = event.target?.closest?.('[data-hs-action]');
|
|
if (!target) return;
|
|
if (target.dataset.hsAction === 'session') {
|
|
void this.selectSession(target.dataset.hsSession);
|
|
} else if (target.dataset.hsAction === 'webview') {
|
|
void this.openWebview?.(target.dataset.hsWebview);
|
|
}
|
|
});
|
|
|
|
if (window.matchMedia) {
|
|
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
|
const onChange = () => {
|
|
// Only relevant while the welcome screen is up; entering a session
|
|
// re-decides through hideWelcome()/showWelcome() anyway.
|
|
if (this.activeSessionId) return;
|
|
const overlay = document.getElementById('welcomeOverlay');
|
|
if (!overlay || !overlay.classList.contains('visible')) return;
|
|
this.showHomeSessions();
|
|
};
|
|
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
|
else if (mq.addListener) mq.addListener(onChange);
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Model
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* One row per live session, in overview order: whatever is blocked on you
|
|
* first, then whatever is running (longest turn first), then the quiet ones
|
|
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
|
|
* (constants.js), shared with the phone overview, and state classification is
|
|
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
|
|
* neither disagree about what "working" means nor about what sorts first.
|
|
*
|
|
* `orderIndex` stays the position in the TAB STRIP, because that is what the
|
|
* number badge means (Alt+1..9). Once the rows are sorted those badges no
|
|
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
|
|
* not "how far down the list is it".
|
|
*
|
|
* @returns {Array<object>} row descriptors, ready to render
|
|
*/
|
|
buildHomeSessionRows() {
|
|
const cases = Array.isArray(this.cases) ? this.cases : [];
|
|
const order = Array.isArray(this.sessionOrder) ? this.sessionOrder : [];
|
|
const ids = order.filter((id) => this.sessions?.has(id));
|
|
// A session created before the order list caught up would otherwise be
|
|
// invisible here while its tab already exists.
|
|
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
|
|
|
const rows = ids.map((id, orderIndex) => {
|
|
const session = this.sessions.get(id);
|
|
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
|
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
|
const mode = session.mode || 'claude';
|
|
return {
|
|
id,
|
|
orderIndex,
|
|
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
|
mode,
|
|
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
|
caseName: matched ? matched.name : '',
|
|
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
|
state,
|
|
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
|
// Epoch ms, straight off the session payload; formatting happens at
|
|
// render time so the clock below can redo it without a re-render.
|
|
createdAt: Number(session.createdAt) || 0,
|
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
|
// The running group is ordered by the pane's last Enter, since a
|
|
// working pane's last-activity stamp is always "now".
|
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
|
// "how long has it been like this", resolved by the phone overview's
|
|
// helper so both home screens label the same stamp with the same word.
|
|
since: this._mobileOverviewSince(state, session),
|
|
};
|
|
});
|
|
|
|
// Guarded like every other constants.js consumer: a stale cached
|
|
// constants.js (iOS Safari serves old JS after a deploy) must degrade to
|
|
// tab order, not TypeError the whole home screen away.
|
|
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(rows) : rows;
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Render
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
renderHomeSessions() {
|
|
const el = document.getElementById('homeSessions');
|
|
if (!el) return;
|
|
|
|
const rows = this.buildHomeSessionRows();
|
|
const webviews = (this.webviewOrder || []).map((id) => this.webviews?.get(id)).filter(Boolean);
|
|
|
|
// Nothing open means nothing to list: an empty framed box next to a
|
|
// first-run welcome screen is noise, not information.
|
|
if (!rows.length && !webviews.length) {
|
|
el.hidden = true;
|
|
el.replaceChildren();
|
|
this._stopHomeSessionsClock();
|
|
return;
|
|
}
|
|
el.hidden = false;
|
|
|
|
el.replaceChildren();
|
|
el.appendChild(this._buildHomeSessionsHeader(rows.length + webviews.length));
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'home-sessions-list';
|
|
for (const row of rows) list.appendChild(this._buildHomeSessionRow(row));
|
|
for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview));
|
|
el.appendChild(list);
|
|
|
|
this._startHomeSessionsClock();
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Age stamps: created / last active
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
|
|
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
|
* rewrite the text without rebuilding the row.
|
|
*
|
|
* The second stamp is the row's state duration, NOT a plain last-active
|
|
* stamp: it is the number the rail is sorted by, and a working row that reads
|
|
* "active just now" (every working pane repaints about once a second) hides
|
|
* exactly the value that decided its position. `_mobileOverviewSince()` owns
|
|
* both the word and the anchor, so the phone says the same thing.
|
|
*/
|
|
_buildHomeSessionsMeta(row) {
|
|
const meta = document.createElement('span');
|
|
meta.className = 'home-sessions-row-meta';
|
|
// Relative times are generated text, and "created"/"idle" here are the
|
|
// same generic words that mean something else on other surfaces.
|
|
meta.setAttribute('data-i18n-skip', '');
|
|
|
|
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
|
|
|
|
if (row.since) {
|
|
const sep = document.createElement('span');
|
|
sep.className = 'home-sessions-meta-sep';
|
|
sep.setAttribute('aria-hidden', 'true');
|
|
sep.textContent = '·';
|
|
meta.appendChild(sep);
|
|
|
|
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
|
|
}
|
|
|
|
return meta;
|
|
},
|
|
|
|
/** One labelled stamp: a dim key, the value, full date in the title. */
|
|
_buildHomeSessionsStamp(key, timestamp, format, className) {
|
|
const wrap = document.createElement('span');
|
|
wrap.className = `home-sessions-meta-item ${className}`;
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'home-sessions-meta-key';
|
|
label.textContent = key;
|
|
wrap.appendChild(label);
|
|
|
|
const value = document.createElement('span');
|
|
value.dataset.hsTs = String(timestamp || 0);
|
|
value.dataset.hsFmt = format;
|
|
value.textContent = this._homeSessionsStampText(timestamp, format);
|
|
wrap.appendChild(value);
|
|
|
|
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
|
|
return wrap;
|
|
},
|
|
|
|
/**
|
|
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
|
|
* Both come from the phone overview's formatter, so a duration is written the
|
|
* same way on both home screens.
|
|
*/
|
|
_homeSessionsStampText(timestamp, format) {
|
|
return this._mobileOverviewStampText(timestamp, format);
|
|
},
|
|
|
|
/**
|
|
* Rewrites the stamps in place every `HOME_SESSIONS_CLOCK_MS`. In place, not a
|
|
* re-render: replacing the rows would restart the blink animation on every
|
|
* waiting row and the ring on every working one, twice a minute, for nothing.
|
|
*/
|
|
_startHomeSessionsClock() {
|
|
if (this._homeSessionsClock) return;
|
|
this._homeSessionsClock = setInterval(() => {
|
|
if (!this.isHomeSessionsVisible()) {
|
|
this._stopHomeSessionsClock();
|
|
return;
|
|
}
|
|
this._tickHomeSessionsTimes();
|
|
}, HOME_SESSIONS_CLOCK_MS);
|
|
},
|
|
|
|
_stopHomeSessionsClock() {
|
|
if (!this._homeSessionsClock) return;
|
|
clearInterval(this._homeSessionsClock);
|
|
this._homeSessionsClock = null;
|
|
},
|
|
|
|
_tickHomeSessionsTimes() {
|
|
const el = document.getElementById('homeSessions');
|
|
if (!el) return;
|
|
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
|
const ts = Number(node.dataset.hsTs) || 0;
|
|
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
|
|
if (node.textContent !== text) node.textContent = text;
|
|
}
|
|
},
|
|
|
|
_buildHomeSessionsHeader(count) {
|
|
const header = document.createElement('div');
|
|
header.className = 'home-sessions-header';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'home-sessions-title';
|
|
label.textContent = 'Open tabs';
|
|
header.appendChild(label);
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'home-sessions-count';
|
|
badge.setAttribute('data-i18n-skip', '');
|
|
badge.textContent = String(count);
|
|
header.appendChild(badge);
|
|
|
|
return header;
|
|
},
|
|
|
|
/**
|
|
* A session row. The state class drives the same visual language as the
|
|
* session tabs and the phone overview: green dot when it is fine (pulsing and
|
|
* ringed by the load spinner while working), a yellow row when it wants input,
|
|
* a red row when it asked a question.
|
|
*/
|
|
_buildHomeSessionRow(row) {
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'home-sessions-row home-sessions-row--' + row.state;
|
|
item.dataset.hsAction = 'session';
|
|
item.dataset.hsSession = row.id;
|
|
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
|
|
|
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
|
|
// even though the rows are sorted by activity: it will not read 1,2,3 down
|
|
// the rail, and must not, or the shortcut it names would be wrong.
|
|
if (row.orderIndex < 9) {
|
|
const number = document.createElement('span');
|
|
number.className = 'home-sessions-number';
|
|
number.setAttribute('data-i18n-skip', '');
|
|
number.textContent = String(row.orderIndex + 1);
|
|
item.appendChild(number);
|
|
}
|
|
|
|
const dot = document.createElement('span');
|
|
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
item.appendChild(dot);
|
|
|
|
const body = document.createElement('span');
|
|
body.className = 'home-sessions-row-body';
|
|
|
|
const line1 = document.createElement('span');
|
|
line1.className = 'home-sessions-row-title';
|
|
if (row.modeBadge) {
|
|
const badge = document.createElement('span');
|
|
badge.className = `home-sessions-mode ${row.mode}`;
|
|
badge.setAttribute('data-i18n-skip', '');
|
|
badge.textContent = row.modeBadge;
|
|
line1.appendChild(badge);
|
|
}
|
|
const name = document.createElement('span');
|
|
// .session-name is in the i18n skip list: a session name is user content.
|
|
name.className = 'session-name';
|
|
name.textContent = row.name;
|
|
line1.appendChild(name);
|
|
body.appendChild(line1);
|
|
|
|
const line2 = document.createElement('span');
|
|
line2.className = 'home-sessions-row-sub';
|
|
line2.setAttribute('data-i18n-skip', '');
|
|
line2.textContent = row.caseName || row.dir || row.mode;
|
|
body.appendChild(line2);
|
|
|
|
item.appendChild(body);
|
|
|
|
const pill = document.createElement('span');
|
|
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
|
|
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
|
// that collide with state strings on other surfaces.
|
|
pill.setAttribute('data-i18n-skip', '');
|
|
pill.textContent = row.pill;
|
|
|
|
// The stamps line wraps onto its own full-width line (the row is flex-wrap)
|
|
// and the pill rides along at its right end, rather than sitting beside the
|
|
// name: that hands the whole width of the rail to the session name, which is
|
|
// what stops it ellipsizing.
|
|
const meta = this._buildHomeSessionsMeta(row);
|
|
meta.appendChild(pill);
|
|
item.appendChild(meta);
|
|
|
|
return item;
|
|
},
|
|
|
|
/** A saved dashboard, listed after the sessions exactly as in the tab strip. */
|
|
_buildHomeSessionsWebviewRow(webview) {
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'home-sessions-row home-sessions-row--web';
|
|
item.dataset.hsAction = 'webview';
|
|
item.dataset.hsWebview = webview.id;
|
|
item.title = webview.url || webview.name;
|
|
|
|
const dot = document.createElement('span');
|
|
dot.className = 'home-sessions-dot home-sessions-dot--web';
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
item.appendChild(dot);
|
|
|
|
const body = document.createElement('span');
|
|
body.className = 'home-sessions-row-body';
|
|
|
|
const title = document.createElement('span');
|
|
title.className = 'home-sessions-row-title';
|
|
const name = document.createElement('span');
|
|
// A dashboard name is user content.
|
|
name.className = 'case-name';
|
|
name.textContent = webview.name;
|
|
title.appendChild(name);
|
|
body.appendChild(title);
|
|
|
|
const sub = document.createElement('span');
|
|
sub.className = 'home-sessions-row-sub';
|
|
sub.setAttribute('data-i18n-skip', '');
|
|
sub.textContent = webview.url || '';
|
|
body.appendChild(sub);
|
|
|
|
item.appendChild(body);
|
|
|
|
const pill = document.createElement('span');
|
|
pill.className = 'home-sessions-pill home-sessions-pill--web';
|
|
pill.setAttribute('data-i18n-skip', '');
|
|
pill.textContent = 'web';
|
|
|
|
// Same bottom line as a session row (minus the stamps, a dashboard has
|
|
// none), so the pill sits in the same place on every row in the rail.
|
|
const foot = document.createElement('span');
|
|
foot.className = 'home-sessions-row-meta';
|
|
foot.setAttribute('data-i18n-skip', '');
|
|
foot.appendChild(pill);
|
|
item.appendChild(foot);
|
|
|
|
return item;
|
|
},
|
|
});
|