mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
The welcome overlay centers ~560px of content in a ~1400px window, so both gutters are dead space. The left one now carries the open tabs as a vertical list (home-sessions.js): one row per live session plus saved web tabs, in TAB order rather than by urgency, because the row badges are the Alt+1..9 indices. Clicking a row enters that session. Working state is deliberately the phone's, exactly: a pulsing green dot ringed by the same tab-load-spin the tab strip uses while a tab loads, now with a green halo added on both surfaces so "working" reads identically wherever you see it. The column is position:absolute so the centered content never moves, which is why it needs a width gate in two places (HOME_SESSIONS_MIN_WIDTH = 1180 in JS, a max-width: 1179px media query as the backstop for a resize that outruns the matchMedia listener). A test pins the two equal. State classification is reused from mobile-overview.js rather than re-derived, so the two home screens cannot disagree about what counts as needing you. Phones keep the mobile overview, and their brand "C" was a 0.85rem inline span, roughly a 12x13px target on the one control that gets you back to that screen. It is now a 44px-wide button filling the full header height, with the glyph scaled to match. 44 is horizontal only: the phone header is pinned to 36px and clips overflow, so a true 44x44 would mean taking height off the terminal. Verified end to end against a real isolated instance (own tmux socket + data dir): 18 browser checks covering render, live update through the tab renderer, the working dot's animation/glow/ring, row click, the narrow-window gate, the phone fallback, and a real touch tap on the far corner of the new hit box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
336 lines
13 KiB
JavaScript
336 lines
13 KiB
JavaScript
/**
|
|
* @fileoverview Desktop home screen session list: the open tabs as a vertical
|
|
* column down the left 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, in TAB ORDER (not sorted by state) so it reads as the
|
|
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
|
*
|
|
* DESKTOP ONLY, and only in a wide enough window: the column is absolutely
|
|
* positioned so the centered welcome content never moves, which means it can
|
|
* only exist where the gutter is genuinely wider than the column. 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.
|
|
*
|
|
* 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 mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
|
* @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 column. The welcome content is 560px wide and
|
|
* centered, so at 1180px each gutter is 310px — enough for the 256px column plus
|
|
* its 20px offset 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;
|
|
|
|
/** 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',
|
|
};
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Gate + visibility
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Width-driven, like every other layout decision in the app. Explicitly yields
|
|
* to the phone overview: that surface already lists 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;
|
|
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;
|
|
return;
|
|
}
|
|
el.hidden = false;
|
|
this.renderHomeSessions();
|
|
},
|
|
|
|
hideHomeSessions() {
|
|
const el = document.getElementById('homeSessions');
|
|
if (el) el.hidden = true;
|
|
},
|
|
|
|
/** 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 the user's tab order. State classification is
|
|
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
|
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
|
* by urgency because it shows one screenful at a time, this column mirrors the
|
|
* tab strip so the number badges line up with Alt+1..9.
|
|
* @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);
|
|
|
|
return ids.map((id, index) => {
|
|
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,
|
|
index,
|
|
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,
|
|
};
|
|
});
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 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();
|
|
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);
|
|
},
|
|
|
|
_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;
|
|
|
|
if (row.index < 9) {
|
|
const number = document.createElement('span');
|
|
number.className = 'home-sessions-number';
|
|
number.setAttribute('data-i18n-skip', '');
|
|
number.textContent = String(row.index + 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;
|
|
item.appendChild(pill);
|
|
|
|
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';
|
|
item.appendChild(pill);
|
|
|
|
return item;
|
|
},
|
|
});
|