mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6570e22d | ||
|
|
0aafabd28d | ||
|
|
4add38c4b1 | ||
|
|
e6df0c4094 | ||
|
|
87e787e934 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
Read My Mind phase 1: per-case intent profiles (docs/readmymind-plan.md). Codeman can now capture the prompts a user actually submits (from the Claude session transcript, opt-in via the new synced readMyMindEnabled setting, default OFF) into a per-case intent profile alongside user-stated goals, stored in ~/.codeman/intents.json (mode 0600, never searched). New endpoints GET/PUT/DELETE /api/sessions/:id/intent (ownership-scoped, strict schemas), a transcript:user_prompt event on TranscriptWatcher, and agent-skill coverage (SKILL.md recipe + endpoints.md rows) so agents can read and record the user's intent. Groundwork for the phase-2 predictor button: nothing is ever auto-sent.
|
||||
@@ -1,5 +1,18 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 161f1da: Read My Mind phase 1: per-case intent profiles (docs/readmymind-plan.md). Codeman can now capture the prompts a user actually submits (from the Claude session transcript, opt-in via the new synced readMyMindEnabled setting, default OFF) into a per-case intent profile alongside user-stated goals, stored in ~/.codeman/intents.json (mode 0600, never searched). New endpoints GET/PUT/DELETE /api/sessions/:id/intent (ownership-scoped, strict schemas), a transcript:user_prompt event on TranscriptWatcher, and agent-skill coverage (SKILL.md recipe + endpoints.md rows) so agents can read and record the user's intent. Groundwork for the phase-2 predictor button: nothing is ever auto-sent.
|
||||
- Home screen and phone touch targets.
|
||||
|
||||
The desktop welcome screen now lists your open tabs as a vertical column down its left gutter, which was previously dead space: one row per live session plus any saved web tabs, in tab order so the row badges match Alt+1..9, with case, backend and state on each row. Clicking a row enters that session. The column is width-gated (1180px and up) and never moves the centered welcome content.
|
||||
|
||||
Working state now reads the same everywhere it appears. A busy session shows a pulsing green dot ringed by the same spinner a tab draws while it loads, with a green halo, on the desktop home column, the phone home screen and the tab strip alike. Phone tabs got the bigger 9px glowing dot for the same reason.
|
||||
|
||||
Phone touch targets: the brand "C" that returns you to the home screen was roughly a 12x13px hit area, well under the 44px minimum. It is now a real 44x44 button, and the phone header grew from 36px to 44px to make that possible, which gives every other header control the same 8px. The simple keyboard accessory bar also swaps /clear for Tab (/clear and /compact stay in the extended bar), flushing locally buffered text to the terminal first so completion applies to what you just typed.
|
||||
|
||||
## 1.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.16.0 (must match `package.json`)
|
||||
**Version**: 1.16.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -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 `<html>`. 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-<backend>` / `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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.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,
|
||||
|
||||
@@ -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<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;
|
||||
},
|
||||
});
|
||||
@@ -387,6 +387,9 @@
|
||||
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
|
||||
Phone: '手机',
|
||||
|
||||
// Desktop home screen tab column (home-sessions.js)
|
||||
'Open tabs': '打开的标签',
|
||||
|
||||
// Session/case dialogs
|
||||
'Session Options': '会话选项',
|
||||
'Session Name': '会话名称',
|
||||
|
||||
@@ -322,6 +322,11 @@
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<!-- Open tabs as a vertical column in the left gutter (home-sessions.js).
|
||||
Absolutely positioned so the centered content below never moves, and
|
||||
therefore only rendered where the gutter is wider than the column;
|
||||
ships `hidden` and only that module reveals it. -->
|
||||
<aside class="home-sessions" id="homeSessions" hidden></aside>
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
@@ -2769,6 +2774,7 @@
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
+56
-10
@@ -350,16 +350,53 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Phones get a 44px header, up from 36px. Every header control is a touch
|
||||
target and 44px is the floor for one; the brand "C" that gets you home is
|
||||
the one that matters most. Redefined as the TOKEN rather than a literal so
|
||||
the panels positioned off `var(--header-height)` (file browser, insights,
|
||||
plan overlays in styles.css) follow it instead of drifting 8px under the
|
||||
header. Costs 8px of terminal height on a phone. */
|
||||
:root {
|
||||
--header-height: 44px;
|
||||
}
|
||||
|
||||
/* 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, which is taller than the
|
||||
header's padding box; centred by the header's `align-items: center` above,
|
||||
that overflow is symmetric and the button lands flush with both edges.
|
||||
Do not "fix" it with `height: 100%`: the header sets min/max-height and no
|
||||
height, so the percentage has no definite containing block to resolve
|
||||
against and silently falls back to auto. */
|
||||
}
|
||||
|
||||
/* 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 44x44 button: 44px wide, and the full height of the phone
|
||||
header, which is itself 44px for exactly this reason. 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 {
|
||||
@@ -404,8 +441,12 @@ html.mobile-init .file-browser-panel {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
/* styles.css top-aligns header children. That read as centred while the bar
|
||||
was 36px and its contents ~31px; in a 44px bar it leaves a visible gap
|
||||
under everything. */
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.3rem;
|
||||
padding-left: calc(0.3rem + var(--safe-area-left));
|
||||
padding-right: calc(0.3rem + var(--safe-area-right));
|
||||
@@ -420,8 +461,8 @@ html.mobile-init .file-browser-panel {
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
.ios-device .header {
|
||||
padding-top: calc(0.15rem + var(--safe-area-top));
|
||||
min-height: calc(36px + var(--safe-area-top));
|
||||
max-height: calc(36px + var(--safe-area-top));
|
||||
min-height: calc(var(--header-height) + var(--safe-area-top));
|
||||
max-height: calc(var(--header-height) + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
@@ -431,11 +472,13 @@ html.mobile-init .file-browser-panel {
|
||||
when keyboard is visible, and resetLayout() clears the inline
|
||||
style to re-expose this CSS value. */
|
||||
.app {
|
||||
padding-top: 42px;
|
||||
/* Header height plus its 1px border and a little slack. Derived from the
|
||||
token so the offset cannot fall out of step with the bar it clears. */
|
||||
padding-top: calc(var(--header-height) + 6px);
|
||||
}
|
||||
|
||||
.ios-device .app {
|
||||
padding-top: calc(42px + var(--safe-area-top));
|
||||
padding-top: calc(var(--header-height) + 6px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
.main {
|
||||
@@ -2690,10 +2733,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, any> = {}, 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<Record<string, any>>) {
|
||||
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(/<aside class="home-sessions" id="homeSessions" hidden><\/aside>/);
|
||||
const overlayStart = html.indexOf('id="welcomeOverlay"');
|
||||
const aside = html.indexOf('id="homeSessions"');
|
||||
const content = html.indexOf('class="welcome-content"');
|
||||
expect(overlayStart).toBeGreaterThan(-1);
|
||||
expect(aside).toBeGreaterThan(overlayStart);
|
||||
expect(aside).toBeLessThan(content);
|
||||
// Load order: the module reuses prototype methods installed by
|
||||
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
||||
// files are named in explanatory comments earlier in the document.
|
||||
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
||||
});
|
||||
});
|
||||
@@ -143,3 +143,48 @@ describe('Mobile header button policy (static guard)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The flip side of the policy above: the ONE header control phones do keep has
|
||||
// to be pressable. The brand "C" is the way back to the home screen and was a
|
||||
// 0.85rem inline span — roughly a 12x13px target, well under the 44px minimum.
|
||||
describe('Phone home button tap target (static guard)', () => {
|
||||
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
|
||||
|
||||
/** Declarations applying to `.header-brand .logo` inside a phone media query. */
|
||||
function phoneLogoDecls(): Map<string, string> {
|
||||
const decls = new Map<string, string>();
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
if (!/\.header-brand\s+\.logo\s*$/.test(rule.selector)) return;
|
||||
rule.walkDecls((decl) => decls.set(decl.prop, decl.value));
|
||||
});
|
||||
});
|
||||
return decls;
|
||||
}
|
||||
|
||||
it('gives the brand button a 44x44 hit area on phones', () => {
|
||||
const decls = phoneLogoDecls();
|
||||
expect(decls.get('min-width'), 'the "C" home button needs an explicit 44px min-width on phones').toBe('44px');
|
||||
// A bare inline span ignores width entirely — the box only exists once it
|
||||
// stops being inline.
|
||||
expect(decls.get('display')).toBe('inline-flex');
|
||||
// The other axis is the header's, so the two have to be read together: the
|
||||
// button is only 44 tall because the phone header is.
|
||||
expect(decls.get('height')).toBe('var(--header-height)');
|
||||
});
|
||||
|
||||
it('keeps the phone header at 44px, the height that makes that target square', () => {
|
||||
// The bar was 36px. Shrinking it again silently takes 8px back off every
|
||||
// header touch target, the home button included.
|
||||
let phoneHeaderHeight: string | undefined;
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
if (rule.selector.trim() !== ':root') return;
|
||||
rule.walkDecls('--header-height', (decl) => (phoneHeaderHeight = decl.value.trim()));
|
||||
});
|
||||
});
|
||||
expect(phoneHeaderHeight, '--header-height must be redefined for phones in mobile.css').toBe('44px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -775,6 +775,76 @@ describe('Virtual Keyboard', () => {
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||
// by #244. selectSession() ends with scrollToLastNonEmptyLine(), which parks
|
||||
// the viewport ABOVE the bottom for any session whose buffer is taller than
|
||||
// the screen and ends in blank rows, i.e. every real session after a tab
|
||||
// switch. A tap-routing scheme that treats "viewport is scrolled up" as a
|
||||
// reason to blur strands document.activeElement on <body> with no way to
|
||||
// raise the keyboard, and the prompt row is no exception. Suppressing the
|
||||
// MOUSE REPORT while scrolled up is correct and pinned below; suppressing
|
||||
// FOCUS is not. Measured against PR #244 on 2026-08-09: body vs textarea.
|
||||
//
|
||||
// Must be a dispatched gesture: calling the touchend handler directly
|
||||
// bypasses touchstart's preventDefault, which is half of what closes the
|
||||
// focus path, so a direct call reports the right intent and still misses.
|
||||
it('keeps the terminal input focusable after a tab switch parks the viewport off-bottom', async () => {
|
||||
const probe = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-offbottom-tap-test';
|
||||
app.sessions.set('mobile-offbottom-tap-test', {
|
||||
id: 'mobile-offbottom-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
|
||||
// Taller than the viewport, ending in the trailing blank rows that make
|
||||
// scrollToLastNonEmptyLine() stop short of the bottom.
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= app.terminal.rows * 3; i++) lines.push(`Transcript row ${i}`);
|
||||
lines.push('', '❯ ', '', '');
|
||||
await new Promise<void>((resolve) => app.terminal.write(lines.join('\r\n'), resolve));
|
||||
|
||||
app.scrollToLastNonEmptyLine(); // what selectSession() does on every tab switch
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
const buffer = app.terminal.buffer.active;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * 5.5,
|
||||
atBottom: buffer.viewportY >= buffer.baseY,
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe).not.toBeNull();
|
||||
// The guard only means anything if the viewport really did park off-bottom.
|
||||
expect(probe!.atBottom).toBe(false);
|
||||
|
||||
await page.touchscreen.tap(probe!.x, probe!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
// SGR coordinates are meaningless off-bottom, so the tap must stay silent.
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||
const calls = await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||
|
||||
Reference in New Issue
Block a user