mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f428e687 | ||
|
|
19aabe34d2 | ||
|
|
869a507482 | ||
|
|
854bcb99aa | ||
|
|
9ee6bf113b | ||
|
|
66d4c483c7 | ||
|
|
ff13234b3d | ||
|
|
0af80b417c |
@@ -0,0 +1,80 @@
|
|||||||
|
# Contributing to Codeman
|
||||||
|
|
||||||
|
Thanks for wanting to help! Codeman is a small project with a fast loop: issues usually get a response within a day, good PRs get reviewed quickly, and every release credits its contributors and bug reporters by name in the release notes. This guide gets you from clone to merged PR without stepping on the traps.
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
1. **Bugs**: open an issue with your OS, install method (installer / npm / git clone), browser, and which CLI + version the session was running.
|
||||||
|
2. **Questions and ideas**: use [Discussions](https://github.com/Ark0N/Codeman/discussions), not issues.
|
||||||
|
3. **Small fixes** (docs, typos, a new skin, a translation): just send the PR.
|
||||||
|
4. **Anything bigger**: open an issue or Discussion first and get a nod before building. Codeman has strong architectural invariants, and a design chat up front is what turns a big idea into a merged PR instead of a stalled one. This flow works: features like Clone Repo (#236) went idea, then design discussion, then review, then shipped.
|
||||||
|
5. **Security issues**: never a public issue. See [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Dev setup
|
||||||
|
|
||||||
|
Requirements: Node.js 22+ (see `.nvmrc`), tmux, and at least one supported agent CLI on your PATH (Claude Code is the primary one).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Ark0N/Codeman.git
|
||||||
|
cd Codeman
|
||||||
|
npm install # postinstall builds the vendored xterm addon bundles
|
||||||
|
npm run dev # dev server on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend is plain JS served from `src/web/public/` with no bundler in dev: edit a `.js`/`.css` file and reload the page. The one exception is `index.html`, which is read once at server start, so markup changes need a server restart.
|
||||||
|
|
||||||
|
## Before you push
|
||||||
|
|
||||||
|
CI runs all of these, so save yourself a round trip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck # tsc --noEmit, strict mode
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- test/<file>.test.ts # one file (the normal way)
|
||||||
|
npm run test:ci # the full CI sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never run bare `npm test`.** The default config includes browser-driven Playwright suites that need a live server, Chromium, and environment-specific baselines; they will hang or fail on a normal machine. `test:ci` is the honest "run everything" command, it is exactly what CI runs.
|
||||||
|
|
||||||
|
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
||||||
|
|
||||||
|
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||||
|
|
||||||
|
## Finding your way around
|
||||||
|
|
||||||
|
- Every source file starts with a `@fileoverview` JSDoc block. Read it before diving into the file, it is the map.
|
||||||
|
- [`CLAUDE.md`](../CLAUDE.md) at the repo root is the densest architecture primer in the repo. It is written for AI coding agents, but the invariants and gotchas in it apply to humans exactly the same, and most review feedback on PRs traces back to something already written there.
|
||||||
|
- Deep mechanisms and the history behind each rule live in [`docs/architecture-invariants.md`](../docs/architecture-invariants.md).
|
||||||
|
- Third-party extension surfaces are documented in [`docs/extending-codeman.md`](../docs/extending-codeman.md).
|
||||||
|
|
||||||
|
## Great first contributions
|
||||||
|
|
||||||
|
These are well-fenced areas where a first PR is genuinely easy to get right:
|
||||||
|
|
||||||
|
- **A new theme skin.** A skin is four things kept in sync: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist and the settings picker (both in `index.html`). `test/skin-themes.test.ts` statically checks the sync, so if the test passes, your skin works.
|
||||||
|
- **A new language.** `src/web/public/i18n.js` is dependency-free, English is the canonical source, and `zh-CN` is a complete example to copy. Add your language's entries and register it in `SUPPORTED_LANGUAGES`.
|
||||||
|
- **Docs.** If you got stuck on something and then figured it out, the sentence that would have unstuck you is a PR.
|
||||||
|
- Anything labeled [`good first issue`](https://github.com/Ark0N/Codeman/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||||
|
|
||||||
|
Bigger extension points worth discussing first: new CLI backends (the pluggable resolver pattern has absorbed six CLIs so far; `docs/extending-codeman.md` and `docs/opencode-integration.md` show the shape), and real-device testing reports, especially mobile, which always find things emulation cannot.
|
||||||
|
|
||||||
|
## PR expectations
|
||||||
|
|
||||||
|
- **One change per PR.** Small and focused reviews fast; a grab-bag stalls.
|
||||||
|
- Target the `master` branch.
|
||||||
|
- **Keep your branch mergeable.** A PR with conflicts silently gets no CI runs at all (GitHub quirk), so rebase or merge master when conflicts appear.
|
||||||
|
- Include or update tests when you change behavior. Route handlers have a lightweight pattern in `test/routes/` using `app.inject()` (no live server needed).
|
||||||
|
- Formatting is Prettier with a deliberately narrow scope (`npm run format`), several frontend files are hand-formatted on purpose and excluded via `.prettierignore`. Don't "fix" a file by adding it back into Prettier's scope.
|
||||||
|
- Don't bump versions or touch `CHANGELOG.md`; releases are handled by the maintainer via changesets after merge.
|
||||||
|
- AI-assisted contributions are welcome (much of Codeman is built that way), with one condition: you must understand what you're submitting and have actually run it. "The model said it works" is not a test.
|
||||||
|
|
||||||
|
## Conduct
|
||||||
|
|
||||||
|
Be kind, be direct, assume good faith. Report unacceptable behavior privately via the contact in [SECURITY.md](SECURITY.md).
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.18.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Faster agent-skill workers, retuned multi-color lineage arcs, a per-tab pop-out option, reliable tab alerts, and the community launch.
|
||||||
|
- Agent skill: SKILL.md now forbids the standalone preamble check and the pre-spawn reconnaissance turns that were costing whole model turns; the same two-worker spawn measured at 28.6s end to end now runs 20.2s cold and 12.8s warm, with the spawn machinery itself unchanged.
|
||||||
|
- Session lineage lines: arcs now hang from the tab strip's bottom edge (dip cap 104px to 64px, no stacked row offsets), fixing the deep bow on wrapped tab strips and keeping same-row arcs off the second row's tab labels; each spawned worker's arc gets its own color (skin blue first, then matrix green, pink, violet, red, turquoise, orange), assigned per child and stable across re-renders.
|
||||||
|
- Session Options > Session: new "Pop-out button on this tab" per-tab override on top of the general App Settings toggle (per-device).
|
||||||
|
- Tab alerts: pending permission/question alerts now survive page reloads regardless of the Approvals Inbox setting (the alert state machine seeds from the server-side approval store on every load), stay visible on the selected tab until the prompt is actually resolved (the alert paints on a ::before overlay the active tab's styling cannot bury), and render as a steady red/yellow ring with glow and a colored status dot instead of a blink that spent half of every cycle looking like a normal tab. The README carries a live capture of the new alerts.
|
||||||
|
- Community launch: README Community section, .github/CONTRIBUTING.md (dev setup, test safety, great first contributions, PR expectations), and GitHub Discussions.
|
||||||
|
- docs: worker warm-pool design sketch with the measured baselines.
|
||||||
|
|
||||||
## 1.18.3
|
## 1.18.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch 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.
|
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.18.3 (must match `package.json`)
|
**Version**: 1.18.4 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -204,13 +204,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. Colors cycle per CHILD in first-seen order from `CodemanLineage.COLORS` (first entry empty = the skin-tuned `--session-blue`; the rest vivid fixed hexes), set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||||
|
|
||||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||||
|
|
||||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||||
|
|
||||||
@@ -262,7 +262,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**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).
|
**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 rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail 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 rail 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. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. 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; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see below), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail 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 rail 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. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. 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; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||||
|
|
||||||
|
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`, and ⚠️ **the tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for). ⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
|
||||||
|
|
||||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,14 @@ The title is templated into the served HTML on first byte, so it's correct from
|
|||||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||||
|
|
||||||
|
### Tab Alerts
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
||||||
@@ -1035,6 +1043,12 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, setup help, and ideas live in [GitHub Discussions](https://github.com/Ark0N/Codeman/discussions): the [Q&A section](https://github.com/Ark0N/Codeman/discussions/categories/q-a) answers the most common ones (phone access, overnight runs, updating), and the roadmap gets decided in [Ideas](https://github.com/Ark0N/Codeman/discussions/categories/ideas). Bugs go to [issues](https://github.com/Ark0N/Codeman/issues); reports usually get a response within a day, and every release credits its reporters and contributors by name. Want to contribute? [CONTRIBUTING.md](.github/CONTRIBUTING.md) has the map: skins, translations, and docs make great first PRs, and bigger features start life as a Discussion. And if you're proud of your rig, post it in [Show and tell](https://github.com/Ark0N/Codeman/discussions/300).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Codebase Quality
|
## Codebase Quality
|
||||||
|
|
||||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
# Warm worker pool: sub-second claude worker spawns
|
||||||
|
|
||||||
|
Design sketch. Status: **proposed**, not started. Opt-in (`workerPoolSize`, default 0 = off); a user who touches nothing sees no change at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem and numbers
|
||||||
|
|
||||||
|
Measured against prod 1.18.3 on 2026-08-15, AFTER the SKILL.md fast-path hardening
|
||||||
|
(no recon turns), on the identical "spawn two codeman workers" prompt:
|
||||||
|
|
||||||
|
- **Cold orchestrator** (fresh session, skill loaded from disk): **20.2 s** prompt to
|
||||||
|
final report. Breakdown: 3.9 s Skill-load turn, 6.4 s generating the one fused Bash
|
||||||
|
call, **4.4 s spawn call**, 5.5 s summary. Tabs appeared at 10.5 s.
|
||||||
|
- **Warm orchestrator** (skill already in context, no Skill turn): **12.8 s**, spawn
|
||||||
|
call 6.0 s.
|
||||||
|
- Inside the spawn call, session + tmux + case creation is cheap: the workers (and
|
||||||
|
their tabs) appeared 0.2-1.7 s in, both siblings within ~350 ms of each other. The
|
||||||
|
remaining **~4-5 s is claude CLI boot plus the composer-readiness wait**, paid again
|
||||||
|
on every cold spawn. That slice is the pool's entire target.
|
||||||
|
|
||||||
|
The honest framing after the hardening: model turns dominate the skill flow (~16 of
|
||||||
|
20 cold seconds) and no server feature can shrink those. The pool attacks the
|
||||||
|
tool-side floor, and it has two distinct beneficiaries:
|
||||||
|
|
||||||
|
- **Skill/API orchestration**: the spawn call drops from ~4.4-6 s to ~1 s. Cold runs
|
||||||
|
land ~16-17 s, warm ~8 s. Tab appearance barely moves for this consumer (it is
|
||||||
|
model-turn-bound at ~10 s cold / ~4 s warm).
|
||||||
|
- **The UI Run button and direct quick-start callers**: a click today waits the full
|
||||||
|
boot + readiness before the worker can take a prompt; a pooled claim makes the tab
|
||||||
|
appear and the worker READY sub-second. This is the most visible win, and it
|
||||||
|
involves no skill at all.
|
||||||
|
|
||||||
|
Target: hand out an already-ready worker in **under 1 s**.
|
||||||
|
|
||||||
|
## 2. Shape
|
||||||
|
|
||||||
|
A new `src/worker-pool.ts` singleton service, following the `CronService` pattern: it **reuses the existing session layer** (`SessionManager` create + the normal spawn path) and never rebuilds tmux logic.
|
||||||
|
|
||||||
|
A pool member is a real claude `Session`, pre-spawned in a reserved scratch case (`~/codeman-cases/.pool-<n>`, created with the standard scaffold + hooks), already past readiness: composer drawn, hooks installed, preamble file seeded. It sits idle at the composer costing no tokens.
|
||||||
|
|
||||||
|
The claim happens **transparently inside `POST /api/quick-start`**: when a request is pool-eligible (§3) and a healthy member is available, quick-start returns that member instead of cold-spawning. The agent skill, the UI Run button, and every existing caller change **nothing**. Ineligible or pool-empty requests cold-spawn exactly as today, so the pool is only ever a fast path, never a behavior change.
|
||||||
|
|
||||||
|
## 3. Eligibility gate
|
||||||
|
|
||||||
|
Claim only when ALL of these hold; otherwise fall through to a cold spawn:
|
||||||
|
|
||||||
|
- `mode === 'claude'` (external CLIs have different readiness semantics and inject secrets via `tmux setenv` at spawn; out of scope).
|
||||||
|
- No `envOverrides`, no `CLAUDE_CONFIG_DIR`, and `modelOverride`/`effort` unset or equal to what the pool member was spawned with. Env vars flow at spawn time and cannot be applied to a running CLI.
|
||||||
|
- The requested case is **fresh** (does not exist yet). A linked case, an existing directory, a remote-SSH case, or a Docker case means the caller wants a specific workspace; pool members cannot provide one.
|
||||||
|
- Single-user mode, or the requester owns the pool (v1 ships single-user only; §11).
|
||||||
|
|
||||||
|
## 4. What a claim does (~300 ms)
|
||||||
|
|
||||||
|
1. Pop a ready member (in-memory check-and-remove; Node's single thread makes this atomic, so two concurrent quick-starts cannot claim the same member).
|
||||||
|
2. Health-probe it: `isPaneDead` (the existing ~750 ms-cached mux probe) plus one `capturePaneText` asserting a clean composer. A dead, limit-paused, or dirty member is recycled, and the claim tries the next member or falls through to cold spawn.
|
||||||
|
3. Rename the session to the normal `w<n>-<case>` name, set `parentSessionId` via the existing `resolveParentSessionId()`, clear the pool flag, persist state.
|
||||||
|
4. Emit `session_created` **now** (it was suppressed at warm-spawn time, §5). The tab appears here, sub-second after the request.
|
||||||
|
5. Return the **pool case** as `casePath`/`workingDir` and do NOT create a directory under the requested name: an empty dir the worker's CLI does not run in is a trap (files written there are invisible to the worker at cwd), and the agent skill greps the RETURNED `casePath` for Codeman hooks before trusting the worker, so the response must point at the directory that really carries them.
|
||||||
|
6. Kick a background refill (§6).
|
||||||
|
|
||||||
|
**The identity wrinkle, stated honestly:** the session id, `CODEMAN_SESSION_ID` inside the pane, the seeded preamble file, and the CLI's cwd are all fixed at warm-spawn and survive the claim unchanged. So a claimed worker's `workingDir` is the pool dir, not `~/codeman-cases/<requested-name>`; the requested name is a **label**. The API must report the truthful `workingDir`. Transcript projHash, response viewer, subagent windows, and Read My Mind all key off the real path and keep working precisely because we do not lie about it. This is acceptable for the dominant use (ephemeral skill workers that are deleted after answering) and is documented in the skill; a caller that needs the real case as cwd is by definition not pool-eligible.
|
||||||
|
|
||||||
|
**Verified skill compatibility (zero preamble changes).** Checked against the shipped 1.18.3 preamble: `spawn_worker`'s readiness probe (`_composer_up`) is a `wait-output` call with `from=buffer`, which scans output that already scrolled past before blocking, so a pooled member's long-since-drawn composer matches instantly instead of stranding a fresh-stream wait. The trust-dialog fallback never fires (members passed the dialog at warm time), and the hooks grep passes because the pool case carries the standard scaffold. Pooled and cold spawns are indistinguishable to the skill except in speed and the additive `pooled: true`.
|
||||||
|
|
||||||
|
## 5. Hiding pre-claim members
|
||||||
|
|
||||||
|
Pool members must be invisible until claimed or they read as ghost tabs. `Session.isPoolWorker` gates, at minimum:
|
||||||
|
|
||||||
|
- `GET /api/sessions` and `GET /api/sessions/unified` (and therefore the Cmd+K palette and the session-history-index snapshot that feeds `/api/search`).
|
||||||
|
- `session_created` SSE at warm-spawn (deferred to claim time). All other per-session SSE for a hidden member is suppressed at the broadcast call sites it would reach.
|
||||||
|
- Push notifications and the Approvals Inbox (a warm member showing a trust dialog must recycle, not notify).
|
||||||
|
- The phone overview / home rail (both render from the session list, so the list filter covers them).
|
||||||
|
- The lifecycle log records `pool_warm` / `pool_claim` events rather than user-visible session history.
|
||||||
|
|
||||||
|
`maxSessions` (50) **counts** pool members, and the pool refuses to warm within `poolSize + 2` of the cap so it can never starve real session creation.
|
||||||
|
|
||||||
|
## 6. Refill, TTL, drain
|
||||||
|
|
||||||
|
- **Refill** after each claim, debounced, at most one warm spawn in flight (a claim burst falls back to cold spawns rather than forking N CLIs at once; same reasoning as the document-conversion limiter).
|
||||||
|
- **TTL ~30 min**: recycle members older than that so they cannot drift from settings, hooks config, or a self-updated CLI on disk.
|
||||||
|
- **Drain and respawn** on: `claudeModel` change, hooks-config regeneration, self-update, and `workerPoolSize` changes. On server shutdown, kill pool sessions (they are stateless and ours). On boot, kill any leftover `.pool-*` tmux sessions found via `mux-sessions.json` rather than adopting them; adoption buys nothing for stateless members.
|
||||||
|
|
||||||
|
## 7. Failure modes
|
||||||
|
|
||||||
|
| Failure | Handling |
|
||||||
|
| --- | --- |
|
||||||
|
| Member died idle (PTY exit, crash) | Health probe at claim catches it; recycle + try next; PTY-exit breaker applies unchanged |
|
||||||
|
| Member hit a usage limit while idle | `isLimitPaused` members are never handed out; recycle |
|
||||||
|
| Composer dirty (stray keystrokes, dialog) | `capturePaneText` probe refuses it; recycle |
|
||||||
|
| Claim race | Impossible by construction (synchronous in-memory pop) |
|
||||||
|
| Warm spawn itself fails | Log, back off, retry on next refill tick; pool empty just means cold spawns |
|
||||||
|
|
||||||
|
## 8. Cost
|
||||||
|
|
||||||
|
Each warm member is one tmux session + one idle claude process (order 150-300 MB RSS; **measure before defaulting the size above 0**, including whether an idle CLI makes any background requests via its statusline refresh). Zero token cost while idle. Suggested starting size for users who opt in: 2.
|
||||||
|
|
||||||
|
## 9. Settings and API surface
|
||||||
|
|
||||||
|
- `workerPoolSize` (int, 0-4, default 0): **synced** setting in `SettingsUpdateSchema`. The watcher that resizes the pool on `PUT /api/settings` must resolve from `merged`, never the raw body (the partial-PUT gotcha in CLAUDE.md).
|
||||||
|
- One internal status endpoint, `GET /api/worker-pool` (size, members' ages, claims served, fall-through count), for debugging. No new SSE events: the claim emits the existing `session_created`.
|
||||||
|
- No new public API semantics: `/api/quick-start`'s contract is unchanged apart from a `pooled: true` field in the response data, which is additive.
|
||||||
|
|
||||||
|
## 10. Considered and rejected
|
||||||
|
|
||||||
|
- **Renaming the pool case dir to the requested name at claim.** Linux keeps the process cwd working across the rename (inode-based), but claude computed its transcript projHash from the old path string at boot, so transcripts, subagent windows, and the response viewer go blind, the exact failure mode the `CLAUDE_CONFIG_DIR` docs warn about. Truthful label semantics (§4) beat a clever rename.
|
||||||
|
- **A new explicit claim endpoint.** Transparency inside quick-start means the skill, the UI, and every existing script get the speedup with zero changes; a new endpoint means new docs, new drift, and callers that must know the pool exists.
|
||||||
|
- **Pooling external CLI modes.** Readiness there is output stabilization, secrets ride `tmux setenv` at spawn, and codex/pi composer semantics differ per CLI. Claude-only until someone measures a need.
|
||||||
|
- **Returning quick-start at creation instead of readiness (no pool).** Would move tabs earlier on cold spawns too, but `sendwait` immediately after would then race the composer; readiness is what makes immediate tasking safe, and the pool makes the whole question moot for eligible spawns.
|
||||||
|
|
||||||
|
## 11. Phasing
|
||||||
|
|
||||||
|
1. **v1**: single-user, claude-only, fixed-size pool, transparent claim, status endpoint. Everything above.
|
||||||
|
2. **v2**: per-owner pools for multi-user mode (pool members must carry an owner because ownership scoping is structural); possibly model-matched pools (one warm set per configured `claudeModel`).
|
||||||
|
3. **Explicitly out**: warming linked/repo cases (spawning where the work is has no hooks and is the skill's documented costliest mistake; a warm pool must not make it faster to reach).
|
||||||
|
|
||||||
|
## 12. Testing
|
||||||
|
|
||||||
|
- Unit: pool manager logic pure and mock-driven (eligibility gate, TTL, refill debounce, drain triggers), `MockSession` from `test/mocks/`.
|
||||||
|
- Route: `app.inject` on quick-start asserting claim vs cold-spawn per eligibility row in §3, plus the double-claim race (two concurrent injects, one pool member: exactly one `pooled: true`).
|
||||||
|
- Live: re-run the pinned baselines against a warmed beta instance. Before (2026-08-15, prod 1.18.3, post-hardening): cold orchestrator **20.2 s** / warm **12.8 s** end to end, spawn call 4.4-6.0 s. Acceptance: spawn call under 1 s, cold ~16-17 s, warm ~8-9 s, and a UI Run click to a READY worker in under 1 s.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.3",
|
"version": "1.18.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.3",
|
"version": "1.18.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.3",
|
"version": "1.18.4",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+25
-11
@@ -42,18 +42,22 @@ hundred-odd lines at the top of every call (a half-re-pasted preamble used to be
|
|||||||
single most likely way to break a run).
|
single most likely way to break a run).
|
||||||
|
|
||||||
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
||||||
1.18.3+), so the bootstrap is usually just loading it — the same two lines every later
|
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||||
call starts with:
|
later call opens with, and your first REAL call performs them anyway:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
If that passed, §0 is done: go straight to your job (§1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
loader, so when §1 is the job you can simply start there). Only when it reports
|
loader, so when §1 is the job, start there: the check rides the spawn call for free,
|
||||||
missing or stale, run the full block below once — and run it **verbatim**: paste it
|
and a standalone "preamble OK" call buys nothing while costing a full model turn
|
||||||
as-is, never re-type it, trim it, or "extract the parts you need". A hand-assembled
|
(measured live: a lone check plus the deliberation around it added ~6 s to a 28 s
|
||||||
|
two-worker run). §0 is done the moment any job call passes its opening check. Only
|
||||||
|
when a call reports missing or stale, run the full block below once — and run it
|
||||||
|
**verbatim**: paste it as-is, never re-type it, trim it, or "extract the parts you
|
||||||
|
need". A hand-assembled
|
||||||
preamble is the documented failure mode of this skill: one live run rebuilt it
|
preamble is the documented failure mode of this skill: one live run rebuilt it
|
||||||
"minimally" and lost the `X-Codeman-Parent-Session` header (every worker spawned with
|
"minimally" and lost the `X-Codeman-Parent-Session` header (every worker spawned with
|
||||||
no lineage arc in the web UI) and the fast-path functions (the spawn fell back to a
|
no lineage arc in the web UI) and the fast-path functions (the spawn fell back to a
|
||||||
@@ -273,14 +277,18 @@ plain-text 401: see §6 and [the symptom gallery](reference/endpoints.md#symptom
|
|||||||
block is the whole thing. Run it, report, and stop reading. §2 onward is for jobs this
|
block is the whole thing. Run it, report, and stop reading. §2 onward is for jobs this
|
||||||
does not cover; you are not being careless by not reading them.**
|
does not cover; you are not being careless by not reading them.**
|
||||||
|
|
||||||
Fill in the case names and the prompts. Everything below is `spawn_workers` /
|
Fill in the case names and the prompts, then run it as your FIRST Bash call: no
|
||||||
`sendwait` / `last_text` / `delete_session` from the §0 preamble, so there is nothing
|
standalone preamble check before it (line one below IS that check), and no
|
||||||
to assemble and no per-call body to hand-build.
|
reconnaissance. `ls ~/codeman-cases` answers nothing this block needs: invented
|
||||||
|
fresh names need no lookup, and `spawn_worker` refuses a name that already exists
|
||||||
|
rather than silently reusing it. Everything below is `spawn_workers` / `sendwait` /
|
||||||
|
`last_text` / `delete_session` from the §0 preamble, so there is nothing to assemble
|
||||||
|
and no per-call body to hand-build.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # one FRESH case name per worker
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
'reply with one line: your model name') # tasks, same order as N
|
'reply with one line: your model name') # tasks, same order as N
|
||||||
|
|
||||||
@@ -307,10 +315,16 @@ done; rm -rf "$D"
|
|||||||
|
|
||||||
Measured against a live 1.18.0 server: two cold workers spawned and ready in **6.3 s**,
|
Measured against a live 1.18.0 server: two cold workers spawned and ready in **6.3 s**,
|
||||||
both turns dispatched and both answers read in **4.0 s** more. If your run takes minutes,
|
both turns dispatched and both answers read in **4.0 s** more. If your run takes minutes,
|
||||||
the time went into deliberation, not the API. The three things that actually cost time:
|
the time went into deliberation, not the API. The four things that actually cost time:
|
||||||
|
|
||||||
- **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus
|
- **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus
|
||||||
`wait`, as above, makes N workers cost about what one costs.
|
`wait`, as above, makes N workers cost about what one costs.
|
||||||
|
- **Reconnaissance turns before the spawn.** A standalone preamble check, an
|
||||||
|
`ls ~/codeman-cases`, a `list_sessions` "to see what is there": each is a whole
|
||||||
|
model turn spent learning something this block already handles (line one performs
|
||||||
|
the preamble check, invented names need no listing, and `spawn_worker` refuses
|
||||||
|
collisions). A live two-worker run spent ~12 s of its 28 s total on exactly two
|
||||||
|
such turns; the API work in between was under 10 s.
|
||||||
- **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the
|
- **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the
|
||||||
preamble functions exist to end. Compose them; do not rebuild them. The tells that
|
preamble functions exist to end. Compose them; do not rebuild them. The tells that
|
||||||
you are rebuilding anyway: a `for` loop around `quick-start`, a poll on `.data.pid`,
|
you are rebuilding anyway: a `for` loop around `quick-start`, a poll on `.data.pid`,
|
||||||
|
|||||||
@@ -3992,7 +3992,7 @@ class CodemanApp {
|
|||||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||||
: (session.workingDir || '');
|
: (session.workingDir || '');
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -37,13 +37,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
async seedApprovals() {
|
async seedApprovals() {
|
||||||
if (!this.approvals) this.approvals = new Map();
|
if (!this.approvals) this.approvals = new Map();
|
||||||
this.approvals.clear();
|
this.approvals.clear();
|
||||||
if (this.approvalsInboxEnabled()) {
|
// ⚠ Fetch and re-arm the tab-alert state machine REGARDLESS of the inbox
|
||||||
const data = await this._apiJson('/api/approvals');
|
// setting. The server-side approval store runs unconditionally (only the
|
||||||
for (const item of (data && data.approvals) || []) {
|
// inbox SURFACES are opt-in), and the red/yellow tab alert predates the
|
||||||
this.approvals.set(item.id, item);
|
// inbox: gating the seed on the setting meant that with the inbox off, a
|
||||||
// Re-arm the tab alert state machine (idempotent set-add).
|
// reload landed with every alert store empty while a permission dialog sat
|
||||||
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
// blocking a session (owner report 2026-08-15: rail said NEEDS YOU from
|
||||||
}
|
// the live SSE event, the reloaded-elsewhere tab showed a plain green
|
||||||
|
// dot). Only populating `this.approvals` (bell/drawer/answer strips) stays
|
||||||
|
// behind the setting.
|
||||||
|
const data = await this._apiJson('/api/approvals');
|
||||||
|
const inboxOn = this.approvalsInboxEnabled();
|
||||||
|
for (const item of (data && data.approvals) || []) {
|
||||||
|
if (inboxOn) this.approvals.set(item.id, item);
|
||||||
|
// Re-arm the tab alert state machine (idempotent set-add).
|
||||||
|
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
||||||
}
|
}
|
||||||
this.renderApprovals();
|
this.renderApprovals();
|
||||||
},
|
},
|
||||||
@@ -68,14 +76,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_onApprovalResolved(info) {
|
_onApprovalResolved(info) {
|
||||||
if (!info || !info.id || !this.approvals) return;
|
if (!info || !info.id) return;
|
||||||
if (this.approvals.delete(info.id)) {
|
// Clear the matching tab alert UNCONDITIONALLY: the inbox resolves on more
|
||||||
// Clear the matching tab alert: the inbox resolves on more signals than
|
// signals than the hook handlers do (superseded, expired, answered from
|
||||||
// the hook handlers do (superseded, expired, answered from another
|
// another device), clearPendingHooks is a no-op when nothing is set, and
|
||||||
// device), and clearPendingHooks is a no-op when nothing is set.
|
// with the inbox setting OFF the item was never stored in `this.approvals`
|
||||||
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
// even though seedApprovals armed the alert — gating the clear on a map hit
|
||||||
this.renderApprovals();
|
// would strand that alert forever.
|
||||||
}
|
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
||||||
|
if (this.approvals?.delete(info.id)) this.renderApprovals();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────
|
||||||
|
|||||||
+128
-20
@@ -222,23 +222,35 @@ function computeTabScrollLeft(input) {
|
|||||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned
|
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||||
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||||
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||||
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||||
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||||
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||||
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||||
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||||
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||||
|
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||||
|
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||||
|
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||||
|
// drawn through them (the retune's own first draft had exactly that regression).
|
||||||
const LINEAGE_DIP_BASE_PX = 14;
|
const LINEAGE_DIP_BASE_PX = 14;
|
||||||
const LINEAGE_DIP_PER_PX = 0.085;
|
const LINEAGE_DIP_PER_PX = 0.06;
|
||||||
const LINEAGE_DIP_MIN_PX = 22;
|
const LINEAGE_DIP_MIN_PX = 22;
|
||||||
const LINEAGE_DIP_MAX_PX = 104;
|
const LINEAGE_DIP_MAX_PX = 64;
|
||||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||||
// apart bled into one thick band instead of reading as three separate lines.
|
// apart bled into one thick band instead of reading as three separate lines.
|
||||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
|
// Lineage palette, assigned per CHILD in first-seen order and cycled (session-lineage.js).
|
||||||
|
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||||
|
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||||
|
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||||
|
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||||
|
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||||
|
// terminal text on every skin.
|
||||||
|
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||||
|
|
||||||
function computeLineagePath(input) {
|
function computeLineagePath(input) {
|
||||||
const parent = input?.parent;
|
const parent = input?.parent;
|
||||||
@@ -269,18 +281,19 @@ function computeLineagePath(input) {
|
|||||||
const cBottom = cTop + ch;
|
const cBottom = cTop + ch;
|
||||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||||
|
|
||||||
// Both ends anchor on the tab BOTTOM, and the control points hang below whichever
|
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||||
// row is lower, so one formula covers a flat strip and a wrapped one.
|
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||||
|
// sitting above further rows (see the corridor note above the constants).
|
||||||
const span = Math.abs(cx - px);
|
const span = Math.abs(cx - px);
|
||||||
const rowDrop = Math.abs(cBottom - pBottom);
|
const stripBottom =
|
||||||
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||||
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
? Number(strip.top) + Number(strip.height)
|
||||||
// the row it crosses instead of grazing its bottom edge.
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||||
const dip =
|
const dip =
|
||||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||||
depth * LINEAGE_SIBLING_STEP_PX +
|
depth * LINEAGE_SIBLING_STEP_PX;
|
||||||
rowDrop;
|
const yc = baseline + dip;
|
||||||
const yc = Math.max(pBottom, cBottom) + dip;
|
|
||||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||||
return { d, endX: cx, endY: cBottom, sameRow };
|
return { d, endX: cx, endY: cBottom, sameRow };
|
||||||
}
|
}
|
||||||
@@ -424,6 +437,94 @@ function computeSseStale(input) {
|
|||||||
return now - lastMessageAt >= timeoutMs;
|
return now - lastMessageAt >= timeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home-screen session order: one comparator for both overviews.
|
||||||
|
//
|
||||||
|
// The phone overview (mobile-overview.js) and the desktop tab rail
|
||||||
|
// (home-sessions.js) list the same sessions, so they answer the same question
|
||||||
|
// and must answer it the same way: "which of these wants me next?".
|
||||||
|
//
|
||||||
|
// 1. Anything blocked on a human first (red question, then error, then a
|
||||||
|
// yellow idle prompt), longest-blocked at the top: a session that has been
|
||||||
|
// sitting on a permission dialog for 20 minutes is starving, one that
|
||||||
|
// raised it 5 seconds ago is not.
|
||||||
|
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
|
||||||
|
// likely to be finished, or stuck, by the time you look.
|
||||||
|
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
|
||||||
|
// running, the session that just finished is the one you came back for,
|
||||||
|
// and the one you abandoned yesterday sinks.
|
||||||
|
//
|
||||||
|
// So the tiebreak flips direction halfway down the list, and that is the point:
|
||||||
|
// for a state something is still doing, longer = more urgent; for a state
|
||||||
|
// something has stopped in, more recent = more relevant.
|
||||||
|
//
|
||||||
|
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
|
||||||
|
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
|
||||||
|
const SESSION_ACTIVITY_RANK = {
|
||||||
|
needs: 0,
|
||||||
|
error: 1,
|
||||||
|
waiting: 2,
|
||||||
|
working: 3,
|
||||||
|
idle: 4,
|
||||||
|
done: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** States still in progress, where the OLDEST stamp sorts first. */
|
||||||
|
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the row entered the state it is in.
|
||||||
|
*
|
||||||
|
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
|
||||||
|
* a Claude pane sitting at its composer prints nothing, so the end of the last
|
||||||
|
* turn is exactly when it went quiet.
|
||||||
|
*
|
||||||
|
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||||
|
* last-activity stamp is always "now" and would rank every running turn as
|
||||||
|
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
|
||||||
|
* persisted server-side and therefore stable across a Codeman restart. A
|
||||||
|
* working pane that has never submitted (spawned with its prompt on the command
|
||||||
|
* line, or an external CLI) falls back to last activity, which puts it at the
|
||||||
|
* short end of the running group rather than falsely at the head of it.
|
||||||
|
*/
|
||||||
|
function sessionActivityAnchor(row) {
|
||||||
|
const activeAt = Number(row && row.lastActivityAt) || 0;
|
||||||
|
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
|
||||||
|
return activeAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort comparator for one overview row against another.
|
||||||
|
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
|
||||||
|
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
|
||||||
|
*/
|
||||||
|
function compareSessionActivity(a, b) {
|
||||||
|
const rankA = SESSION_ACTIVITY_RANK[a.state];
|
||||||
|
const rankB = SESSION_ACTIVITY_RANK[b.state];
|
||||||
|
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
|
||||||
|
const atA = sessionActivityAnchor(a);
|
||||||
|
const atB = sessionActivityAnchor(b);
|
||||||
|
if (atA !== atB) {
|
||||||
|
// A row with no stamp at all gets no opinion: it sorts last either way
|
||||||
|
// rather than claiming to be the oldest (0) thing on the screen.
|
||||||
|
if (!atA) return 1;
|
||||||
|
if (!atB) return -1;
|
||||||
|
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
|
||||||
|
// the list is deterministic and cannot shuffle between renders.
|
||||||
|
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
return orderA - orderB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
|
||||||
|
function sortSessionsByActivity(rows) {
|
||||||
|
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -441,6 +542,7 @@ if (typeof window !== 'undefined') {
|
|||||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||||
|
COLORS: LINEAGE_COLORS,
|
||||||
};
|
};
|
||||||
window.CodemanConnectionLoss = {
|
window.CodemanConnectionLoss = {
|
||||||
compute: computeConnectionLossUi,
|
compute: computeConnectionLossUi,
|
||||||
@@ -450,6 +552,12 @@ if (typeof window !== 'undefined') {
|
|||||||
compute: computeSseStale,
|
compute: computeSseStale,
|
||||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
window.CodemanSessionOrder = {
|
||||||
|
RANK: SESSION_ACTIVITY_RANK,
|
||||||
|
anchor: sessionActivityAnchor,
|
||||||
|
compare: compareSessionActivity,
|
||||||
|
sort: sortSessionsByActivity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -5,8 +5,13 @@
|
|||||||
* The welcome screen centers ~560px of content in a window that is usually
|
* 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
|
* 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:
|
* 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
|
* one row per live tab.
|
||||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
*
|
||||||
|
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
|
||||||
|
* the phone overview uses: blocked on you first, then running longest-first,
|
||||||
|
* then quiet most-recently-quiet first. The number badge stays the tab-strip
|
||||||
|
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
|
||||||
|
* it names a shortcut, not a row position.
|
||||||
*
|
*
|
||||||
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
||||||
* positioned so the centered welcome content never moves, which means it can
|
* positioned so the centered welcome content never moves, which means it can
|
||||||
@@ -16,11 +21,13 @@
|
|||||||
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
||||||
* a fixed 256px card looks abandoned on a 2560px display.
|
* a fixed 256px card looks abandoned on a 2560px display.
|
||||||
*
|
*
|
||||||
* Each row carries when the session was FIRST CREATED and when it was LAST
|
* Each row carries when the session was FIRST CREATED and how long it has been
|
||||||
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
|
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
|
||||||
* session emits no event), so a slow clock refreshes them IN PLACE from the
|
* the value the order above is computed from, so the rail explains itself
|
||||||
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
|
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
|
||||||
* would restart every row's blink animation and its working ring.
|
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
|
||||||
|
* from the epoch-ms values parked on the elements, rather than re-rendering: a
|
||||||
|
* re-render would restart every row's blink animation and its working ring.
|
||||||
*
|
*
|
||||||
* The working state is deliberately identical to the phone's: a pulsing green
|
* 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
|
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||||
|
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||||
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
||||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||||
@@ -158,11 +166,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per live session, in the user's tab order. State classification is
|
* One row per live session, in overview order: whatever is blocked on you
|
||||||
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
* first, then whatever is running (longest turn first), then the quiet ones
|
||||||
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
|
||||||
* by urgency because it shows one screenful at a time, this column mirrors the
|
* (constants.js), shared with the phone overview, and state classification is
|
||||||
* tab strip so the number badges line up with Alt+1..9.
|
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
|
||||||
|
* neither disagree about what "working" means nor about what sorts first.
|
||||||
|
*
|
||||||
|
* `orderIndex` stays the position in the TAB STRIP, because that is what the
|
||||||
|
* number badge means (Alt+1..9). Once the rows are sorted those badges no
|
||||||
|
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
|
||||||
|
* not "how far down the list is it".
|
||||||
|
*
|
||||||
* @returns {Array<object>} row descriptors, ready to render
|
* @returns {Array<object>} row descriptors, ready to render
|
||||||
*/
|
*/
|
||||||
buildHomeSessionRows() {
|
buildHomeSessionRows() {
|
||||||
@@ -173,14 +188,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// invisible here while its tab already exists.
|
// invisible here while its tab already exists.
|
||||||
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
||||||
|
|
||||||
return ids.map((id, index) => {
|
const rows = ids.map((id, orderIndex) => {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||||
const mode = session.mode || 'claude';
|
const mode = session.mode || 'claude';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
index,
|
orderIndex,
|
||||||
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
||||||
mode,
|
mode,
|
||||||
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
||||||
@@ -192,8 +207,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// render time so the clock below can redo it without a re-render.
|
// render time so the clock below can redo it without a re-render.
|
||||||
createdAt: Number(session.createdAt) || 0,
|
createdAt: Number(session.createdAt) || 0,
|
||||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
// The running group is ordered by the pane's last Enter, since a
|
||||||
|
// working pane's last-activity stamp is always "now".
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
|
// "how long has it been like this", resolved by the phone overview's
|
||||||
|
// helper so both home screens label the same stamp with the same word.
|
||||||
|
since: this._mobileOverviewSince(state, session),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Guarded like every other constants.js consumer: a stale cached
|
||||||
|
// constants.js (iOS Safari serves old JS after a deploy) must degrade to
|
||||||
|
// tab order, not TypeError the whole home screen away.
|
||||||
|
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(rows) : rows;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -234,32 +260,40 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
|
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
|
||||||
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
||||||
* rewrite the text without rebuilding the row.
|
* rewrite the text without rebuilding the row.
|
||||||
|
*
|
||||||
|
* The second stamp is the row's state duration, NOT a plain last-active
|
||||||
|
* stamp: it is the number the rail is sorted by, and a working row that reads
|
||||||
|
* "active just now" (every working pane repaints about once a second) hides
|
||||||
|
* exactly the value that decided its position. `_mobileOverviewSince()` owns
|
||||||
|
* both the word and the anchor, so the phone says the same thing.
|
||||||
*/
|
*/
|
||||||
_buildHomeSessionsMeta(row) {
|
_buildHomeSessionsMeta(row) {
|
||||||
const meta = document.createElement('span');
|
const meta = document.createElement('span');
|
||||||
meta.className = 'home-sessions-row-meta';
|
meta.className = 'home-sessions-row-meta';
|
||||||
// Relative times are generated text, and "created"/"active" here are the
|
// Relative times are generated text, and "created"/"idle" here are the
|
||||||
// same generic words that mean something else on other surfaces.
|
// same generic words that mean something else on other surfaces.
|
||||||
meta.setAttribute('data-i18n-skip', '');
|
meta.setAttribute('data-i18n-skip', '');
|
||||||
|
|
||||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
|
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
|
||||||
|
|
||||||
const sep = document.createElement('span');
|
if (row.since) {
|
||||||
sep.className = 'home-sessions-meta-sep';
|
const sep = document.createElement('span');
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
sep.className = 'home-sessions-meta-sep';
|
||||||
sep.textContent = '·';
|
sep.setAttribute('aria-hidden', 'true');
|
||||||
meta.appendChild(sep);
|
sep.textContent = '·';
|
||||||
|
meta.appendChild(sep);
|
||||||
|
|
||||||
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
|
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
|
||||||
|
}
|
||||||
|
|
||||||
return meta;
|
return meta;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** One labelled stamp: a dim key, the relative value, full date in the title. */
|
/** One labelled stamp: a dim key, the value, full date in the title. */
|
||||||
_buildHomeSessionsStamp(key, timestamp, className) {
|
_buildHomeSessionsStamp(key, timestamp, format, className) {
|
||||||
const wrap = document.createElement('span');
|
const wrap = document.createElement('span');
|
||||||
wrap.className = `home-sessions-meta-item ${className}`;
|
wrap.className = `home-sessions-meta-item ${className}`;
|
||||||
|
|
||||||
@@ -270,18 +304,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const value = document.createElement('span');
|
const value = document.createElement('span');
|
||||||
value.dataset.hsTs = String(timestamp || 0);
|
value.dataset.hsTs = String(timestamp || 0);
|
||||||
value.textContent = this._homeSessionsAgo(timestamp);
|
value.dataset.hsFmt = format;
|
||||||
|
value.textContent = this._homeSessionsStampText(timestamp, format);
|
||||||
wrap.appendChild(value);
|
wrap.appendChild(value);
|
||||||
|
|
||||||
if (timestamp)
|
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
|
||||||
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
|
|
||||||
return wrap;
|
return wrap;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
|
/**
|
||||||
_homeSessionsAgo(timestamp) {
|
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
|
||||||
if (!timestamp) return '—';
|
* Both come from the phone overview's formatter, so a duration is written the
|
||||||
return this.formatRelativeTime(timestamp) || '—';
|
* same way on both home screens.
|
||||||
|
*/
|
||||||
|
_homeSessionsStampText(timestamp, format) {
|
||||||
|
return this._mobileOverviewStampText(timestamp, format);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,7 +348,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
||||||
const ts = Number(node.dataset.hsTs) || 0;
|
const ts = Number(node.dataset.hsTs) || 0;
|
||||||
const text = this._homeSessionsAgo(ts);
|
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
|
||||||
if (node.textContent !== text) node.textContent = text;
|
if (node.textContent !== text) node.textContent = text;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -348,11 +385,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.dataset.hsSession = row.id;
|
item.dataset.hsSession = row.id;
|
||||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||||
|
|
||||||
if (row.index < 9) {
|
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
|
||||||
|
// even though the rows are sorted by activity: it will not read 1,2,3 down
|
||||||
|
// the rail, and must not, or the shortcut it names would be wrong.
|
||||||
|
if (row.orderIndex < 9) {
|
||||||
const number = document.createElement('span');
|
const number = document.createElement('span');
|
||||||
number.className = 'home-sessions-number';
|
number.className = 'home-sessions-number';
|
||||||
number.setAttribute('data-i18n-skip', '');
|
number.setAttribute('data-i18n-skip', '');
|
||||||
number.textContent = String(row.index + 1);
|
number.textContent = String(row.orderIndex + 1);
|
||||||
item.appendChild(number);
|
item.appendChild(number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1211,6 +1211,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="pop out detach tab window this session">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Pop-out button on this tab</span>
|
||||||
|
<span class="set-row-desc">Show the open-in-a-window button on this tab even while the general App Settings toggle is off.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="sessionOptShowTabDetach" onchange="app.onSessionTabDetachToggle(this.checked)"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||||
* WORKING and IDLE / DONE.
|
* WORKING and IDLE / DONE.
|
||||||
*
|
*
|
||||||
|
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
|
||||||
|
* the SAME comparator the desktop rail uses: blocked longest-first, then
|
||||||
|
* running longest-first, then quiet most-recently-quiet first.
|
||||||
|
*
|
||||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
|
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||||||
* @dependency mobile-handlers.js (MobileDetection)
|
* @dependency mobile-handlers.js (MobileDetection)
|
||||||
@@ -35,16 +40,6 @@
|
|||||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||||
|
|
||||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
|
||||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
|
||||||
needs: 0,
|
|
||||||
error: 1,
|
|
||||||
waiting: 2,
|
|
||||||
working: 3,
|
|
||||||
idle: 4,
|
|
||||||
done: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** How many past conversations show before the "Show all" toggle. */
|
/** How many past conversations show before the "Show all" toggle. */
|
||||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||||
|
|
||||||
@@ -188,18 +183,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Epoch ms, straight off the session payload; formatting happens at
|
// Epoch ms, straight off the session payload; formatting happens at
|
||||||
// render time so the clock can redo it without a re-render.
|
// render time so the clock can redo it without a re-render.
|
||||||
createdAt: Number(session.createdAt) || 0,
|
createdAt: Number(session.createdAt) || 0,
|
||||||
|
// Raw stamps for the shared order comparator; `since` above is the same
|
||||||
|
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||||
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
since: this._mobileOverviewSince(state, session),
|
since: this._mobileOverviewSince(state, session),
|
||||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const bySeverityThenOrder = (a, b) => {
|
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
|
||||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
// rail: blocked first (longest-blocked at the top), then running
|
||||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
// longest-first, then quiet most-recent-first.
|
||||||
|
// Guarded: a stale cached constants.js (iOS Safari after a deploy) must
|
||||||
|
// degrade to tab order, not TypeError the overview away.
|
||||||
|
const inSection = (states) => {
|
||||||
|
const filtered = rows.filter((r) => states.includes(r.state));
|
||||||
|
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(filtered) : filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
|
||||||
|
|
||||||
// Past = conversations from the unified list that are not currently live.
|
// Past = conversations from the unified list that are not currently live.
|
||||||
// The endpoint already folds a transcript into its owning session (via the
|
// The endpoint already folds a transcript into its owning session (via the
|
||||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||||
* @dependency constants.js (window.CodemanLineage.computePath)
|
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +90,35 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return edges;
|
return edges;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour for one child's arc, from CodemanLineage.COLORS, assigned in FIRST-SEEN
|
||||||
|
* order and remembered per child id. First-seen rather than draw-index keeps a
|
||||||
|
* line's colour stable across re-renders, tab reorders and sibling closes (the
|
||||||
|
* SVG is wiped and rebuilt constantly, so an index-based colour would flicker).
|
||||||
|
* An empty string means "no override": the CSS falls back to --session-blue.
|
||||||
|
*/
|
||||||
|
_lineageColorFor(childId) {
|
||||||
|
const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || [];
|
||||||
|
if (palette.length === 0) return '';
|
||||||
|
if (!this._lineageColorByChild) {
|
||||||
|
this._lineageColorByChild = new Map();
|
||||||
|
this._lineageColorNext = 0;
|
||||||
|
}
|
||||||
|
let idx = this._lineageColorByChild.get(childId);
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = this._lineageColorNext++ % palette.length;
|
||||||
|
this._lineageColorByChild.set(childId, idx);
|
||||||
|
// Bounded: entries for long-gone sessions are pruned once the map is clearly
|
||||||
|
// stale, so a day-long dashboard cannot grow it without limit.
|
||||||
|
if (this._lineageColorByChild.size > 200 && this.sessions) {
|
||||||
|
for (const key of this._lineageColorByChild.keys()) {
|
||||||
|
if (!this.sessions.has(key)) this._lineageColorByChild.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return palette[idx] || '';
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append the lineage layer to the shared SVG pass.
|
* Append the lineage layer to the shared SVG pass.
|
||||||
*
|
*
|
||||||
@@ -137,6 +166,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||||
|
// Per-child colour rides a CSS custom property so the stylesheet keeps owning
|
||||||
|
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||||
|
const color = this._lineageColorFor(edge.childId);
|
||||||
|
if (color) line.style.setProperty('--lineage-color', color);
|
||||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||||
line.setAttribute('data-parent-tab', edge.parentId);
|
line.setAttribute('data-parent-tab', edge.parentId);
|
||||||
@@ -153,6 +186,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
dot.setAttribute('r', '3.5');
|
dot.setAttribute('r', '3.5');
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||||
dot.setAttribute('data-child-tab', edge.childId);
|
dot.setAttribute('data-child-tab', edge.childId);
|
||||||
|
if (color) dot.style.setProperty('--lineage-color', color);
|
||||||
svg.appendChild(dot);
|
svg.appendChild(dot);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1283,12 +1283,65 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-TAB pop-out button override (Session Options → Session → Identity). The
|
||||||
|
* general `showTabDetachButton` App Setting stays the per-device default for ALL
|
||||||
|
* tabs; this map whitelists single sessions on top of it, so one tab can carry
|
||||||
|
* the ⧉ button while the general toggle stays off. Per-device on purpose, like
|
||||||
|
* the general setting: it is a display choice, so it lives in localStorage and
|
||||||
|
* never touches the server schema. Rendered as the `tab-show-detach` class on
|
||||||
|
* the tab (see _fullRenderSessionTabs), which styles.css exempts from the
|
||||||
|
* global `display: none` gate; the active-tab reveal rules stay shared, so an
|
||||||
|
* overridden tab behaves exactly like a tab under the general toggle.
|
||||||
|
*/
|
||||||
|
_tabDetachOverrides() {
|
||||||
|
if (this._tabDetachOverrideMap === undefined) {
|
||||||
|
try {
|
||||||
|
this._tabDetachOverrideMap = JSON.parse(localStorage.getItem('codeman:tab-detach-overrides') || '{}') || {};
|
||||||
|
} catch (_e) {
|
||||||
|
this._tabDetachOverrideMap = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._tabDetachOverrideMap;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasTabDetachOverride(sessionId) {
|
||||||
|
return !!this._tabDetachOverrides()[sessionId];
|
||||||
|
},
|
||||||
|
|
||||||
|
onSessionTabDetachToggle(on) {
|
||||||
|
const id = this.editingSessionId;
|
||||||
|
if (!id) return;
|
||||||
|
const map = this._tabDetachOverrides();
|
||||||
|
if (on) map[id] = 1;
|
||||||
|
else delete map[id];
|
||||||
|
// Prune ids whose sessions are gone, so closed sessions cannot grow the map.
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
if (key !== id && this.sessions && !this.sessions.has(key)) delete map[key];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman:tab-detach-overrides', JSON.stringify(map));
|
||||||
|
} catch (_e) {
|
||||||
|
/* storage full/blocked: the in-memory map still applies this page load */
|
||||||
|
}
|
||||||
|
// Apply to the LIVE tab directly: the debounced render may take the
|
||||||
|
// incremental path (same session set), which patches rather than rebuilds,
|
||||||
|
// so the template's class would only land on the next full render. Future
|
||||||
|
// full renders re-emit it from _fullRenderSessionTabs.
|
||||||
|
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||||
|
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
||||||
|
},
|
||||||
|
|
||||||
openSessionOptions(sessionId) {
|
openSessionOptions(sessionId) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
this.editingSessionId = sessionId;
|
this.editingSessionId = sessionId;
|
||||||
|
|
||||||
|
// Per-tab pop-out override state (see _tabDetachOverrides above).
|
||||||
|
const detachToggle = document.getElementById('sessionOptShowTabDetach');
|
||||||
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|||||||
+88
-21
@@ -1459,23 +1459,80 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
|||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab alert animations */
|
/* Tab alerts: a STEADY red/yellow base with a pulse breathing on top.
|
||||||
.session-tab.tab-alert-action {
|
⚠ The original animation swung background AND border to transparent at its
|
||||||
|
0%/100% keyframes, so for roughly half of every cycle an alerted tab was
|
||||||
|
indistinguishable from a normal one: a glance (or a screenshot, owner report
|
||||||
|
2026-08-15) read "no alert" while the home rail showed a steady NEEDS YOU.
|
||||||
|
A pending permission is BLOCKING the agent, so the tab must look blocked at
|
||||||
|
every instant; only the intensity is allowed to move. The status dot joins
|
||||||
|
in (red/yellow, (0,4,0) so it outranks the skin block's (0,3,1) dot rules),
|
||||||
|
mirroring the phone overview's red-row language. */
|
||||||
|
/* The alert paints on ::before, NEVER on the tab element: .session-tab.active
|
||||||
|
forces background/border/box-shadow with !important, and !important beats
|
||||||
|
even a running animation, so an element-level alert vanished the moment the
|
||||||
|
tab was selected. The permission is still blocking while you look at it, so
|
||||||
|
the red ring must survive selection and clear only on resolution (owner call
|
||||||
|
2026-08-15). Same convention as the entrance styles (see the tab-enter block).
|
||||||
|
(0,3,x) via the strip parent on purpose: the non-OG skin block quiets
|
||||||
|
decorative glows (`.tab-glow { box-shadow: none }` lands at (0,2,1)), and an
|
||||||
|
alert halo is signal, not decor, so it must outrank that on every skin.
|
||||||
|
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||||
|
is fine at these alphas and is exactly what keeps it visible over the active
|
||||||
|
tab's opaque-ish background. */
|
||||||
|
.session-tabs .session-tab.tab-alert-action::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Explicit: .tab-enter::before (entrance animations) parks ::before at
|
||||||
|
opacity 0 with fill-mode both, and an alerted tab that is also entering
|
||||||
|
would otherwise inherit that and render an invisible alert. Our animation
|
||||||
|
shorthand already displaces theirs at this specificity; the opacity must
|
||||||
|
be pinned the same way. */
|
||||||
|
opacity: 1;
|
||||||
|
border: 2px solid var(--red);
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||||
animation: tab-blink-red 2.5s ease-in-out infinite;
|
animation: tab-blink-red 2.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tab.tab-alert-idle {
|
.session-tab.tab-alert-action .tab-status.idle,
|
||||||
|
.session-tab.tab-alert-action .tab-status.busy,
|
||||||
|
.session-tab.tab-alert-action .tab-status {
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tabs .session-tab.tab-alert-idle::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1; /* see the action variant above */
|
||||||
|
border: 2px solid var(--yellow);
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.35);
|
||||||
animation: tab-blink-yellow 3.5s ease-in-out infinite;
|
animation: tab-blink-yellow 3.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tab.tab-alert-idle .tab-status.idle,
|
||||||
|
.session-tab.tab-alert-idle .tab-status.busy,
|
||||||
|
.session-tab.tab-alert-idle .tab-status {
|
||||||
|
background: var(--yellow);
|
||||||
|
box-shadow: 0 0 6px rgba(234, 179, 8, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tab-blink-red {
|
@keyframes tab-blink-red {
|
||||||
0%, 100% { background: transparent; border-color: transparent; }
|
0%, 100% { background: rgba(239, 68, 68, 0.12); box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
|
||||||
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
|
50% { background: rgba(239, 68, 68, 0.3); box-shadow: 0 0 16px rgba(239, 68, 68, 0.75); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tab-blink-yellow {
|
@keyframes tab-blink-yellow {
|
||||||
0%, 100% { background: transparent; border-color: transparent; }
|
0%, 100% { background: rgba(234, 179, 8, 0.1); box-shadow: 0 0 8px rgba(234, 179, 8, 0.35); }
|
||||||
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
|
50% { background: rgba(234, 179, 8, 0.24); box-shadow: 0 0 14px rgba(234, 179, 8, 0.65); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -2081,8 +2138,12 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
|||||||
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
||||||
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
||||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||||
re-focus affordance for the popped-out window. */
|
re-focus affordance for the popped-out window. A SINGLE tab can also opt in
|
||||||
html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach {
|
via Session Options → Session (`tab-show-detach` on the tab, per-device map
|
||||||
|
in session-ui.js) while the general toggle stays off; the active-tab reveal
|
||||||
|
rules above are shared, so the overridden tab behaves identically. Phones are
|
||||||
|
unaffected either way: mobile.css hides .tab-detach with !important. */
|
||||||
|
html:not(.tabs-show-detach) .session-tab:not(.detached):not(.tab-show-detach) .tab-detach {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9329,11 +9390,15 @@ kbd {
|
|||||||
Deliberately quieter and thinner than the subagent lines above so the two
|
Deliberately quieter and thinner than the subagent lines above so the two
|
||||||
layers read as different things in the same SVG.
|
layers read as different things in the same SVG.
|
||||||
|
|
||||||
Colour comes from --session-blue, which EVERY skin block already defines and
|
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||||
already tunes for its own background, so one rule covers all seven (the four
|
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||||
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||||
and would outrank this one from a surprising place.
|
skin block already defines and tunes for its own background; a lone arc therefore
|
||||||
|
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||||
|
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||||
|
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||||
|
surprising place.
|
||||||
|
|
||||||
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
||||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||||
@@ -9352,13 +9417,13 @@ kbd {
|
|||||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||||
exactly two dash cycles, so it has to move with them. */
|
exactly two dash cycles, so it has to move with them. */
|
||||||
.connection-line.lineage-line {
|
.connection-line.lineage-line {
|
||||||
stroke: var(--session-blue, #2b8fd9);
|
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
stroke-dasharray: 5 5;
|
stroke-dasharray: 5 5;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-blue, #2b8fd9))
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||||
drop-shadow(0 0 11px var(--session-blue, #2b8fd9));
|
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||||
@@ -9374,9 +9439,10 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lineage-line-dot {
|
.lineage-line-dot {
|
||||||
fill: var(--session-blue, #2b8fd9);
|
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
filter: drop-shadow(0 0 4px var(--session-blue, #2b8fd9)) drop-shadow(0 0 9px var(--session-blue, #2b8fd9));
|
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||||
|
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The child end marches while that worker is actually working, so the line
|
/* The child end marches while that worker is actually working, so the line
|
||||||
@@ -14836,8 +14902,9 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The freshest signal on the row: while a session is actually doing something,
|
/* The freshest signal on the row: while a session is actually doing something,
|
||||||
its "active" stamp is the one the eye should land on. */
|
how long it has been doing it is what the eye should land on (and it is what
|
||||||
.home-sessions-row--working .home-sessions-meta-active {
|
the rail is sorted by). */
|
||||||
|
.home-sessions-row--working .home-sessions-meta-since {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
// re-capture instead).
|
// re-capture instead).
|
||||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||||
|
// Full state ride-along: the home screens sort the running group on
|
||||||
|
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||||
|
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||||
|
// Enter). Debounced, so working-signal flaps cost one broadcast.
|
||||||
|
deps.broadcastSessionStateDebounced(session.id);
|
||||||
const tracker = deps.getRunSummaryTracker(session.id);
|
const tracker = deps.getRunSummaryTracker(session.id);
|
||||||
if (tracker) {
|
if (tracker) {
|
||||||
tracker.recordWorking();
|
tracker.recordWorking();
|
||||||
|
|||||||
+48
-19
@@ -2,11 +2,11 @@
|
|||||||
//
|
//
|
||||||
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
// 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
|
// 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
|
// and are pinned here: the row ORDER (shared with the phone overview via
|
||||||
// overview which sorts by urgency, and the number badges are only correct if it
|
// CodemanSessionOrder, with the number badge still carrying the TAB index so
|
||||||
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
|
// Alt+N keeps working), and the WIDTH GATE, which lives in two places at once —
|
||||||
// and a CSS media query — because the column is absolutely positioned and would
|
// the JS constant and a CSS media query — because the column is absolutely
|
||||||
// overlap the search panel in a narrow window.
|
// positioned and would overlap the search panel in a narrow window.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
@@ -35,9 +35,10 @@ function fakeElement(): any {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
||||||
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
|
* `shouldUseMobileOverview` from mobile-overview.js and the row comparator from
|
||||||
* same context — which is also the point: if that reuse ever breaks, these
|
* constants.js, so all three files run in the same context, which is also the
|
||||||
* tests stop loading rather than quietly testing a divergent copy.
|
* 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) {
|
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
||||||
const CodemanApp = function CodemanApp(this: any) {};
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
@@ -52,7 +53,7 @@ function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1
|
|||||||
},
|
},
|
||||||
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
||||||
});
|
});
|
||||||
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
|
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +77,9 @@ function sessionMap(list: Array<Record<string, any>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('home sessions column: model', () => {
|
describe('home sessions column: model', () => {
|
||||||
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
|
it('hoists a session blocked on you, and keeps its badge on the TAB index', () => {
|
||||||
// The phone overview would hoist 'needy' to the top; this surface must not,
|
// The badge names the Alt+N shortcut, so a sorted rail shows 2,1,3 rather
|
||||||
// because its badges are the Alt+N indices.
|
// than renumbering itself 1,2,3 and lying about which key selects what.
|
||||||
const app = loadHomeSessionsApp({
|
const app = loadHomeSessionsApp({
|
||||||
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
||||||
sessionOrder: ['first', 'needy', 'third'],
|
sessionOrder: ['first', 'needy', 'third'],
|
||||||
@@ -87,10 +88,34 @@ describe('home sessions column: model', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = app.buildHomeSessionRows();
|
const rows = app.buildHomeSessionRows();
|
||||||
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
|
expect(rows.map((r: any) => r.id)).toEqual(['needy', 'first', 'third']);
|
||||||
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
|
expect(rows.map((r: any) => r.orderIndex)).toEqual([1, 0, 2]);
|
||||||
expect(rows[1].state).toBe('needs');
|
expect(rows[0].state).toBe('needs');
|
||||||
expect(rows[1].pill).toBe('needs you');
|
expect(rows[0].pill).toBe('needs you');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||||
|
// The same rule the phone overview follows, and the reason the rail exists:
|
||||||
|
// what is running longest is what is most likely to be done or stuck, and
|
||||||
|
// once nothing is running the session that just stopped is the one you came
|
||||||
|
// back for.
|
||||||
|
const app = loadHomeSessionsApp({
|
||||||
|
sessions: sessionMap([
|
||||||
|
{ id: 'young-turn', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'old-turn', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'stale-idle', status: 'idle', lastActivityAt: 2_000 },
|
||||||
|
{ id: 'fresh-idle', status: 'idle', lastActivityAt: 8_000 },
|
||||||
|
]),
|
||||||
|
sessionOrder: ['young-turn', 'old-turn', 'stale-idle', 'fresh-idle'],
|
||||||
|
cases: CASES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual([
|
||||||
|
'old-turn',
|
||||||
|
'young-turn',
|
||||||
|
'fresh-idle',
|
||||||
|
'stale-idle',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a session that is not in the order list yet', () => {
|
it('shows a session that is not in the order list yet', () => {
|
||||||
@@ -117,11 +142,13 @@ describe('home sessions column: model', () => {
|
|||||||
cases: CASES,
|
cases: CASES,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unstamped rows fall back to the tab order inside a state, so this reads
|
||||||
|
// as the state ranking alone: an errored session is blocked on you.
|
||||||
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
||||||
|
['error', 'error'],
|
||||||
['working', 'working'],
|
['working', 'working'],
|
||||||
['idle', 'idle'],
|
['idle', 'idle'],
|
||||||
['done', 'done'],
|
['done', 'done'],
|
||||||
['error', 'error'],
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,8 +263,10 @@ describe('home sessions column: wiring', () => {
|
|||||||
expect(aside).toBeGreaterThan(overlayStart);
|
expect(aside).toBeGreaterThan(overlayStart);
|
||||||
expect(aside).toBeLessThan(content);
|
expect(aside).toBeLessThan(content);
|
||||||
// Load order: the module reuses prototype methods installed by
|
// Load order: the module reuses prototype methods installed by
|
||||||
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
// mobile-overview.js and the comparator installed by constants.js. Compare
|
||||||
// files are named in explanatory comments earlier in the document.
|
// 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"'));
|
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
||||||
|
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ function loadOverviewApp(overrides: Record<string, any> = {}) {
|
|||||||
},
|
},
|
||||||
MobileDetection: { getDeviceType: () => 'mobile' },
|
MobileDetection: { getDeviceType: () => 'mobile' },
|
||||||
});
|
});
|
||||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
|
// constants.js first: it installs the row comparator (window.CodemanSessionOrder)
|
||||||
filename: 'mobile-overview.js',
|
// that buildMobileOverviewModel() sorts every section with, shared with the
|
||||||
});
|
// desktop rail so the two home screens cannot order the same list differently.
|
||||||
|
for (const file of ['constants.js', 'mobile-overview.js']) {
|
||||||
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
const app = new (CodemanApp as any)();
|
const app = new (CodemanApp as any)();
|
||||||
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
||||||
@@ -123,7 +126,7 @@ describe('mobile overview model', () => {
|
|||||||
expect(model.sessionCount).toBe(4);
|
expect(model.sessionCount).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the user tab order as the tiebreak inside a section', () => {
|
it('keeps the user tab order as the tiebreak when nothing is stamped', () => {
|
||||||
const app = loadOverviewApp();
|
const app = loadOverviewApp();
|
||||||
const model = app.buildMobileOverviewModel({
|
const model = app.buildMobileOverviewModel({
|
||||||
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
||||||
@@ -134,6 +137,42 @@ describe('mobile overview model', () => {
|
|||||||
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sorts running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||||
|
// A working pane repaints about once a second, so its last-activity stamp
|
||||||
|
// is always "now": the running group has to key off the pane's last Enter
|
||||||
|
// instead, or every turn ranks as freshly started.
|
||||||
|
const app = loadOverviewApp();
|
||||||
|
const model = app.buildMobileOverviewModel({
|
||||||
|
sessions: [
|
||||||
|
session({ id: 'quiet-old', status: 'idle', lastActivityAt: 2_000 }),
|
||||||
|
session({ id: 'turn-young', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 }),
|
||||||
|
session({ id: 'quiet-new', status: 'idle', lastActivityAt: 8_000 }),
|
||||||
|
session({ id: 'turn-old', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 }),
|
||||||
|
],
|
||||||
|
cases: CASES,
|
||||||
|
sessionOrder: ['quiet-old', 'turn-young', 'quiet-new', 'turn-old'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.current.map((r: any) => r.id)).toEqual(['turn-old', 'turn-young', 'quiet-new', 'quiet-old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the longest-blocked session at the top of NEEDS YOU', () => {
|
||||||
|
const app = loadOverviewApp();
|
||||||
|
const model = app.buildMobileOverviewModel({
|
||||||
|
sessions: [
|
||||||
|
session({ id: 'just-asked', lastActivityAt: 9_000 }),
|
||||||
|
session({ id: 'starving', lastActivityAt: 1_000 }),
|
||||||
|
],
|
||||||
|
cases: CASES,
|
||||||
|
pendingHooks: new Map([
|
||||||
|
['just-asked', new Set(['permission_prompt'])],
|
||||||
|
['starving', new Set(['permission_prompt'])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.needsYou.map((r: any) => r.id)).toEqual(['starving', 'just-asked']);
|
||||||
|
});
|
||||||
|
|
||||||
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
||||||
const app = loadOverviewApp();
|
const app = loadOverviewApp();
|
||||||
const model = app.buildMobileOverviewModel({
|
const model = app.buildMobileOverviewModel({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function loadLineageHelper() {
|
|||||||
DIP_MIN_PX: number;
|
DIP_MIN_PX: number;
|
||||||
DIP_MAX_PX: number;
|
DIP_MAX_PX: number;
|
||||||
SIBLING_STEP_PX: number;
|
SIBLING_STEP_PX: number;
|
||||||
|
COLORS: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).CodemanLineage;
|
).CodemanLineage;
|
||||||
@@ -61,8 +62,9 @@ describe('lineage line geometry', () => {
|
|||||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||||
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
||||||
|
|
||||||
const nearDip = controlYs(near.d)[0] - 34;
|
// The dip hangs from the STRIP's bottom edge (40), not the tab bottoms.
|
||||||
const farDip = controlYs(far.d)[0] - 34;
|
const nearDip = controlYs(near.d)[0] - 40;
|
||||||
|
const farDip = controlYs(far.d)[0] - 40;
|
||||||
expect(farDip).toBeGreaterThan(nearDip);
|
expect(farDip).toBeGreaterThan(nearDip);
|
||||||
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
||||||
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
||||||
@@ -80,15 +82,48 @@ describe('lineage line geometry', () => {
|
|||||||
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
||||||
const helper = loadLineageHelper();
|
const helper = loadLineageHelper();
|
||||||
// A worker the agent skill starts is appended to the END of the strip, so this
|
// A worker the agent skill starts is appended to the END of the strip, so this
|
||||||
// is the span the feature is actually used at. The first shipped clamp (44px)
|
// is the span the feature is actually used at. The corridor has failed in BOTH
|
||||||
// turned it into a flat thread across the terminal.
|
// directions: the first 44px clamp read as a flat thread here (#285), and the
|
||||||
|
// 104px clamp that replaced it bowed deep into the terminal (2026-08-15), so this
|
||||||
|
// pins the cap exactly rather than just a floor.
|
||||||
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
||||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||||
|
|
||||||
const wideDip = controlYs(wide.d)[0] - 34;
|
const wideDip = controlYs(wide.d)[0] - 40; // from the strip's bottom edge
|
||||||
const nearDip = controlYs(near.d)[0] - 34;
|
const nearDip = controlYs(near.d)[0] - 40;
|
||||||
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
||||||
expect(wideDip).toBeGreaterThanOrEqual(80);
|
expect(wideDip).toBe(helper.DIP_MAX_PX);
|
||||||
|
expect(helper.DIP_MAX_PX).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hangs the dip from the STRIP bottom, so no per-row offset ever stacks on it', () => {
|
||||||
|
const helper = loadLineageHelper();
|
||||||
|
const twoRowStrip = { left: 0, top: 0, width: 1200, height: 84 }; // rows at y 4-34 and 48-78
|
||||||
|
// A wrapped pair (row 1 → row 2) and a same-row pair on ROW 1 of the same strip.
|
||||||
|
const wrapped = helper.computePath({ parent: tab(0), child: tab(400, 48), strip: twoRowStrip })!;
|
||||||
|
const row1Pair = helper.computePath({ parent: tab(0), child: tab(400), strip: twoRowStrip })!;
|
||||||
|
|
||||||
|
// Both brackets clear the ENTIRE strip: the wrapped one does not add the row
|
||||||
|
// offset on top (the 2026-08-15 over-bow), and the row-1 pair does not draw
|
||||||
|
// through row 2's tab labels (the retune's own first-draft regression).
|
||||||
|
for (const geom of [wrapped, row1Pair]) {
|
||||||
|
for (const y of controlYs(geom.d)) {
|
||||||
|
expect(y).toBeGreaterThanOrEqual(84 + helper.DIP_MIN_PX);
|
||||||
|
expect(y).toBeLessThanOrEqual(84 + helper.DIP_MAX_PX + helper.SIBLING_STEP_PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a colour palette whose first entry defers to the skin blue', () => {
|
||||||
|
const helper = loadLineageHelper();
|
||||||
|
const colors = helper.COLORS;
|
||||||
|
expect(Array.isArray(colors)).toBe(true);
|
||||||
|
// '' = no override: session-lineage.js sets no inline --lineage-color and the
|
||||||
|
// CSS falls back to the skin-tuned --session-blue, so a lone arc stays blue.
|
||||||
|
expect(colors[0]).toBe('');
|
||||||
|
expect(colors.length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(new Set(colors).size).toBe(colors.length);
|
||||||
|
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Port: none (pure comparator — no browser, no server).
|
||||||
|
//
|
||||||
|
// `CodemanSessionOrder` (src/web/public/constants.js) is the single row order
|
||||||
|
// behind both home screens: the phone overview and the desktop tab rail. It is
|
||||||
|
// the one place the two surfaces can disagree about which session you should
|
||||||
|
// look at next, which is why it is pure and pinned here rather than living
|
||||||
|
// inside either renderer.
|
||||||
|
//
|
||||||
|
// The rule it encodes, and the thing worth protecting: the tiebreak FLIPS
|
||||||
|
// direction halfway down the list. For a state a session is still in, older is
|
||||||
|
// more urgent (blocked longest, running longest). For a state it has stopped
|
||||||
|
// in, newer is more relevant (just finished beats abandoned yesterday).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
lastSubmitAt?: number;
|
||||||
|
orderIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadOrderHelper() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanSessionOrder: {
|
||||||
|
RANK: Record<string, number>;
|
||||||
|
anchor: (row: Row) => number;
|
||||||
|
compare: (a: Row, b: Row) => number;
|
||||||
|
sort: (rows: Row[]) => Row[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).CodemanSessionOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = loadOrderHelper();
|
||||||
|
const ids = (rows: Row[]) => order.sort(rows).map((r) => r.id);
|
||||||
|
|
||||||
|
describe('session overview order: state ranking', () => {
|
||||||
|
it('puts everything blocked on a human above everything else', () => {
|
||||||
|
// Red question, then a hard error, then the yellow "waiting for input"
|
||||||
|
// prompt, then work, then whatever has stopped.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'done', state: 'done' },
|
||||||
|
{ id: 'idle', state: 'idle' },
|
||||||
|
{ id: 'working', state: 'working' },
|
||||||
|
{ id: 'waiting', state: 'waiting' },
|
||||||
|
{ id: 'error', state: 'error' },
|
||||||
|
{ id: 'needs', state: 'needs' },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['needs', 'error', 'waiting', 'working', 'idle', 'done']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts an unknown state last instead of dropping it or crashing', () => {
|
||||||
|
// A state added to one renderer and not to the rank map must still render,
|
||||||
|
// just at the bottom — a missing row is a worse failure than a misplaced one.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'mystery', state: 'quantum' },
|
||||||
|
{ id: 'done', state: 'done' },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['done', 'mystery']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: in-progress states sort oldest first', () => {
|
||||||
|
it('ranks the longest-running turn above a turn that just started', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'young', state: 'working', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['old', 'young']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures a running turn from the last Enter, not the last repaint', () => {
|
||||||
|
// A working pane repaints about once a second, so last-activity is always
|
||||||
|
// "now" and would rank every running turn identically.
|
||||||
|
expect(order.anchor({ id: 'w', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(1_000);
|
||||||
|
expect(order.anchor({ id: 'i', state: 'idle', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(999_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to last activity for a working pane that never submitted', () => {
|
||||||
|
// Spawned with its prompt on the command line, or an external CLI whose
|
||||||
|
// Enter never went through Codeman. Its fallback stamp is ~now, so it sits
|
||||||
|
// at the SHORT end of the running group rather than falsely leading it.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'no-submit', state: 'working', lastActivityAt: 10_000 },
|
||||||
|
{ id: 'submitted', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['submitted', 'no-submit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks the longest-blocked session above one that just asked', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'just-asked', state: 'needs', lastActivityAt: 9_000 },
|
||||||
|
{ id: 'starving', state: 'needs', lastActivityAt: 1_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['starving', 'just-asked']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: stopped states sort newest first', () => {
|
||||||
|
it('puts the session that just went quiet above one idle since yesterday', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'just-now', state: 'idle', lastActivityAt: 9_000 },
|
||||||
|
{ id: 'this-morning', state: 'idle', lastActivityAt: 5_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['just-now', 'this-morning', 'yesterday']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the same recency rule to finished sessions', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'old-exit', state: 'done', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'fresh-exit', state: 'done', lastActivityAt: 9_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['fresh-exit', 'old-exit']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: tiebreaks', () => {
|
||||||
|
it('falls back to the tab order when two rows share a stamp', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'third', state: 'idle', lastActivityAt: 5_000, orderIndex: 2 },
|
||||||
|
{ id: 'first', state: 'idle', lastActivityAt: 5_000, orderIndex: 0 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['first', 'third']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts an unstamped row last within its state, never first', () => {
|
||||||
|
// 0 is "we have no stamp", not "the epoch": treating it as a timestamp
|
||||||
|
// would park a brand-new session at the head of the oldest-first groups.
|
||||||
|
expect(
|
||||||
|
ids([
|
||||||
|
{ id: 'none', state: 'idle', orderIndex: 0 },
|
||||||
|
{ id: 'stamped', state: 'idle', lastActivityAt: 1_000, orderIndex: 1 },
|
||||||
|
])
|
||||||
|
).toEqual(['stamped', 'none']);
|
||||||
|
expect(
|
||||||
|
ids([
|
||||||
|
{ id: 'none', state: 'working', orderIndex: 0 },
|
||||||
|
{ id: 'stamped', state: 'working', lastSubmitAt: 1_000, orderIndex: 1 },
|
||||||
|
])
|
||||||
|
).toEqual(['stamped', 'none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: two unstamped rows keep tab order in both directions', () => {
|
||||||
|
const a: Row = { id: 'a', state: 'idle', orderIndex: 0 };
|
||||||
|
const b: Row = { id: 'b', state: 'idle', orderIndex: 1 };
|
||||||
|
expect(order.compare(a, b)).toBeLessThan(0);
|
||||||
|
expect(order.compare(b, a)).toBeGreaterThan(0);
|
||||||
|
expect(order.compare(a, a)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies rather than sorting the caller array in place', () => {
|
||||||
|
// Both renderers hand it a filtered slice of a shared row array; mutating
|
||||||
|
// that would reorder the other surface's list as a side effect.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'b', state: 'idle', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'a', state: 'idle', lastActivityAt: 9_000 },
|
||||||
|
];
|
||||||
|
order.sort(rows);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives junk input rather than throwing inside a render', () => {
|
||||||
|
expect(order.sort(undefined as unknown as Row[])).toEqual([]);
|
||||||
|
expect(order.anchor({} as Row)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user