From 4add38c4b1196860971c82bab74bef42b2f9700a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 18:17:42 +0200 Subject: [PATCH] feat(home): open-tab column on the desktop home screen; bigger phone home button 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) --- CLAUDE.md | 6 +- src/web/public/app.js | 2 + src/web/public/home-sessions.js | 335 ++++++++++++++++++++++ src/web/public/i18n.js | 3 + src/web/public/index.html | 6 + src/web/public/mobile.css | 44 ++- src/web/public/styles.css | 296 +++++++++++++++++++ src/web/public/terminal-ui.js | 5 + test/home-sessions.test.ts | 223 ++++++++++++++ test/mobile-header-buttons-policy.test.ts | 32 +++ 10 files changed, 946 insertions(+), 6 deletions(-) create mode 100644 src/web/public/home-sessions.js create mode 100644 test/home-sessions.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 81bc048b..845e499e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | | **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 26 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 27 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -246,12 +246,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). +**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. + **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. diff --git a/src/web/public/app.js b/src/web/public/app.js index d97fa8f3..f475cb67 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3686,6 +3686,8 @@ class CodemanApp { // (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks) // already funnels through here. No-ops unless that surface is showing. this._refreshMobileOverviewIfVisible?.(); + // Same deal for the desktop home screen's tab column. + this._refreshHomeSessionsIfVisible?.(); } // Auto-wrap desktop session tabs to a second row when they overflow one row, diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js new file mode 100644 index 00000000..032596da --- /dev/null +++ b/src/web/public/home-sessions.js @@ -0,0 +1,335 @@ +/** + * @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} 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; + }, +}); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 10a84634..d0153e49 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -387,6 +387,9 @@ '在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页', Phone: '手机', + // Desktop home screen tab column (home-sessions.js) + 'Open tabs': '打开的标签', + // Session/case dialogs 'Session Options': '会话选项', 'Session Name': '会话名称', diff --git a/src/web/public/index.html b/src/web/public/index.html index 46efc141..7f175204 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -322,6 +322,11 @@
+ +

Codeman

Manage AI Coding tools in persistent tmux sessions.

@@ -2769,6 +2774,7 @@ + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 23f36d2a..7ce21953 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -353,13 +353,46 @@ html.mobile-init .file-browser-panel { /* Phone brand collapses to a single "C" home button: hide the wordmark, keep the tap target */ .header-brand { - padding-right: 0.25rem; - margin-right: 0.2rem; + padding-right: 0; + margin-right: 0.1rem; border-right: none; + /* styles.css sizes this to the FULL header height, so inside the header's + 0.15rem vertical padding it overflows and the last 2.4px are clipped + (`overflow: hidden` on the phone header). Harmless while the brand was + bare text; it would cut the bottom off the tap target's pressed state. + Cancelling the padding for this one item is what makes the button fill + the header edge to edge. `height: 100%` cannot do it: the header sets + min/max-height rather than height, so the percentage has no definite + containing block to resolve against and silently falls back to auto. */ + margin-block: -0.15rem; } + /* The "C" was a 0.85rem inline span — about a 12x13px hit area, far under the + 44px minimum, on the one control that gets you back to the home screen. + It is now a real 44px-wide button spanning the full header height, with the + glyph scaled to match. 44 is reachable on the horizontal axis only: the + phone header is pinned to 36px tall (min-height/max-height below) and + `overflow: hidden` clips anything taller, so a true 44x44 would mean growing + the header and taking that height off the terminal. The negative margin + spends the header's OWN left padding on the target instead of pushing the + tab strip right. */ .header-brand .logo { - font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + /* Taller than its parent on purpose: centred in the padded brand box, this + makes the button fill all 36 header pixels edge to edge. */ + height: var(--header-height); + margin-left: -0.3rem; + font-size: 1.15rem; + line-height: 1; + border-radius: 8px; + -webkit-tap-highlight-color: transparent; + } + + .header-brand .logo:active { + background: rgba(96, 165, 250, 0.16); } .header-brand .logo .logo-text { @@ -2690,10 +2723,13 @@ html.mobile-init .file-browser-panel { } /* Same as .session-tab .tab-status: green when the session is fine, and the - shared `pulse` keyframes while it is working. */ + shared `pulse` keyframes while it is working. The halo matches the busy tab + dot and the desktop home column (.home-sessions-dot--working, styles.css): + working reads identically on every surface or it reads as three features. */ .mobile-overview-dot--working { background: var(--green); animation: pulse 1.5s infinite; + box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent); will-change: opacity; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b740f2d7..63c2dd09 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -13922,3 +13922,299 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { color: #8b93a1; min-height: 1em; } + +/* ══════════════════════════════════════════════════════════════════════════ + Home screen: open tabs in the left gutter (home-sessions.js) + + The welcome content is 560px wide and centered, so this column lives in dead + space. It is `position: absolute` precisely so that stays true: the centered + content does not move by a pixel whether the column renders or not. That in + turn is why the width gate below has to exist — in a narrow window there is + no gutter to sit in, and an absolute box would simply overlap the search + panel. JS gates on the same 1180px so the two can never disagree. + + The working dot is the phone's, exactly: pulsing green ringed by the very + same `tab-load-spin` a tab shows while it loads (reused from above, never + re-declared), plus a green halo. One signal, one motion, both home screens. + ══════════════════════════════════════════════════════════════════════════ */ + +.home-sessions { + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 8px; + width: 256px; + max-height: calc(100% - 3rem); + text-align: left; + z-index: 1; +} + +/* `hidden` has to be re-asserted over the display above, or the module's only + lever (el.hidden) does nothing. */ +.home-sessions[hidden] { + display: none; +} + +/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the + matchMedia listener must never leave the column overlapping the content. */ +@media (max-width: 1179px) { + .home-sessions { + display: none !important; + } +} + +.home-sessions-header { + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px; +} + +.home-sessions-title { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); +} + +.home-sessions-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 999px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 0.6rem; + font-weight: 700; + font-family: monospace; +} + +.home-sessions-list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + overflow-x: hidden; + padding: 2px 2px 6px; +} + +.home-sessions-list::-webkit-scrollbar { + width: 4px; +} + +.home-sessions-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.home-sessions-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 9px; + border-radius: 9px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-dim); + font-family: inherit; + font-size: 0.76rem; + text-align: left; + cursor: pointer; + transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth); +} + +.home-sessions-row:hover { + background: var(--bg-hover); + border-color: rgba(34, 197, 94, 0.35); + color: var(--text); +} + +.home-sessions-row:active { + background: rgba(34, 197, 94, 0.12); +} + +.home-sessions-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + flex-shrink: 0; + border-radius: 3px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.58rem; + font-weight: 700; + font-family: monospace; +} + +.home-sessions-dot { + position: relative; + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--text-muted); +} + +.home-sessions-dot--needs, +.home-sessions-dot--error { + background: var(--red); +} + +.home-sessions-dot--waiting { + background: var(--yellow); +} + +.home-sessions-dot--idle { + background: var(--green); +} + +.home-sessions-dot--done { + background: var(--text-muted); + opacity: 0.5; +} + +.home-sessions-dot--web { + background: #60a5fa; +} + +.home-sessions-dot--working { + background: var(--green); + animation: pulse 1.5s infinite; + box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent); + will-change: opacity; +} + +.home-sessions-dot--working::after { + content: ''; + position: absolute; + inset: -4px; + border: 2px solid color-mix(in srgb, var(--green) 25%, transparent); + border-top-color: var(--green); + border-radius: 50%; + animation: tab-load-spin 0.7s linear infinite; +} + +.home-sessions-row-body { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.home-sessions-row-title { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--text); + font-weight: 600; +} + +.home-sessions-row-title .session-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-sessions-row-sub { + font-size: 0.66rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-sessions-mode { + flex-shrink: 0; + padding: 0 4px; + border-radius: 3px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.55rem; + font-weight: 700; + font-family: monospace; + text-transform: uppercase; +} + +.home-sessions-pill { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 999px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.home-sessions-pill--needs, +.home-sessions-pill--error { + background: color-mix(in srgb, var(--red) 18%, transparent); + border-color: color-mix(in srgb, var(--red) 45%, transparent); + color: var(--red); +} + +.home-sessions-pill--waiting { + background: color-mix(in srgb, var(--yellow) 18%, transparent); + border-color: color-mix(in srgb, var(--yellow) 45%, transparent); + color: var(--yellow); +} + +.home-sessions-pill--working, +.home-sessions-pill--idle { + background: color-mix(in srgb, var(--green) 15%, transparent); + border-color: color-mix(in srgb, var(--green) 40%, transparent); + color: var(--green); +} + +/* Row accents: same language as the session tabs and the phone overview — red + means a question is pending, yellow means it wants input, green means work is + happening. Nothing else on this screen may reuse these colors. */ +.home-sessions-row--needs, +.home-sessions-row--error { + border-color: color-mix(in srgb, var(--red) 50%, transparent); + animation: home-sessions-blink-red 2.5s ease-in-out infinite; +} + +.home-sessions-row--waiting { + border-color: color-mix(in srgb, var(--yellow) 50%, transparent); + animation: home-sessions-blink-yellow 3.5s ease-in-out infinite; +} + +.home-sessions-row--working { + border-color: color-mix(in srgb, var(--green) 35%, transparent); +} + +@keyframes home-sessions-blink-red { + 0%, 100% { border-color: color-mix(in srgb, var(--red) 50%, transparent); } + 50% { border-color: color-mix(in srgb, var(--red) 95%, transparent); } +} + +@keyframes home-sessions-blink-yellow { + 0%, 100% { border-color: color-mix(in srgb, var(--yellow) 45%, transparent); } + 50% { border-color: color-mix(in srgb, var(--yellow) 90%, transparent); } +} + +@media (prefers-reduced-motion: reduce) { + .home-sessions-row, + .home-sessions-dot, + .home-sessions-dot::after { + animation: none !important; + } +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 53cdd12a..1508c32b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1427,6 +1427,7 @@ Object.assign(CodemanApp.prototype, { if (this.shouldUseMobileOverview?.()) { const overlay = document.getElementById('welcomeOverlay'); if (overlay) overlay.classList.remove('visible'); + this.hideHomeSessions?.(); this.showMobileOverview(); this._updateCjkInputState?.(); return; @@ -1439,6 +1440,9 @@ Object.assign(CodemanApp.prototype, { this.applyWelcomeCliVisibility(); this.loadHistorySessions(); this.initSearchPanel(); + // Open tabs down the left gutter. Self-gating: a window too narrow to hold + // the column without overlapping the content leaves it hidden. + this.showHomeSessions?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -1447,6 +1451,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); + this.hideHomeSessions?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/test/home-sessions.test.ts b/test/home-sessions.test.ts new file mode 100644 index 00000000..abae5884 --- /dev/null +++ b/test/home-sessions.test.ts @@ -0,0 +1,223 @@ +// Port: none (pure model + static markup assertions — no browser, no server). +// +// The desktop home screen's tab column (src/web/public/home-sessions.js) fills +// the welcome overlay's left gutter. Two things about it can silently go wrong +// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone +// overview which sorts by urgency, and the number badges are only correct if it +// does), and the WIDTH GATE, which lives in two places at once — the JS constant +// and a CSS media query — because the column is absolutely positioned and would +// overlap the search panel in a narrow window. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +/** Minimal fake DOM node — enough surface for the programmatic row builders. */ +function fakeElement(): any { + const el: any = { + className: '', + type: '', + title: '', + textContent: '', + dataset: {}, + style: {}, + children: [] as any[], + setAttribute() {}, + appendChild(child: any) { + el.children.push(child); + return child; + }, + }; + return el; +} + +/** + * home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` / + * `shouldUseMobileOverview` from mobile-overview.js, so both files run in the + * same context — which is also the point: if that reuse ever breaks, these + * tests stop loading rather than quietly testing a divergent copy. + */ +function loadHomeSessionsApp(overrides: Record = {}, innerWidth = 1512) { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + console, + window: { innerWidth }, + document: { + getElementById: () => null, + createElement: () => fakeElement(), + createElementNS: () => fakeElement(), + }, + MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') }, + }); + for (const file of ['mobile-overview.js', 'home-sessions.js']) { + vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file }); + } + + const app = new (CodemanApp as any)(); + app.getSessionName = (session: any) => session.name || session.id.slice(0, 8); + app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/'); + app.loadAppSettingsFromStorage = () => ({}); + Object.assign(app, overrides); + return app; +} + +const CASES = [{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' }]; + +function sessionMap(list: Array>) { + return new Map( + list.map((over) => { + const s = { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over }; + return [s.id, s]; + }) + ); +} + +describe('home sessions column: model', () => { + it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => { + // The phone overview would hoist 'needy' to the top; this surface must not, + // because its badges are the Alt+N indices. + const app = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]), + sessionOrder: ['first', 'needy', 'third'], + cases: CASES, + pendingHooks: new Map([['needy', new Set(['permission_prompt'])]]), + }); + + const rows = app.buildHomeSessionRows(); + expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']); + expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]); + expect(rows[1].state).toBe('needs'); + expect(rows[1].pill).toBe('needs you'); + }); + + it('shows a session that is not in the order list yet', () => { + // A freshly created session exists in this.sessions before the order array + // catches up; its tab is already on screen, so its row must be too. + const app = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'known' }, { id: 'fresh' }]), + sessionOrder: ['known'], + cases: CASES, + }); + + expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual(['known', 'fresh']); + }); + + it('classifies state through the shared phone-overview helper', () => { + const app = loadHomeSessionsApp({ + sessions: sessionMap([ + { id: 'w', status: 'busy' }, + { id: 'i', status: 'idle' }, + { id: 'd', status: 'stopped' }, + { id: 'e', status: 'error' }, + ]), + sessionOrder: ['w', 'i', 'd', 'e'], + cases: CASES, + }); + + expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([ + ['working', 'working'], + ['idle', 'idle'], + ['done', 'done'], + ['error', 'error'], + ]); + }); + + it('labels a row with its case and a short backend badge', () => { + const app = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'a', name: 'w1-claudeman', mode: 'codex' }]), + sessionOrder: ['a'], + cases: CASES, + }); + + const [row] = app.buildHomeSessionRows(); + expect(row.caseName).toBe('claudeman'); + expect(row.modeBadge).toBe('cx'); + // claude is the default backend and gets no badge — the strip does the same. + const plain = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'a', mode: 'claude' }]), + sessionOrder: ['a'], + cases: CASES, + }); + expect(plain.buildHomeSessionRows()[0].modeBadge).toBe(''); + }); +}); + +describe('home sessions column: gate', () => { + it('renders on a wide desktop', () => { + const app = loadHomeSessionsApp({}, 1512); + expect(app.shouldShowHomeSessions()).toBe(true); + }); + + it('stays out of a window too narrow to hold it beside the centered content', () => { + // Absolutely positioned: below the gate it would overlap the search panel + // rather than push it aside. + expect(loadHomeSessionsApp({}, 1100).shouldShowHomeSessions()).toBe(false); + expect(loadHomeSessionsApp({}, 1179).shouldShowHomeSessions()).toBe(false); + expect(loadHomeSessionsApp({}, 1180).shouldShowHomeSessions()).toBe(true); + }); + + it('yields to the phone overview, which already lists the same sessions', () => { + const app = loadHomeSessionsApp({}, 390); + expect(app.shouldUseMobileOverview()).toBe(true); + expect(app.shouldShowHomeSessions()).toBe(false); + }); + + it('stays out of a popped-out solo window', () => { + expect(loadHomeSessionsApp({ isSoloWindow: true }, 1512).shouldShowHomeSessions()).toBe(false); + }); +}); + +describe('home sessions column: wiring', () => { + const js = readFileSync(resolve(PUBLIC, 'home-sessions.js'), 'utf8'); + const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); + const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + + it('keeps the JS width gate and the CSS media query in agreement', () => { + // Two gates for one decision: the JS one hides the element, the CSS one is + // the backstop for a resize that outruns the matchMedia listener. Drift + // means a column that overlaps the welcome content at some widths. + const jsMin = Number(/HOME_SESSIONS_MIN_WIDTH = (\d+)/.exec(js)?.[1]); + const cssMax = Number(/@media \(max-width: (\d+)px\) \{\s*\.home-sessions \{/.exec(css)?.[1]); + expect(jsMin).toBeGreaterThan(0); + expect(cssMax).toBe(jsMin - 1); + }); + + it('re-asserts [hidden] over the flex display', () => { + // .home-sessions is display:flex, which defeats the `hidden` attribute — the + // module's only visibility lever — unless this rule exists. + expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/); + }); + + it('reuses the tab-load spinner rather than declaring a second one', () => { + // The working ring is the same motion a tab shows while it loads, on both + // home screens. Re-declaring the keyframes here is how they drift apart. + expect(js).toContain('tab-load-spin'); + expect(css).toMatch(/\.home-sessions-dot--working::after[\s\S]*?animation: tab-load-spin/); + expect(css).not.toMatch(/@keyframes home-sessions-load-spin/); + const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); + expect(mobileCss).toMatch(/\.mobile-overview-dot--working::after[\s\S]*?animation: tab-load-spin/); + }); + + it('gives the working dot the same green halo on both home screens', () => { + const halo = /box-shadow: 0 0 8px 2px color-mix\(in srgb, var\(--green\) 55%, transparent\)/; + expect(css).toMatch(halo); + expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(halo); + }); + + it('ships the container hidden, inside the welcome overlay, loaded after mobile-overview.js', () => { + expect(html).toMatch(/