mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210da991d5 | ||
|
|
869a507482 | ||
|
|
854bcb99aa | ||
|
|
9ee6bf113b | ||
|
|
66d4c483c7 | ||
|
|
ff13234b3d | ||
|
|
0af80b417c | ||
|
|
0afd4e1cdc | ||
|
|
b6293959d2 | ||
|
|
c01edcbbb8 |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
Add an optional collapsible left session sidebar as an alternative to the header tab strip.
|
||||
|
||||
With many concurrent sessions the horizontal strip wraps into several rows and stops being scannable. The new layout puts the session list in a vertical `<aside>` with a filter box and a live session count, collapsible to a 44px rail that keeps the status dots and task badges visible.
|
||||
|
||||
Opt-in via Settings → Layout → Tabs → Session List Layout; the default stays the header strip, so nothing changes unless you switch. Both layouts share one `#sessionTabs` element that is re-parented between mount points, so every existing affordance (status, mode badge, alerts, drag-reorder, keyboard navigation, web tabs, subagent windows) behaves identically in both. Below 1024px the sidebar is an off-canvas drawer that overlays the terminal instead of shrinking it. Collapse state persists per device; `Alt+B` toggles it.
|
||||
@@ -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
|
||||
|
||||
## 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
|
||||
|
||||
### 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.
|
||||
|
||||
**Version**: 1.18.3 (must match `package.json`)
|
||||
**Version**: 1.18.4 (must match `package.json`)
|
||||
|
||||
## 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)
|
||||
|
||||
**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)
|
||||
|
||||
**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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| **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
|
||||
|
||||
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.
|
||||
@@ -675,6 +683,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Alt/Option+B` | Collapse / expand the session sidebar (sidebar layout only) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
|
||||
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
|
||||
@@ -1035,6 +1044,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
|
||||
|
||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||
|
||||
@@ -287,6 +287,12 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
|
||||
|
||||
### Session list layout (header strip vs. left sidebar)
|
||||
|
||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
|
||||
### Gesture control: the setting
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
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",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Manual verification harness for the session-sidebar feature.
|
||||
*
|
||||
* Renders the real UI in headless Chromium against a testMode WebServer,
|
||||
* injects a synthetic 25-session fleet, and screenshots every layout state.
|
||||
* Not part of the automated suite — run it by hand:
|
||||
*
|
||||
* npx tsx scripts/verify-session-sidebar.mts
|
||||
*
|
||||
* SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
|
||||
* dedicated port. It never touches a real Codeman instance or tmux socket.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Mirror test/setup.ts: isolate HOME before the app modules touch state.
|
||||
process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
|
||||
process.env.VITEST = 'true';
|
||||
|
||||
const PORT = 3299;
|
||||
const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// Generic on purpose: these names end up in the harness screenshots, so they
|
||||
// should not carry one contributor's project list into everyone else's review.
|
||||
// The mix of CLI modes matters (each renders a different badge); the names do not.
|
||||
const PROJECTS = [
|
||||
['api-server', 'claude'],
|
||||
['web-client', 'claude'],
|
||||
['mobile-app', 'codex'],
|
||||
['data-pipeline', 'claude'],
|
||||
['shared-lib', 'gemini'],
|
||||
['codeman', 'claude'],
|
||||
['docs-site', 'claude'],
|
||||
['batch-jobs', 'opencode'],
|
||||
['search-index', 'claude'],
|
||||
];
|
||||
const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
|
||||
|
||||
function fleet(n: number) {
|
||||
const out: any[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [proj, mode] = PROJECTS[i % PROJECTS.length];
|
||||
const status = STATUSES[i % STATUSES.length];
|
||||
out.push({
|
||||
id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
|
||||
pid: 10000 + i,
|
||||
status,
|
||||
workingDir: `${tmpdir()}/projects/${proj}`,
|
||||
name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
|
||||
mode,
|
||||
currentTaskId: null,
|
||||
createdAt: Date.now() - i * 60000,
|
||||
lastActivityAt: Date.now() - i * 1000,
|
||||
isWorking: status === 'busy',
|
||||
messageCount: i * 3,
|
||||
totalCost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
color: 'default',
|
||||
taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
|
||||
taskTree: [],
|
||||
tokens: { input: 0, output: 0, total: 0 },
|
||||
bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SESSIONS = fleet(25);
|
||||
|
||||
async function main() {
|
||||
const server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const results: string[] = [];
|
||||
|
||||
async function shot(
|
||||
name: string,
|
||||
opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
|
||||
) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: opts.width, height: opts.height },
|
||||
hasTouch: !!opts.touch,
|
||||
isMobile: !!opts.touch,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
const settings = JSON.stringify({ sessionListLayout: opts.layout });
|
||||
const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
|
||||
await page.addInitScript(
|
||||
([s, c]) => {
|
||||
localStorage.setItem('codeman-app-settings', s as string);
|
||||
localStorage.setItem('codeman-app-settings-mobile', s as string);
|
||||
if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
|
||||
else localStorage.removeItem('codeman-sidebar-collapsed');
|
||||
},
|
||||
[settings, collapsed]
|
||||
);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.evaluate((list) => {
|
||||
const app = (window as any).app;
|
||||
if (!app) throw new Error('no window.app');
|
||||
app.sessions.clear();
|
||||
for (const s of list as any[]) app.sessions.set(s.id, s);
|
||||
// The renderer iterates sessionOrder, not the map.
|
||||
app.sessionOrder = (list as any[]).map((s) => s.id);
|
||||
app.activeSessionId = (list as any[])[3].id;
|
||||
// renderSessionTabs() is debounced; drive the immediate path directly.
|
||||
(app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
|
||||
app.applySessionListLayout?.();
|
||||
}, SESSIONS as any);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const aside = document.getElementById('sessionSidebar');
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const asideBox = aside?.getBoundingClientRect();
|
||||
const cs = aside ? getComputedStyle(aside) : null;
|
||||
return {
|
||||
dataSessionList: root.dataset.sessionList ?? null,
|
||||
dataSidebar: root.dataset.sidebar ?? null,
|
||||
rows: document.querySelectorAll('.session-tab').length,
|
||||
tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
|
||||
asideWidth: asideBox ? Math.round(asideBox.width) : null,
|
||||
asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
|
||||
asideInert: aside?.hasAttribute('inert') ?? null,
|
||||
ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
|
||||
toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
|
||||
firstRowText:
|
||||
(document.querySelector('.session-tab') as HTMLElement | null)?.innerText
|
||||
?.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 40) ?? null,
|
||||
listScrollable: (() => {
|
||||
const el = document.getElementById('sessionTabs');
|
||||
return el ? el.scrollHeight > el.clientHeight + 2 : null;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
await page.waitForTimeout(400);
|
||||
const file = join(OUT, `${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
|
||||
await ctx.close();
|
||||
return info;
|
||||
}
|
||||
|
||||
await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
|
||||
await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
|
||||
await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
|
||||
await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
|
||||
await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
|
||||
await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
|
||||
await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
|
||||
|
||||
console.log('\n=== RESULTS ===');
|
||||
for (const r of results) console.log(r);
|
||||
console.log(`\nScreenshots in ${OUT}`);
|
||||
|
||||
await browser.close();
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
+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).
|
||||
|
||||
**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
|
||||
call starts with:
|
||||
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||
later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${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; }
|
||||
```
|
||||
|
||||
If that passed, §0 is done: go straight to your job (§1's block opens with this same
|
||||
loader, so when §1 is the job you can simply start there). Only when it 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
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
loader, so when §1 is the job, start there: the check rides the spawn call for free,
|
||||
and a standalone "preamble OK" call buys nothing while costing a full model turn
|
||||
(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
|
||||
"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
|
||||
@@ -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
|
||||
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` /
|
||||
`sendwait` / `last_text` / `delete_session` from the §0 preamble, so there is nothing
|
||||
to assemble and no per-call body to hand-build.
|
||||
Fill in the case names and the prompts, then run it as your FIRST Bash call: no
|
||||
standalone preamble check before it (line one below IS that check), and no
|
||||
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
|
||||
. "${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; }
|
||||
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'
|
||||
'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**,
|
||||
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
|
||||
`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
|
||||
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`,
|
||||
|
||||
+382
-13
@@ -428,6 +428,16 @@ const DEFAULT_SHORTCUTS = [
|
||||
],
|
||||
action: 'openCommandPalette',
|
||||
},
|
||||
{
|
||||
id: 'toggle-session-sidebar',
|
||||
group: 'Session',
|
||||
label: 'Toggle Session Sidebar',
|
||||
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
|
||||
// readline backward-char). The Alt block below claims only Digit1-9 and
|
||||
// the brackets, and the registry claims Alt for KeyK and Slash only.
|
||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||
action: 'toggleSessionSidebar',
|
||||
},
|
||||
{
|
||||
id: 'previous-next-session',
|
||||
group: 'Session',
|
||||
@@ -873,7 +883,9 @@ class CodemanApp {
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
this._installLineageStripScrollListener?.();
|
||||
@@ -940,7 +952,7 @@ class CodemanApp {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applySessionListLayout();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
@@ -1062,6 +1074,7 @@ class CodemanApp {
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
toggleSessionSidebar: () => this.toggleSessionSidebar(),
|
||||
};
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -1083,6 +1096,14 @@ class CodemanApp {
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas
|
||||
// drawer over the terminal, so Escape must close it. The docked desktop
|
||||
// sidebar is chrome, not a dialog — collapsing it would be a surprise.
|
||||
if (this._isSessionSidebarOverlay() &&
|
||||
this.isSessionSidebarActive() && !this.isSessionSidebarCollapsed()) {
|
||||
this.toggleSessionSidebar();
|
||||
document.getElementById('sidebarToggleBtn')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
@@ -3564,6 +3585,262 @@ class CodemanApp {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session List Layout (header strip ⟷ collapsible left sidebar)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 'header' | 'sidebar'. Solo (detached single-session) windows are ALWAYS
|
||||
* 'header': they show exactly one session, so a session list is noise — and
|
||||
* #sessionTabs must never be parked inside the display:none <aside>, where
|
||||
* updateTabOverflowMode() would measure 0/0 and the inline rename input would
|
||||
* get zero geometry.
|
||||
*/
|
||||
getSessionListLayout() {
|
||||
if (this.soloSessionId) return 'header';
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const layout = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
return layout === 'sidebar' ? 'sidebar' : 'header';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the APPLIED layout off <html>, not the settings blob: this is called
|
||||
* per dragover event and per tab in render loops, and getSessionListLayout()
|
||||
* re-parses localStorage on every call. The attribute is written by the
|
||||
* pre-paint script in index.html and thereafter only by applySessionListLayout(),
|
||||
* so it is authoritative from the very first frame.
|
||||
*/
|
||||
isSessionSidebarActive() {
|
||||
return document.documentElement.dataset.sessionList === 'sidebar';
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
*
|
||||
* That behaviour is defined purely in mobile.css, which index.html loads with
|
||||
* media="(max-width: 1023px)" — so this must test the SAME breakpoint.
|
||||
* MobileDetection.getDeviceType() is NOT usable here: it calls anything
|
||||
* >= 768px 'desktop', which would leave 768-1023px (iPad portrait, a narrowed
|
||||
* desktop window) with overlay CSS but docked-sidebar logic — drawer opens
|
||||
* itself on load, tapping a session doesn't dismiss it, Escape does nothing.
|
||||
* Mirrored in the pre-paint script in index.html.
|
||||
*/
|
||||
_isSessionSidebarOverlay() {
|
||||
return window.innerWidth < 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse state is per-device and lives in its OWN localStorage key, not in
|
||||
* the app-settings blob: saveAppSettings() rebuilds that blob from the DOM
|
||||
* controls, so any key without a control is silently wiped on every Save.
|
||||
* Precedent: codeman:skin, codeman-session-order, codeman-active-session.
|
||||
*/
|
||||
isSessionSidebarCollapsed() {
|
||||
// In-memory intent wins over storage: where localStorage throws (Safari
|
||||
// private mode, disabled storage, quota) the write in toggleSessionSidebar()
|
||||
// is a no-op, and re-reading here would return the OLD value — the sidebar
|
||||
// would refuse to collapse at all. Persistence degrades, the control does not.
|
||||
if (this._sidebarCollapsedOverride !== undefined) return this._sidebarCollapsedOverride;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem('codeman-sidebar-collapsed');
|
||||
} catch {}
|
||||
// Never chosen yet: the docked desktop sidebar starts open, the overlay
|
||||
// drawer starts CLOSED — "expanded" there would mean a drawer covering the
|
||||
// terminal on every cold load.
|
||||
if (raw === null) return this._isSessionSidebarOverlay();
|
||||
return raw === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this keydown is the sidebar-toggle chord AND toggling would
|
||||
* actually do something. Used by terminal-ui.js's custom key handler to keep
|
||||
* the chord out of the PTY: the document CAPTURE handler has already toggled
|
||||
* the sidebar by the time xterm sees the event, but its preventDefault() does
|
||||
* NOT stop xterm — without this gate Alt+B would ALSO write ESC b into the
|
||||
* live session, which readline/Ink read as backward-word and which walks the
|
||||
* cursor back through whatever the user was typing (same trap as COD-153).
|
||||
*
|
||||
* Deliberately registry-aware and gated on the sidebar being active, so a
|
||||
* rebound/disabled shortcut — and the default header layout, where the toggle
|
||||
* is a no-op — leave Meta-b reaching the terminal exactly as before.
|
||||
*/
|
||||
shouldToggleSessionSidebarFromShortcut(e) {
|
||||
if (!e) return false;
|
||||
// Every dispatchable binding requires Ctrl/Cmd/Alt, so plain typing exits
|
||||
// before any registry work — this runs on the xterm keydown hot path.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
if (!this.isSessionSidebarActive()) return false;
|
||||
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const shortcut = this.getShortcutRegistry().find((s) => s.id === 'toggle-session-sidebar');
|
||||
if (!shortcut || shortcut.disabled) return false;
|
||||
return this.matchesShortcutEvent(e, shortcut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the ONE #sessionTabs element between its two hosts and set the layout
|
||||
* attributes that all the sidebar CSS keys off.
|
||||
*
|
||||
* Never clones or recreates the node: this.$('sessionTabs') caches elements by
|
||||
* id and never invalidates, and settings-ui.js / webview-tabs.js resolve the
|
||||
* same id independently. A rebuilt container would leave every consumer
|
||||
* writing into a detached orphan — silently, with no error.
|
||||
*/
|
||||
applySessionListLayout() {
|
||||
const mode = this.getSessionListLayout();
|
||||
const collapsed = this.isSessionSidebarCollapsed();
|
||||
const prevMode = document.documentElement.dataset.sessionList;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
const sidebarList = document.getElementById('sessionSidebarList');
|
||||
if (!tabsEl || !headerHost || !sidebarList) return;
|
||||
|
||||
const host = mode === 'sidebar' ? sidebarList : headerHost;
|
||||
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
|
||||
|
||||
document.documentElement.dataset.sessionList = mode;
|
||||
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
|
||||
tabsEl.setAttribute('aria-orientation', mode === 'sidebar' ? 'vertical' : 'horizontal');
|
||||
|
||||
const btn = document.getElementById('sidebarToggleBtn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('btn-sidebar-toggle--hidden', mode !== 'sidebar');
|
||||
const label = collapsed ? 'Expand session sidebar' : 'Collapse session sidebar';
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('title', label);
|
||||
}
|
||||
|
||||
// Handheld (mobile.css): the sidebar is an off-canvas overlay, and
|
||||
// "collapsed" means the drawer is closed.
|
||||
const aside = document.getElementById('sessionSidebar');
|
||||
if (aside) {
|
||||
aside.classList.toggle('open', mode === 'sidebar' && !collapsed);
|
||||
// A closed overlay drawer is only moved off screen by translateX(-100%);
|
||||
// it keeps display:flex, so without this its filter box and ~4 tab stops
|
||||
// per session stay in the Tab order and in the accessibility tree.
|
||||
// NOT applied to the docked desktop rail — its rows are still clickable.
|
||||
const hiddenDrawer = mode === 'sidebar' && collapsed && this._isSessionSidebarOverlay();
|
||||
aside.toggleAttribute('inert', hiddenDrawer);
|
||||
if (hiddenDrawer) aside.setAttribute('aria-hidden', 'true');
|
||||
else aside.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
// The filter box only exists inside the sidebar; leaving a stale filter
|
||||
// applied when the layout goes back to the header strip would hide sessions
|
||||
// from the tab bar with no reachable control to clear it.
|
||||
if (mode !== 'sidebar') {
|
||||
this._sidebarFilter = '';
|
||||
const filterInput = document.getElementById('sessionSidebarFilter');
|
||||
if (filterInput) filterInput.value = '';
|
||||
}
|
||||
|
||||
// applyTabWrapSettings() (settings-ui.js) is the ONE owner of
|
||||
// tabs-two-rows / tabs-show-folder / _tallTabsEnabled and is itself
|
||||
// sidebar-aware — it reads the data-session-list attribute set just above,
|
||||
// so it must run AFTER it. It re-renders by itself when the folder row
|
||||
// appears or disappears.
|
||||
const prevTall = this._tallTabsEnabled;
|
||||
this.applyTabWrapSettings();
|
||||
// A layout flip alone still needs one render: the rows are rebuilt into the
|
||||
// new host with the drag/keyboard handlers re-bound. Skipped when
|
||||
// applyTabWrapSettings() already rendered for the folder-row change.
|
||||
if (prevMode !== mode && prevTall === this._tallTabsEnabled) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
|
||||
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
|
||||
// for a frame before the next measure.
|
||||
if (mode === 'sidebar') tabsEl.classList.remove('tabs-auto-wrap');
|
||||
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
|
||||
// here too — not only at the render tails.
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
this.updateConnectionLines();
|
||||
// The desktop home rail defers to the sidebar (both dock the session list
|
||||
// flush left), so a layout flip while the welcome screen is up has to
|
||||
// re-evaluate it — showHomeSessions() self-gates on shouldShowHomeSessions().
|
||||
if (document.getElementById('welcomeOverlay')?.classList.contains('visible')) {
|
||||
this.showHomeSessions?.();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSessionSidebar() {
|
||||
if (!this.isSessionSidebarActive()) return;
|
||||
const collapsed = !this.isSessionSidebarCollapsed();
|
||||
this._sidebarCollapsedOverride = collapsed;
|
||||
try {
|
||||
localStorage.setItem('codeman-sidebar-collapsed', collapsed ? '1' : '0');
|
||||
} catch {}
|
||||
// Collapsing hides the filter row. If focus is sitting in there it would be
|
||||
// reset to <body>, dropping the user back to the top of the tab order — so
|
||||
// hand it to the toggle, which is the control they just used.
|
||||
if (collapsed && this.$('sessionSidebar')?.contains(document.activeElement)) {
|
||||
document.getElementById('sidebarToggleBtn')?.focus();
|
||||
}
|
||||
this.applySessionListLayout();
|
||||
// Opening the MODAL drawer moves focus into it, as a dialog should. The
|
||||
// docked desktop sidebar is not modal: stealing focus there would pull the
|
||||
// caret out of the terminal mid-prompt, and .session-tab handles only
|
||||
// arrows/Home/End/Enter/Space, so everything typed after would be swallowed.
|
||||
if (!collapsed && this._isSessionSidebarOverlay()) {
|
||||
this.$('sessionTabs')?.querySelector('.session-tab.active')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay layouts only: below 1024px the sidebar is a modal drawer on top of
|
||||
* the terminal (mobile.css), so picking a session from it must get it out of
|
||||
* the way again. The docked desktop sidebar stays exactly where the user put
|
||||
* it. No-op unless the drawer is actually open.
|
||||
*/
|
||||
closeSessionSidebarOnHandheld() {
|
||||
if (!this._isSessionSidebarOverlay()) return;
|
||||
if (!this.isSessionSidebarActive() || this.isSessionSidebarCollapsed()) return;
|
||||
this.toggleSessionSidebar();
|
||||
}
|
||||
|
||||
updateSidebarCount() {
|
||||
const el = document.getElementById('sessionSidebarCount');
|
||||
if (el) el.textContent = String(this.sessions?.size ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar filter box. Pure DOM class toggling — no re-render, no state on the
|
||||
* sessions themselves. Matches the rendered aria-label (session name) and the
|
||||
* title (working directory).
|
||||
*
|
||||
* Re-applied at the tail of both render paths: _fullRenderSessionTabs() rebuilds
|
||||
* innerHTML wholesale, so without that the filtered-out rows flicker back in on
|
||||
* every SSE tick.
|
||||
*
|
||||
* The filter only takes effect while the box that produced it is on screen —
|
||||
* i.e. the expanded sidebar. In the header strip, the collapsed rail or a
|
||||
* closed drawer the classes come off, otherwise sessions would stay hidden
|
||||
* with no visible cause and no reachable control to clear them. The remembered
|
||||
* needle is restored when the box comes back.
|
||||
*/
|
||||
applySidebarFilter(query) {
|
||||
this._sidebarFilter = (query ?? '').trim().toLowerCase();
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
continue;
|
||||
}
|
||||
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
||||
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3612,6 +3889,16 @@ class CodemanApp {
|
||||
container.querySelector('.session-tab.active');
|
||||
if (!tab) return;
|
||||
|
||||
// Sidebar layout: the list scrolls VERTICALLY in its own scroller, so the
|
||||
// horizontal computeTabScrollLeft math below would always no-op (scrollLeft
|
||||
// pinned at 0). With 25+ sessions the active row is routinely below the
|
||||
// fold; 'nearest' never scrolls when it is already visible, and only the
|
||||
// list's own scroller moves — the drawer and document stay put.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
tab.scrollIntoView({ block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
|
||||
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
|
||||
if (!policy) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
@@ -3636,6 +3923,45 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a floating window (subagent / ultracode) attaches to its parent tab.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar: to the
|
||||
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
|
||||
* on top of the sidebar and its bezier loops backwards underneath it.
|
||||
*/
|
||||
_tabAnchor(rect) {
|
||||
if (this.isSessionSidebarActive()) {
|
||||
return {
|
||||
x: rect.right,
|
||||
y: rect.top + rect.height / 2,
|
||||
spawnLeft: rect.right + 14,
|
||||
spawnTop: rect.top,
|
||||
vertical: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom,
|
||||
spawnLeft: rect.left,
|
||||
spawnTop: rect.bottom,
|
||||
vertical: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
|
||||
_tabConnectorPath(anchor, winRect) {
|
||||
if (anchor.vertical) {
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (anchor.y + y2) / 2;
|
||||
return `M ${anchor.x} ${anchor.y} C ${anchor.x} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
}
|
||||
const x2 = winRect.left;
|
||||
const y2 = winRect.top + winRect.height / 2;
|
||||
const midX = (anchor.x + x2) / 2;
|
||||
return `M ${anchor.x} ${anchor.y} C ${midX} ${anchor.y}, ${midX} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
_setTerminalLoadState(sessionId, selectGen, phase) {
|
||||
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
|
||||
this._updateTerminalLoadTab(sessionId);
|
||||
@@ -3871,6 +4197,9 @@ class CodemanApp {
|
||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||
// is an arc to keep anchored.
|
||||
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
@@ -3880,6 +4209,13 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
// The sidebar list is a single vertical column with its own scroller —
|
||||
// there is no row to overflow, and measuring it would fight the CSS.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = MobileDetection.getDeviceType();
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3928,6 +4264,15 @@ class CodemanApp {
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
||||
// here so a render triggered straight from applyTabWrapSettings() — which
|
||||
// only knows the header strip — cannot leave the sidebar folderless.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
this._tallTabsEnabled = true;
|
||||
container.classList.add('tabs-show-folder');
|
||||
container.classList.remove('tabs-two-rows', 'tabs-auto-wrap');
|
||||
}
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||||
this.cancelHideSubagentDropdown();
|
||||
@@ -3938,6 +4283,9 @@ class CodemanApp {
|
||||
// right-hand tabs kept getting yanked back to the first one. Remember
|
||||
// where the strip was; the browser clamps the restore to the new content.
|
||||
const prevScrollLeft = container.scrollLeft;
|
||||
// Sidebar layout scrolls the same container VERTICALLY, so it needs the
|
||||
// same protection on the other axis.
|
||||
const prevScrollTop = container.scrollTop;
|
||||
const prevActiveTabId = this._lastRenderedActiveTabId;
|
||||
const isFirstRender = !container.querySelector('.session-tab');
|
||||
|
||||
@@ -3992,7 +4340,7 @@ class CodemanApp {
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (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>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -4025,6 +4373,7 @@ class CodemanApp {
|
||||
// the strip while a background rebuild fires, without the active tab ever
|
||||
// being stranded off-screen after a switch.
|
||||
container.scrollLeft = prevScrollLeft;
|
||||
container.scrollTop = prevScrollTop;
|
||||
this._lastRenderedActiveTabId = this.activeSessionId;
|
||||
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
|
||||
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
|
||||
@@ -4047,6 +4396,11 @@ class CodemanApp {
|
||||
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
|
||||
// than restarting, since this rebuild just destroyed the animating elements.
|
||||
this._applyTabEntrances?.();
|
||||
|
||||
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
|
||||
// re-apply them or filtered-out sessions flicker back on every SSE tick.
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -4057,9 +4411,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this._tabKeydownHandler = (e) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
|
||||
// Up/Down are aliases of Left/Right, not replacements: the strip stays
|
||||
// arrow-key navigable exactly as before, the vertical sidebar just gains
|
||||
// the axis a user reaches for there.
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
|
||||
|
||||
const tabs = [...container.querySelectorAll('.session-tab')];
|
||||
// Rows hidden by the sidebar filter must not be steppable.
|
||||
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
// Enter or Space activates the tab
|
||||
@@ -4075,9 +4433,11 @@ class CodemanApp {
|
||||
let newIndex;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
|
||||
break;
|
||||
case 'Home':
|
||||
@@ -4209,14 +4569,19 @@ class CodemanApp {
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Determine drop position based on mouse position
|
||||
// Determine drop position based on mouse position. Read the layout here,
|
||||
// inside the handler — these listeners survive a layout flip between
|
||||
// renders, so capturing the axis at bind time would go stale.
|
||||
// drag-over-left/-right keep their names and now read as before/after;
|
||||
// the sidebar CSS just draws them as top/bottom edges.
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const isLeftHalf = e.clientX < midpoint;
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
// Update visual indicator
|
||||
tab.classList.toggle('drag-over-left', isLeftHalf);
|
||||
tab.classList.toggle('drag-over-right', !isLeftHalf);
|
||||
tab.classList.toggle('drag-over-left', insertBefore);
|
||||
tab.classList.toggle('drag-over-right', !insertBefore);
|
||||
});
|
||||
|
||||
tab.addEventListener('dragleave', () => {
|
||||
@@ -4232,10 +4597,11 @@ class CodemanApp {
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
// Determine insertion position
|
||||
// Determine insertion position (same axis rule as the dragover handler)
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const insertBefore = e.clientX < midpoint;
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
// Reorder sessionOrder array
|
||||
const fromIndex = this.sessionOrder.indexOf(draggedId);
|
||||
@@ -4750,6 +5116,9 @@ class CodemanApp {
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
this._updateActiveTabImmediate(sessionId);
|
||||
// Handheld: the session drawer overlays the terminal, so slide it away now
|
||||
// that a session has been picked. No-op on desktop and in header layout.
|
||||
this.closeSessionSidebarOnHandheld();
|
||||
this.renderSessionTabs();
|
||||
this.updateAttachmentHistoryBadge?.();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
|
||||
@@ -37,14 +37,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
async seedApprovals() {
|
||||
if (!this.approvals) this.approvals = new Map();
|
||||
this.approvals.clear();
|
||||
if (this.approvalsInboxEnabled()) {
|
||||
// ⚠ Fetch and re-arm the tab-alert state machine REGARDLESS of the inbox
|
||||
// setting. The server-side approval store runs unconditionally (only the
|
||||
// inbox SURFACES are opt-in), and the red/yellow tab alert predates the
|
||||
// inbox: gating the seed on the setting meant that with the inbox off, a
|
||||
// reload landed with every alert store empty while a permission dialog sat
|
||||
// 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) || []) {
|
||||
this.approvals.set(item.id, item);
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -68,14 +76,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onApprovalResolved(info) {
|
||||
if (!info || !info.id || !this.approvals) return;
|
||||
if (this.approvals.delete(info.id)) {
|
||||
// Clear the matching tab alert: the inbox resolves on more signals than
|
||||
// the hook handlers do (superseded, expired, answered from another
|
||||
// device), and clearPendingHooks is a no-op when nothing is set.
|
||||
if (!info || !info.id) return;
|
||||
// Clear the matching tab alert UNCONDITIONALLY: the inbox resolves on more
|
||||
// signals than the hook handlers do (superseded, expired, answered from
|
||||
// another device), clearPendingHooks is a no-op when nothing is set, and
|
||||
// with the inbox setting OFF the item was never stored in `this.approvals`
|
||||
// even though seedApprovals armed the alert — gating the clear on a map hit
|
||||
// would strand that alert forever.
|
||||
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
||||
this.renderApprovals();
|
||||
}
|
||||
if (this.approvals?.delete(info.id)) this.renderApprovals();
|
||||
},
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
+34
-20
@@ -222,23 +222,35 @@ function computeTabScrollLeft(input) {
|
||||
// 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
|
||||
// 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
|
||||
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
||||
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
||||
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
||||
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
||||
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
||||
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
||||
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
||||
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||
// 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_PER_PX = 0.085;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
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
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
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) {
|
||||
const parent = input?.parent;
|
||||
@@ -269,18 +281,19 @@ function computeLineagePath(input) {
|
||||
const cBottom = cTop + ch;
|
||||
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
|
||||
// row is lower, so one formula covers a flat strip and a wrapped one.
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||
// 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 rowDrop = Math.abs(cBottom - pBottom);
|
||||
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
||||
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
||||
// the row it crosses instead of grazing its bottom edge.
|
||||
const stripBottom =
|
||||
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||
? Number(strip.top) + Number(strip.height)
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||
const dip =
|
||||
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 +
|
||||
rowDrop;
|
||||
const yc = Math.max(pBottom, cBottom) + dip;
|
||||
depth * LINEAGE_SIBLING_STEP_PX;
|
||||
const yc = baseline + dip;
|
||||
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 };
|
||||
}
|
||||
@@ -441,6 +454,7 @@ if (typeof window !== 'undefined') {
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
compute: computeConnectionLossUi,
|
||||
|
||||
@@ -85,6 +85,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
shouldShowHomeSessions() {
|
||||
if (this.isSoloWindow) return false;
|
||||
if (this.shouldUseMobileOverview?.()) return false;
|
||||
// The sidebar layout already docks the full session list flush left at full
|
||||
// height — the rail would render the same list right next to it (and z-wise
|
||||
// UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
|
||||
if (this.isSessionSidebarActive?.()) return false;
|
||||
return window.innerWidth >= HOME_SESSIONS_MIN_WIDTH;
|
||||
},
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
'Skip to terminal': '跳转到终端',
|
||||
'Go to main page': '返回主页',
|
||||
'Session tabs': '会话标签页',
|
||||
/* 'Sessions' (the sidebar heading) is already mapped further down. */
|
||||
'Collapse session sidebar': '收起会话侧边栏',
|
||||
'Expand session sidebar': '展开会话侧边栏',
|
||||
'Filter sessions': '筛选会话',
|
||||
'Admin Panel': '管理面板',
|
||||
'Open admin panel': '打开管理面板',
|
||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||
@@ -227,6 +231,11 @@
|
||||
'Cron Button': '定时任务按钮',
|
||||
'Redraw Terminal Button': '重绘终端按钮',
|
||||
'Tab Bar': '标签栏',
|
||||
'Session List Layout': '会话列表布局',
|
||||
'Header tab strip': '顶栏标签条',
|
||||
'Left sidebar': '左侧边栏',
|
||||
'Horizontal strip in the header, or a collapsible left sidebar (Alt+B).':
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
Panels: '面板',
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
layer loads below; setting lang/dir here prevents an English accessibility
|
||||
tree from flashing while the deferred scripts start. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
|
||||
<!-- Apply the saved session-list layout (header strip vs. left sidebar) and the
|
||||
sidebar collapse state before first paint, so the loading skeleton and the
|
||||
first frame already match. Same per-device settings key as the language
|
||||
script above. Solo windows (/session/:id) never get a sidebar — mirrors
|
||||
_detectSoloSessionId() in app.js. With no stored collapse choice the
|
||||
docked desktop sidebar starts open and the off-canvas overlay drawer
|
||||
starts closed — the overlay test is `innerWidth < 1024`, matching
|
||||
mobile.css's media attribute below and _isSessionSidebarOverlay() in
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');document.documentElement.dataset.sessionList=(L==='sidebar'&&!solo)?'sidebar':'header';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebar='expanded';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -58,8 +70,22 @@
|
||||
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
|
||||
.skeleton-body{flex:1;display:flex;min-height:0}
|
||||
.skeleton-sidebar{display:none;width:44px;flex:0 0 44px;background:var(--glass-bg,rgba(31,38,48,0.85));border-right:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
|
||||
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
/* Sidebar layout: the strip skeleton would flash a grey pill where no strip
|
||||
will be, so swap it for a rail matching --sidebar-width-collapsed. */
|
||||
html[data-session-list="sidebar"] .skeleton-tabs{display:none}
|
||||
/* Only >=1024px docks the sidebar and reserves layout width; below that it is
|
||||
an off-canvas overlay, so a rail in the skeleton would be a strip that
|
||||
vanishes. The pre-paint script has already resolved the collapse state, so
|
||||
match the real width and spare the terminal a 216px sideways jump once
|
||||
styles.css lands. */
|
||||
@media (min-width: 1024px) {
|
||||
html[data-session-list="sidebar"] .skeleton-sidebar{display:block}
|
||||
html[data-session-list="sidebar"][data-sidebar="expanded"] .skeleton-sidebar{width:260px;flex:0 0 260px}
|
||||
}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -70,7 +96,10 @@
|
||||
<span class="skeleton-brand">Codeman</span>
|
||||
<div class="skeleton-tabs"><div class="skeleton-tab"></div></div>
|
||||
</div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-sidebar"></div>
|
||||
<div class="skeleton-terminal"></div>
|
||||
</div>
|
||||
<div class="skeleton-toolbar"></div>
|
||||
</div>
|
||||
<!-- Skip link for keyboard users -->
|
||||
@@ -84,10 +113,27 @@
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page"
|
||||
><span class="logo-text">Codeman</span><span class="logo-compact" aria-hidden="true">C</span></span
|
||||
>
|
||||
<!-- Collapse/expand the session sidebar. Lives in .header-brand, NOT in
|
||||
#headerRight: test/mobile-header-buttons-policy.test.ts only enumerates
|
||||
buttons inside .header-right, and on a phone this button is the only
|
||||
way to open the off-canvas session drawer, so it must never be hidden
|
||||
by the phone header policy. Shown only in sidebar layout — visibility
|
||||
via marker class, never inline style. -->
|
||||
<button class="btn-icon-header btn-sidebar-toggle btn-sidebar-toggle--hidden"
|
||||
id="sidebarToggleBtn" onclick="app.toggleSessionSidebar()"
|
||||
title="Collapse session sidebar" aria-label="Collapse session sidebar"
|
||||
aria-expanded="true" aria-controls="sessionSidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
<!-- Session Tabs. In sidebar layout THIS VERY #sessionTabs element is
|
||||
re-parented into #sessionSidebarList by applySessionListLayout() and
|
||||
this host is hidden — it is never cloned or rebuilt, because
|
||||
app.$('sessionTabs') caches it by object identity and never invalidates. -->
|
||||
<div class="session-tabs-host" id="sessionTabsHost">
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs" aria-orientation="horizontal">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
@@ -309,6 +355,26 @@
|
||||
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
|
||||
markup: applySessionListLayout() moves #sessionTabs in here, so the
|
||||
vertical list is the exact same DOM node as the header strip and every
|
||||
renderer, drag handler and webview-tabs.js consumer keeps working.
|
||||
Must stay a SIBLING of .terminal-wrap — .main.webview-active hides
|
||||
.terminal-wrap, and the sidebar has to survive that. -->
|
||||
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
|
||||
<div class="session-sidebar-head">
|
||||
<span class="session-sidebar-title">Sessions</span>
|
||||
<span class="session-sidebar-count" id="sessionSidebarCount" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="session-sidebar-filter">
|
||||
<input type="search" id="sessionSidebarFilter" class="session-sidebar-filter-input"
|
||||
placeholder="Filter sessions" aria-label="Filter sessions"
|
||||
autocomplete="off" spellcheck="false"
|
||||
oninput="app.applySidebarFilter(this.value)">
|
||||
</div>
|
||||
<div class="session-sidebar-list" id="sessionSidebarList"></div>
|
||||
</aside>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
<!-- Partial-history notice (#258). Lives OUTSIDE the terminal on purpose:
|
||||
the old notice was a grey line written into the scrollback, so it
|
||||
@@ -687,6 +753,7 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>B</kbd></div><div>Toggle Session Sidebar</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
@@ -694,8 +761,8 @@
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
@@ -1211,6 +1278,13 @@
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -1766,6 +1840,16 @@
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Session List Layout</span>
|
||||
<span class="set-row-desc">Horizontal strip in the header, or a collapsible left sidebar (Alt+B).</span>
|
||||
</div>
|
||||
<select id="appSettingsSessionListLayout" class="set-select">
|
||||
<option value="header">Header tab strip</option>
|
||||
<option value="sidebar">Left sidebar</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row" data-search="tall tabs folder name two rows">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tall Tabs</span>
|
||||
|
||||
@@ -168,6 +168,11 @@ const MobileDetection = {
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
// Whether the session sidebar is a docked column or a modal overlay is
|
||||
// decided at 1024px, so crossing that width has to re-sync the drawer
|
||||
// state — otherwise the `inert`/aria-hidden set on a closed overlay
|
||||
// drawer survives into the docked rail and makes it unclickable.
|
||||
if (typeof app !== 'undefined') app.applySessionListLayout?.();
|
||||
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
|
||||
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
|
||||
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
|
||||
@@ -652,6 +657,7 @@ const SwipeHandler = {
|
||||
_touchStartHandler: null,
|
||||
_touchEndHandler: null,
|
||||
_element: null,
|
||||
_ignoreGesture: false,
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
@@ -680,6 +686,12 @@ const SwipeHandler = {
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
// The session sidebar is an overlay child of .main, so its touches bubble in
|
||||
// here. Swiping across the open session drawer — the natural "dismiss it"
|
||||
// gesture — would otherwise fire nextSession() and drop the user into a
|
||||
// session they never tapped.
|
||||
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
@@ -687,6 +699,10 @@ const SwipeHandler = {
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (this._ignoreGesture) {
|
||||
this._ignoreGesture = false;
|
||||
return;
|
||||
}
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
|
||||
@@ -497,6 +497,20 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Exception to the 26px shrink above: in sidebar layout this button is the
|
||||
ONLY way to open the session list — the strip it replaced is gone. A 26px
|
||||
target is below --touch-target-min (44px), which the 430-768px block
|
||||
already enforces for every other header button. */
|
||||
html[data-session-list='sidebar'] #sidebarToggleBtn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'] #sidebarToggleBtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||
@@ -3604,3 +3618,117 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
background: rgba(var(--accent-rgb), 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
SESSION SIDEBAR — off-canvas drawer (tablet + phone)
|
||||
============================================================================
|
||||
This whole file is served with media="(max-width: 1023px)", so these
|
||||
top-level rules cover the entire handheld range — deliberately NOT wrapped in
|
||||
a nested @media, because the two compact `.session-tabs` blocks above live in
|
||||
`max-width: 768px` and `max-width: 430px` and would leave 769-1023px
|
||||
unhandled.
|
||||
|
||||
Placement at the END of the file is load-bearing: the compact strip blocks at
|
||||
lines ~117 and ~584 use the deliberate `.session-tabs, .session-tabs.tabs-two-rows`
|
||||
(0,2,0) doubling documented there. The sidebar selectors below are (0,2,1)
|
||||
and up AND come later, so they win on both counts. Move this block and the
|
||||
list collapses to a 36px sliver that looks like an empty list.
|
||||
|
||||
Why an overlay instead of the desktop rail: 44px is 11% of a 393px viewport.
|
||||
Below 1024px the sidebar never occupies layout width — it slides over the
|
||||
terminal, following the .attachment-history-drawer recipe in styles.css.
|
||||
`collapsed` therefore means "drawer closed", and applySessionListLayout()
|
||||
mirrors that into the `.open` class. */
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(280px, 80vw);
|
||||
flex: 0 0 auto;
|
||||
transform: translateX(-100%);
|
||||
/* visibility, not just transform: an off-screen drawer keeps display:flex, so
|
||||
without this its filter box and ~4 tab stops per session stay in the Tab
|
||||
order and in the a11y tree. applySessionListLayout() also sets `inert`; this
|
||||
is the CSS half, and the transition keeps it visible for the slide-out. */
|
||||
visibility: hidden;
|
||||
transition: transform var(--sidebar-transition), visibility var(--sidebar-transition);
|
||||
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.36);
|
||||
z-index: 12;
|
||||
padding-left: var(--safe-area-left);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar.open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Collapsed == closed here, so the desktop icon-rail styling must not apply:
|
||||
the drawer keeps its full width and its head/filter/labels while it is off
|
||||
screen, otherwise opening it would animate in a 44px stub. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar {
|
||||
flex-basis: auto;
|
||||
width: min(280px, 80vw);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-head,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-filter {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .session-tab {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Undo the rail's content trimming: these rows are full-width drawer rows, just
|
||||
currently off screen. Same specificity as the styles.css rail rules and later
|
||||
in the cascade, which is why this file must stay loaded after styles.css. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-subagent-badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* mobile.css:~604 pins .session-tab to max-height:32px for the horizontal strip,
|
||||
which clips the folder row the sidebar always renders. Rows also need the
|
||||
44px touch target the strip cannot afford. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab {
|
||||
min-height: 44px;
|
||||
max-height: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Touch has no hover: reveal-on-hover row actions would be unreachable.
|
||||
Matches the (hover: none) block above, but has to be repeated here because
|
||||
the phone block hides them on non-active tabs with (0,2,0). */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
min-width: 28px;
|
||||
height: auto;
|
||||
margin-left: 0;
|
||||
padding: 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
/* (.tab-filtered-out is handled in styles.css — its rule is already scoped to
|
||||
html[data-session-list="sidebar"] and carries !important, so it wins here too;
|
||||
no handheld variant needed.) */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @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)
|
||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||
*/
|
||||
@@ -90,6 +90,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
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.
|
||||
*
|
||||
@@ -101,6 +130,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
_appendLineageConnectionLines(svg, rects) {
|
||||
this._lineageEdgeCount = 0;
|
||||
if (!svg || !this._lineageLinesEnabled()) return;
|
||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
||||
// sidebar, so every arc would draw a giant loop to the foot of the list.
|
||||
// Parent/child adjacency reads fine in a vertical list without arcs; a
|
||||
// sideways lineage shape is a follow-up with its own visual tuning, not a
|
||||
// by-product of a layout port.
|
||||
if (this.isSessionSidebarActive?.()) return;
|
||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
||||
if (!compute) return;
|
||||
|
||||
@@ -137,6 +174,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' 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.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||
line.setAttribute('data-parent-tab', edge.parentId);
|
||||
@@ -153,6 +194,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
},
|
||||
@@ -169,7 +211,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
this._lineageScrollHandler = () => {
|
||||
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
|
||||
// Sidebar layout scrolls the SAME element vertically, and there the
|
||||
// subagent/ultracode connectors anchor to tab rects too (lineage arcs are
|
||||
// skipped, so _lineageEdgeCount alone would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
},
|
||||
|
||||
@@ -1283,12 +1283,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
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)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
@@ -1761,7 +1814,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
||||
// should give the whole line to the input.
|
||||
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px';
|
||||
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
||||
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
@@ -387,6 +387,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||||
@@ -2007,6 +2009,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -2151,7 +2154,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
// Re-parents #sessionTabs between header host and sidebar if the layout
|
||||
// changed, then calls applyTabWrapSettings() itself — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyLineageLineSettings?.();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
@@ -2389,6 +2394,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
sessionListLayout: 'header',
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
@@ -2633,19 +2639,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const deviceType = MobileDetection.getDeviceType();
|
||||
// The left sidebar is one vertical column with its own scroller: there is no
|
||||
// row to wrap into, and its rows are always tall (name + folder) because that
|
||||
// is the cheapest way to tell 25 sessions apart. Header strip keeps the old
|
||||
// rules unchanged. Kept here rather than only in applySessionListLayout() so
|
||||
// that a stray applyTabWrapSettings() call (this one is invoked from
|
||||
// saveAppSettings and from the resize path) cannot leave the sidebar wrapped.
|
||||
const sidebar = this.isSessionSidebarActive?.() === true;
|
||||
// Two-row tabs disabled on mobile/tablet — not enough screen space
|
||||
const twoRows = deviceType === 'desktop'
|
||||
const twoRows = !sidebar && deviceType === 'desktop'
|
||||
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
||||
: false;
|
||||
const showFolder = sidebar || twoRows;
|
||||
const prevTallTabs = this._tallTabsEnabled;
|
||||
this._tallTabsEnabled = twoRows;
|
||||
this._tallTabsEnabled = showFolder;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
if (tabsEl) {
|
||||
tabsEl.classList.toggle('tabs-two-rows', twoRows);
|
||||
tabsEl.classList.toggle('tabs-show-folder', twoRows);
|
||||
tabsEl.classList.toggle('tabs-show-folder', showFolder);
|
||||
}
|
||||
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
|
||||
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
|
||||
if (prevTallTabs !== undefined && prevTallTabs !== showFolder) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
},
|
||||
@@ -2850,7 +2864,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
|
||||
+332
-19
@@ -49,6 +49,9 @@
|
||||
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
|
||||
--header-height: 36px;
|
||||
--toolbar-height: 42px;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-collapsed: 44px; /* == --touch-target-min */
|
||||
--sidebar-transition: 0.18s ease;
|
||||
--glass-bg: rgba(31, 38, 48, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--control-bg: rgba(255, 255, 255, 0.045);
|
||||
@@ -1459,23 +1462,80 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Tab alert animations */
|
||||
.session-tab.tab-alert-action {
|
||||
/* Tab alerts: a STEADY red/yellow base with a pulse breathing on top.
|
||||
⚠ 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
0%, 100% { background: transparent; border-color: transparent; }
|
||||
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
|
||||
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.3); box-shadow: 0 0 16px rgba(239, 68, 68, 0.75); }
|
||||
}
|
||||
|
||||
@keyframes tab-blink-yellow {
|
||||
0%, 100% { background: transparent; border-color: transparent; }
|
||||
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
|
||||
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.24); box-shadow: 0 0 14px rgba(234, 179, 8, 0.65); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -2081,13 +2141,20 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
/* 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>.
|
||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||
re-focus affordance for the popped-out window. */
|
||||
html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach {
|
||||
re-focus affordance for the popped-out window. A SINGLE tab can also opt in
|
||||
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;
|
||||
}
|
||||
|
||||
/* ===== Solo (detached single-session) window chrome ===================== */
|
||||
body.solo-mode .session-tabs,
|
||||
body.solo-mode .session-tabs-host,
|
||||
body.solo-mode .session-sidebar,
|
||||
body.solo-mode .btn-sidebar-toggle,
|
||||
body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
@@ -9329,11 +9396,15 @@ kbd {
|
||||
Deliberately quieter and thinner than the subagent lines above so the two
|
||||
layers read as different things in the same SVG.
|
||||
|
||||
Colour comes from --session-blue, which EVERY skin block already defines and
|
||||
already tunes for its own background, so one rule covers all seven (the four
|
||||
light skins included). 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.
|
||||
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||
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
|
||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||
@@ -9352,13 +9423,13 @@ kbd {
|
||||
(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. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--session-blue, #2b8fd9);
|
||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-blue, #2b8fd9))
|
||||
drop-shadow(0 0 11px 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(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
@@ -9374,9 +9445,10 @@ kbd {
|
||||
}
|
||||
|
||||
.lineage-line-dot {
|
||||
fill: var(--session-blue, #2b8fd9);
|
||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
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
|
||||
@@ -16531,3 +16603,244 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
=== Collapsible session sidebar (opt-in layout) ===
|
||||
Appended at top level ON PURPOSE: styles.css:12171-12390 is one
|
||||
html:not([data-skin="og"]) { … } native-nesting block whose bare
|
||||
selectors resolve at (0,2,x) and re-tone .session-tab with
|
||||
!important. Everything below is LAYOUT ONLY (flex/size/overflow/
|
||||
display) and sets no colour on .session-tab, so it composes with
|
||||
every skin instead of fighting it. Keep it that way.
|
||||
|
||||
The list itself is not a second DOM tree: applySessionListLayout()
|
||||
moves the one #sessionTabs element between #sessionTabsHost (header)
|
||||
and #sessionSidebarList (this aside).
|
||||
============================================================ */
|
||||
|
||||
/* Header host — wraps #sessionTabs so the strip can be hidden without
|
||||
touching the element that gets re-parented. */
|
||||
.session-tabs-host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-tabs-host {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The header only needs flex-start to support the two-row strip; with the
|
||||
strip gone the remaining header chrome should sit centered. */
|
||||
html[data-session-list="sidebar"] .header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
width: var(--sidebar-width);
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border);
|
||||
/* Own stacking context ABOVE .welcome-overlay (z-index 10, which is what a
|
||||
user with no open session sees) but BELOW .toolbar (20) — raising it to or
|
||||
past 20 makes the Run menu unclickable again. */
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
transition: flex-basis var(--sidebar-transition), width var(--sidebar-transition);
|
||||
/* Deliberately NO contain:paint — .header has it, which is exactly why app.js
|
||||
re-parents .subagent-dropdown to <body>. Leaving it off keeps per-row
|
||||
dropdowns and the inline rename input paintable in place. */
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar {
|
||||
flex-basis: var(--sidebar-width-collapsed);
|
||||
width: var(--sidebar-width-collapsed);
|
||||
}
|
||||
|
||||
.session-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-sidebar-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.session-sidebar-filter {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Host for the relocated #sessionTabs. */
|
||||
.session-sidebar-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- The relocated strip, now vertical --------------------------------- */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tabs {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: var(--btn-radius);
|
||||
}
|
||||
|
||||
/* .tab-info is already column/overflow-hidden/min-width:0 — it only has to
|
||||
claim the free width now that rows are full-width. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-name {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
|
||||
actions permanently visible on the active session — no layout jitter when
|
||||
the pointer crosses the list. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Drag-reorder indicators become horizontal edges. The class names stay
|
||||
drag-over-left / drag-over-right (they read as before/after now) so app.js,
|
||||
the base rules above and the generated gesture bundle need no renaming. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left {
|
||||
box-shadow: 0 -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right {
|
||||
box-shadow: 0 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Sidebar filter box (applySidebarFilter toggles this class post-render).
|
||||
Scoped to the sidebar layout on purpose: applySidebarFilter() already strips
|
||||
the class whenever the filter box is off screen, and this prefix is the
|
||||
second lock — a leaked class must never be able to hide tabs from the header
|
||||
strip, which has no filter control to clear it with. */
|
||||
html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Collapsed rail ---------------------------------------------------- */
|
||||
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
||||
task/subagent/ultracode badges) is the whole point of mission control and
|
||||
must survive collapse. The rail is also its own reopen affordance — clicking
|
||||
a row still switches session.
|
||||
NOT surviving: the name, the folder and the `sh`/`oc`/`cx`/`gm` mode chip —
|
||||
the chip is rendered inside .tab-info (app.js row template), which the rail
|
||||
hides. Moving it out of .tab-info just to keep it would change the shared row
|
||||
markup for both layouts; agent type stays a hover/expand affordance. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-head,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 44px rail minus the list's 0.25rem padding either side minus the row's 1px
|
||||
borders leaves ~34px of content box. Number (16) + gap (5.6) + dot (6) + gap
|
||||
(5.6) + one badge (16) already overflows that, and .tab-number / .tab-status
|
||||
are flex-shrink: 0 — with justify-content: center the excess gets clipped at
|
||||
BOTH ends, so the digit and the badge are cut in half. Two fixes, both
|
||||
needed: drop the Alt+N hint (it is a keyboard affordance that only reads in
|
||||
the expanded list; Alt+N itself keeps working), and let whatever is left wrap
|
||||
instead of clipping, so a row carrying several badges just gets taller. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .session-tab {
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The subagent badge carries a 4px left margin tuned for the horizontal strip;
|
||||
in a centered 34px rail it pushes the row off-centre. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-subagent-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* --- Toggle button ----------------------------------------------------- */
|
||||
.btn-sidebar-toggle--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* .btn-icon-header:hover rotates 45deg globally — a panel glyph must not spin. */
|
||||
.btn-sidebar-toggle:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,15 +401,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw curved line from TAB bottom-center to window top-center
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
|
||||
// Bezier curve control points for smooth curve
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
// Draw a curved line from the tab to the window. Header strip: tab
|
||||
// bottom-center → window top-center (vertical). Sidebar: tab right-edge →
|
||||
// window left-edge (horizontal), otherwise the curve loops backwards
|
||||
// underneath the sidebar. _tabAnchor/_tabConnectorPath live in app.js.
|
||||
const anchor = this._tabAnchor(tabRect);
|
||||
const path = this._tabConnectorPath(anchor, winRect);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
@@ -749,9 +746,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.bottom = 'auto';
|
||||
} else if (flyFromTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
// Spawn at the tab: below it in header layout, to its RIGHT in sidebar
|
||||
// layout — spawning at tabRect.left there would land on top of the sidebar.
|
||||
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
|
||||
win.style.left = `${anchor.spawnLeft}px`;
|
||||
win.style.top = `${anchor.spawnTop}px`;
|
||||
win.style.transform = 'scale(0.3)';
|
||||
win.style.opacity = '0';
|
||||
win.classList.add('spawning');
|
||||
@@ -1226,6 +1225,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
dropdown.classList.add('open');
|
||||
|
||||
// Keep it on screen. A badge in the left sidebar — and above all one in the
|
||||
// 44px collapsed rail — sits so far left that a centre-anchored dropdown
|
||||
// hangs off the viewport. Measured after .open so it has a box; a no-op
|
||||
// whenever the centred position already fits, so header layout is unchanged.
|
||||
const dropRect = dropdown.getBoundingClientRect();
|
||||
const overflowLeft = 8 - dropRect.left;
|
||||
const overflowRight = dropRect.right - (window.innerWidth - 8);
|
||||
if (overflowLeft > 0) {
|
||||
dropdown.style.transform = `translateX(calc(-50% + ${Math.round(overflowLeft)}px))`;
|
||||
} else if (overflowRight > 0) {
|
||||
dropdown.style.transform = `translateX(calc(-50% - ${Math.round(overflowRight)}px))`;
|
||||
}
|
||||
},
|
||||
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
|
||||
@@ -326,6 +326,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Session-sidebar toggle chord (default Alt+B): same trap as above —
|
||||
// preventDefault() in the capture handler does not stop xterm, so without
|
||||
// this gate every toggle would ALSO send ESC b (readline backward-word)
|
||||
// into the live session and walk the cursor back through the user's
|
||||
// half-typed prompt. Registry-aware and only while the sidebar layout is
|
||||
// active, so a rebind/disable and the default header layout keep plain
|
||||
// Meta-b working in the terminal.
|
||||
if (ev.type === 'keydown' && this.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
|
||||
@@ -197,10 +197,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Position: spawn from the parent tab if we can find it, else cascade.
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
if (parentTab) {
|
||||
const r = parentTab.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
|
||||
// _tabAnchor() puts the spawn point below the tab in header layout and to
|
||||
// the RIGHT of it in sidebar layout, so the window never lands on the
|
||||
// sidebar. The viewport clamp is unchanged.
|
||||
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
|
||||
const left = Math.max(8, Math.min(anchor.spawnLeft, window.innerWidth - 392));
|
||||
win.style.left = `${left}px`;
|
||||
win.style.top = `${r.bottom + 14}px`;
|
||||
win.style.top = `${anchor.spawnTop + (anchor.vertical ? 14 : 0)}px`;
|
||||
} else {
|
||||
const n = this.ultracodeWindows.size;
|
||||
win.style.left = `${24 + n * 26}px`;
|
||||
@@ -784,16 +787,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
|
||||
}
|
||||
|
||||
// PHASE 2: writes (curve from tab bottom-center to window top-center).
|
||||
// PHASE 2: writes (curve from the tab anchor to the window — bottom-center to
|
||||
// top-center in header layout, right-edge to left-edge in sidebar layout).
|
||||
for (const { runId, parentSessionId, winRect } of winList) {
|
||||
const tabRect = rects.get('tab:' + parentSessionId);
|
||||
if (!tabRect) continue;
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const path = this._tabConnectorPath(this._tabAnchor(tabRect), winRect);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection');
|
||||
@@ -818,12 +817,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!info.element) continue;
|
||||
const winRect = info.element.getBoundingClientRect();
|
||||
// Anchor: parent run window bottom-center if open, else the run's tab.
|
||||
let px, py;
|
||||
// A window anchor is always vertical; a tab anchor follows the session-list
|
||||
// layout (_tabAnchor), so the curve leaves a sidebar row sideways.
|
||||
let anchor;
|
||||
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
|
||||
if (runWin && runWin.element) {
|
||||
const pr = runWin.element.getBoundingClientRect();
|
||||
px = pr.left + pr.width / 2;
|
||||
py = pr.bottom;
|
||||
anchor = { x: pr.left + pr.width / 2, y: pr.bottom, vertical: true };
|
||||
} else {
|
||||
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
|
||||
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
@@ -835,13 +835,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const tabRect = rects.get(tabKey);
|
||||
if (!tabRect) continue;
|
||||
px = tabRect.left + tabRect.width / 2;
|
||||
py = tabRect.bottom;
|
||||
anchor = this._tabAnchor(tabRect);
|
||||
}
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (py + y2) / 2;
|
||||
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const path = this._tabConnectorPath(anchor, winRect);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
|
||||
|
||||
@@ -156,6 +156,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
// Web tabs live in the same list as sessions, so picking one from the
|
||||
// handheld session drawer has to dismiss it too (no-op elsewhere).
|
||||
this.closeSessionSidebarOnHandheld?.();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
|
||||
@@ -945,6 +945,8 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/** Session list layout: 'header' = horizontal tab strip, 'sidebar' = collapsible left sidebar. Display key (per-device). */
|
||||
sessionListLayout: z.enum(['header', 'sidebar']).optional(),
|
||||
agentTeamsEnabled: z.boolean().optional(),
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
claudeModel: z.string().max(50).optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ function loadLineageHelper() {
|
||||
DIP_MIN_PX: number;
|
||||
DIP_MAX_PX: number;
|
||||
SIBLING_STEP_PX: number;
|
||||
COLORS: string[];
|
||||
};
|
||||
}
|
||||
).CodemanLineage;
|
||||
@@ -61,8 +62,9 @@ describe('lineage line geometry', () => {
|
||||
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 nearDip = controlYs(near.d)[0] - 34;
|
||||
const farDip = controlYs(far.d)[0] - 34;
|
||||
// The dip hangs from the STRIP's bottom edge (40), not the tab bottoms.
|
||||
const nearDip = controlYs(near.d)[0] - 40;
|
||||
const farDip = controlYs(far.d)[0] - 40;
|
||||
expect(farDip).toBeGreaterThan(nearDip);
|
||||
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_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', () => {
|
||||
const helper = loadLineageHelper();
|
||||
// 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)
|
||||
// turned it into a flat thread across the terminal.
|
||||
// is the span the feature is actually used at. The corridor has failed in BOTH
|
||||
// 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 near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||
|
||||
const wideDip = controlYs(wide.d)[0] - 34;
|
||||
const nearDip = controlYs(near.d)[0] - 34;
|
||||
const wideDip = controlYs(wide.d)[0] - 40; // from the strip's bottom edge
|
||||
const nearDip = controlYs(near.d)[0] - 40;
|
||||
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', () => {
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* @fileoverview Session list layout: header tab strip ⟷ collapsible left sidebar.
|
||||
*
|
||||
* The whole design rests on ONE invariant: there is exactly one `#sessionTabs`
|
||||
* element and `applySessionListLayout()` RE-PARENTS it between the header host
|
||||
* and the sidebar. It must never be cloned or rebuilt — `app.$(id)` caches
|
||||
* elements by id and never invalidates, and settings-ui.js / webview-tabs.js
|
||||
* resolve the same id independently, so a rebuilt container would leave every
|
||||
* consumer writing into a detached orphan, silently and without an error.
|
||||
* `keeps the same DOM node across a layout flip` below is therefore the single
|
||||
* most important assertion in this file.
|
||||
*
|
||||
* Builds a JSDOM window in-test under the default node env, same shape as
|
||||
* test/webview-menu-rows.test.ts. Do NOT declare a per-file jsdom environment:
|
||||
* it externalizes node:fs under vite and the readFileSync calls below stop
|
||||
* working. ⚠ Do not name that directive in a comment either, vitest matches the
|
||||
* string anywhere in the file.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const APP = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
|
||||
const SETTINGS_UI = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
|
||||
const MOBILE_CSS = readFileSync(new URL('../src/web/public/mobile.css', import.meta.url), 'utf-8');
|
||||
const I18N = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf-8');
|
||||
const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
|
||||
const MOBILE_HANDLERS = readFileSync(new URL('../src/web/public/mobile-handlers.js', import.meta.url), 'utf-8');
|
||||
const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url), 'utf-8');
|
||||
|
||||
interface LayoutApp {
|
||||
soloSessionId: string | null;
|
||||
sessions: Map<string, unknown>;
|
||||
sessionOrder: string[];
|
||||
_tallTabsEnabled?: boolean;
|
||||
_sidebarFilter?: string;
|
||||
_elemCache: Map<string, unknown>;
|
||||
$(id: string): Element | null;
|
||||
getSessionListLayout(): string;
|
||||
isSessionSidebarActive(): boolean;
|
||||
isSessionSidebarCollapsed(): boolean;
|
||||
applySessionListLayout(): void;
|
||||
toggleSessionSidebar(): void;
|
||||
updateSidebarCount(): void;
|
||||
closeSessionSidebarOnHandheld(): void;
|
||||
_isSessionSidebarOverlay(): boolean;
|
||||
applySidebarFilter(query?: string): void;
|
||||
_fullRenderSessionTabs(): void;
|
||||
updateConnectionLines(): void;
|
||||
}
|
||||
|
||||
/** The parts of index.html this feature touches, minus everything it does not. */
|
||||
const SHELL = `
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<span class="logo">Codeman</span>
|
||||
<button class="btn-icon-header btn-sidebar-toggle btn-sidebar-toggle--hidden"
|
||||
id="sidebarToggleBtn" aria-expanded="true" aria-controls="sessionSidebar"
|
||||
title="Collapse session sidebar" aria-label="Collapse session sidebar"></button>
|
||||
</div>
|
||||
<div class="session-tabs-host" id="sessionTabsHost">
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs" aria-orientation="horizontal"></div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
|
||||
<div class="session-sidebar-head">
|
||||
<span class="session-sidebar-title">Sessions</span>
|
||||
<span class="session-sidebar-count" id="sessionSidebarCount"></span>
|
||||
</div>
|
||||
<div class="session-sidebar-filter">
|
||||
<input type="search" id="sessionSidebarFilter" class="session-sidebar-filter-input">
|
||||
</div>
|
||||
<div class="session-sidebar-list" id="sessionSidebarList"></div>
|
||||
</aside>
|
||||
<div class="terminal-wrap"></div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
function boot(
|
||||
options: {
|
||||
stored?: Record<string, unknown>;
|
||||
solo?: string | null;
|
||||
deviceType?: string;
|
||||
viewportWidth?: number;
|
||||
} = {}
|
||||
) {
|
||||
const dom = new JSDOM(`<!doctype html><html><body>${SHELL}</body></html>`, {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { __CodemanApp: new () => LayoutApp };
|
||||
|
||||
// Whether the sidebar is a docked column or a modal overlay is decided by
|
||||
// WIDTH (< 1024px), not by MobileDetection.getDeviceType() — that one calls
|
||||
// everything from 768px up 'desktop' while mobile.css, which defines the
|
||||
// overlay, is loaded with media="(max-width: 1023px)". jsdom defaults to
|
||||
// exactly 1024, so every handheld case has to say so explicitly.
|
||||
const width = options.viewportWidth ?? ((options.deviceType ?? 'desktop') === 'desktop' ? 1440 : 393);
|
||||
Object.defineProperty(win, 'innerWidth', { value: width, configurable: true, writable: true });
|
||||
|
||||
// Handhelds read a separate settings blob (getSettingsStorageKey), so a
|
||||
// handheld harness must seed the handheld key or the layout silently stays
|
||||
// on the header strip.
|
||||
const settingsKey =
|
||||
(options.deviceType ?? 'desktop') === 'desktop' ? 'codeman-app-settings' : 'codeman-app-settings-mobile';
|
||||
if (options.stored) {
|
||||
win.localStorage.setItem(settingsKey, JSON.stringify(options.stored));
|
||||
}
|
||||
|
||||
// app.js assigns window.MobileDetection at top level from the global that
|
||||
// mobile-handlers.js declares, so it has to exist before the source runs.
|
||||
// One eval, not three: `class CodemanApp` is a lexical binding and would not
|
||||
// survive into a second global eval, and settings-ui.js needs it at load time.
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => ${JSON.stringify(options.deviceType ?? 'desktop')},
|
||||
isHandheldDevice: () => ${JSON.stringify(options.deviceType ?? 'desktop')} !== 'desktop',
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};`,
|
||||
CONSTANTS,
|
||||
APP,
|
||||
SETTINGS_UI,
|
||||
'window.__CodemanApp = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// Object.create, not `new`: the constructor boots SSE, timers and the whole
|
||||
// terminal stack. Only the layout surface is under test here.
|
||||
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
|
||||
app.soloSessionId = options.solo ?? null;
|
||||
app.sessions = new Map();
|
||||
app.sessionOrder = [];
|
||||
app._elemCache = new Map();
|
||||
app._fullRenderSessionTabs = vi.fn();
|
||||
app.updateConnectionLines = vi.fn();
|
||||
|
||||
return { dom, win, app };
|
||||
}
|
||||
|
||||
const tabsEl = (win: Window) => win.document.getElementById('sessionTabs')!;
|
||||
const toggleBtn = (win: Window) => win.document.getElementById('sidebarToggleBtn')!;
|
||||
|
||||
describe('session list layout', () => {
|
||||
it('defaults to the header tab strip when nothing is stored', () => {
|
||||
const { win, app } = boot();
|
||||
expect(app.getSessionListLayout()).toBe('header');
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.documentElement.dataset.sessionList).toBe('header');
|
||||
expect(app.isSessionSidebarActive()).toBe(false);
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
|
||||
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('re-parents the tab list into the sidebar and flips the a11y state', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
expect(app.getSessionListLayout()).toBe('sidebar');
|
||||
|
||||
app.applySessionListLayout();
|
||||
|
||||
expect(win.document.documentElement.dataset.sessionList).toBe('sidebar');
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
expect(app.isSessionSidebarActive()).toBe(true);
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionSidebarList');
|
||||
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
|
||||
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(false);
|
||||
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('keeps the same DOM node across a layout flip (the $() element cache never invalidates)', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
const original = tabsEl(win);
|
||||
// Seed the cache the way any real render would.
|
||||
expect(app.$('sessionTabs')).toBe(original);
|
||||
|
||||
app.applySessionListLayout();
|
||||
expect(tabsEl(win)).toBe(original);
|
||||
expect(app.$('sessionTabs')).toBe(original);
|
||||
expect(original.parentElement?.id).toBe('sessionSidebarList');
|
||||
|
||||
// …and back again.
|
||||
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'header' }));
|
||||
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||
app.applySessionListLayout();
|
||||
expect(tabsEl(win)).toBe(original);
|
||||
expect(app.$('sessionTabs')).toBe(original);
|
||||
expect(original.parentElement?.id).toBe('sessionTabsHost');
|
||||
expect(original.getAttribute('aria-orientation')).toBe('horizontal');
|
||||
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('never selects the sidebar in a solo (detached) window', () => {
|
||||
// A solo window shows one session, so the list is noise — and #sessionTabs
|
||||
// parked in the display:none <aside> would measure 0/0 for tab overflow and
|
||||
// the inline rename input.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, solo: 'sess-1' });
|
||||
expect(app.getSessionListLayout()).toBe('header');
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.documentElement.dataset.sessionList).toBe('header');
|
||||
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
|
||||
});
|
||||
|
||||
it('round-trips the collapse state through its own storage key', () => {
|
||||
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
|
||||
// blob from the DOM controls, so a key without a control is wiped on Save.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
app.applySessionListLayout();
|
||||
const aside = win.document.getElementById('sessionSidebar')!;
|
||||
expect(aside.classList.contains('open')).toBe(true);
|
||||
|
||||
app.toggleSessionSidebar();
|
||||
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBe('1');
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('false');
|
||||
expect(toggleBtn(win).getAttribute('aria-label')).toBe('Expand session sidebar');
|
||||
expect(aside.classList.contains('open')).toBe(false);
|
||||
|
||||
app.toggleSessionSidebar();
|
||||
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBe('0');
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('true');
|
||||
expect(toggleBtn(win).getAttribute('aria-label')).toBe('Collapse session sidebar');
|
||||
expect(aside.classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
it('starts the handheld drawer CLOSED when the user has made no choice yet', () => {
|
||||
// Below 1024px the sidebar is an off-canvas overlay, so "expanded" on a cold
|
||||
// load would mean a drawer sitting on top of the terminal every time.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, deviceType: 'mobile' });
|
||||
app.applySessionListLayout();
|
||||
expect(app.isSessionSidebarActive()).toBe(true);
|
||||
expect(app.isSessionSidebarCollapsed()).toBe(true);
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
expect(win.document.getElementById('sessionSidebar')?.classList.contains('open')).toBe(false);
|
||||
|
||||
// An explicit choice still wins over the device default.
|
||||
win.localStorage.setItem('codeman-sidebar-collapsed', '0');
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
});
|
||||
|
||||
it('dismisses the handheld drawer on selection but never the docked desktop sidebar', () => {
|
||||
const handheld = boot({ stored: { sessionListLayout: 'sidebar' }, deviceType: 'mobile' });
|
||||
handheld.win.localStorage.setItem('codeman-sidebar-collapsed', '0');
|
||||
handheld.app.applySessionListLayout();
|
||||
handheld.app.closeSessionSidebarOnHandheld();
|
||||
expect(handheld.win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
|
||||
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
desktop.app.applySessionListLayout();
|
||||
desktop.app.closeSessionSidebarOnHandheld();
|
||||
expect(desktop.win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
});
|
||||
|
||||
it('does nothing on toggle while the header strip is active', () => {
|
||||
const { win, app } = boot();
|
||||
app.applySessionListLayout();
|
||||
app.toggleSessionSidebar();
|
||||
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBeNull();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
});
|
||||
|
||||
it('filters rows by rendered name and working directory without re-rendering', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
app.applySessionListLayout();
|
||||
tabsEl(win).innerHTML = `
|
||||
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
|
||||
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
|
||||
<div class="session-tab session-tab--web" data-webview-id="w" aria-label="Grafana web tab" title="http://x/g"></div>
|
||||
`;
|
||||
const before = tabsEl(win).querySelectorAll('.session-tab');
|
||||
|
||||
app.applySidebarFilter('api');
|
||||
expect(
|
||||
[...tabsEl(win).querySelectorAll('.session-tab')].map((t) => t.classList.contains('tab-filtered-out'))
|
||||
).toEqual([false, true, true]);
|
||||
// Pure class toggling — no node was replaced.
|
||||
expect(tabsEl(win).querySelectorAll('.session-tab')[0]).toBe(before[0]);
|
||||
|
||||
app.applySidebarFilter('/home');
|
||||
expect(tabsEl(win).querySelectorAll('.session-tab')[1].classList.contains('tab-filtered-out')).toBe(false);
|
||||
|
||||
app.applySidebarFilter('');
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the filter when the list moves back to the header strip', () => {
|
||||
// The filter <input> lives inside the sidebar, so a filter surviving a
|
||||
// layout flip would hide sessions from the header tab strip with no
|
||||
// reachable control to clear it — and every SSE-driven re-render re-hides
|
||||
// them, so only a reload recovers.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
app.applySessionListLayout();
|
||||
tabsEl(win).innerHTML = `
|
||||
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
|
||||
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
|
||||
`;
|
||||
const filterInput = win.document.getElementById('sessionSidebarFilter') as HTMLInputElement;
|
||||
filterInput.value = 'api';
|
||||
app.applySidebarFilter('api');
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
|
||||
|
||||
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'header' }));
|
||||
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||
app.applySessionListLayout();
|
||||
|
||||
expect(app._sidebarFilter).toBe('');
|
||||
expect(filterInput.value).toBe('');
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('suspends the filter while the rail is collapsed and restores it on expand', () => {
|
||||
// Collapsing hides .session-sidebar-filter, so a filter left applied would
|
||||
// show 3 of 25 status dots in the rail with no visible cause.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
app.applySessionListLayout();
|
||||
tabsEl(win).innerHTML = `
|
||||
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
|
||||
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
|
||||
`;
|
||||
app.applySidebarFilter('api');
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
|
||||
|
||||
app.toggleSessionSidebar();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
|
||||
expect(app._sidebarFilter).toBe('api');
|
||||
|
||||
app.toggleSessionSidebar();
|
||||
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats the 768-1023px band as an overlay, matching mobile.css', () => {
|
||||
// getDeviceType() calls 900px 'desktop', but mobile.css — which defines the
|
||||
// off-canvas overlay — is loaded with media="(max-width: 1023px)". Using the
|
||||
// device type here gave that band overlay CSS with docked-sidebar logic: the
|
||||
// drawer opened itself on load and neither selection nor Escape closed it.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
|
||||
expect(app._isSessionSidebarOverlay()).toBe(true);
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
|
||||
win.localStorage.setItem('codeman-sidebar-collapsed', '0');
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
|
||||
app.closeSessionSidebarOnHandheld();
|
||||
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
});
|
||||
|
||||
it('makes a closed overlay drawer inert, but never the docked desktop rail', () => {
|
||||
// translateX(-100%) alone leaves the filter box and ~4 tab stops per session
|
||||
// in the Tab order and in the accessibility tree.
|
||||
const overlay = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
|
||||
overlay.app.applySessionListLayout();
|
||||
const drawer = overlay.win.document.getElementById('sessionSidebar')!;
|
||||
expect(drawer.hasAttribute('inert')).toBe(true);
|
||||
expect(drawer.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
overlay.app.toggleSessionSidebar();
|
||||
expect(drawer.hasAttribute('inert')).toBe(false);
|
||||
expect(drawer.hasAttribute('aria-hidden')).toBe(false);
|
||||
|
||||
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
desktop.win.localStorage.setItem('codeman-sidebar-collapsed', '1');
|
||||
desktop.app.applySessionListLayout();
|
||||
const rail = desktop.win.document.getElementById('sessionSidebar')!;
|
||||
expect(desktop.win.document.documentElement.dataset.sidebar).toBe('collapsed');
|
||||
expect(rail.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('steals focus only for the modal drawer, never for the docked sidebar', () => {
|
||||
// The docked sidebar is chrome, not a dialog: pulling the caret out of the
|
||||
// terminal mid-prompt swallows everything typed after, because .session-tab
|
||||
// handles only arrows/Home/End/Enter/Space.
|
||||
const rows = `<div class="session-tab active" data-id="a" tabindex="0" aria-label="api"></div>`;
|
||||
|
||||
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
desktop.win.localStorage.setItem('codeman-sidebar-collapsed', '1');
|
||||
desktop.app.applySessionListLayout();
|
||||
tabsEl(desktop.win).innerHTML = rows;
|
||||
desktop.app.toggleSessionSidebar();
|
||||
expect(desktop.win.document.activeElement).toBe(desktop.win.document.body);
|
||||
|
||||
const drawer = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
|
||||
drawer.app.applySessionListLayout();
|
||||
tabsEl(drawer.win).innerHTML = rows;
|
||||
drawer.app.toggleSessionSidebar();
|
||||
expect((drawer.win.document.activeElement as HTMLElement).className).toContain('session-tab');
|
||||
});
|
||||
|
||||
it('shows the live session count in the sidebar header', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||
app.sessions = new Map([
|
||||
['a', {}],
|
||||
['b', {}],
|
||||
['c', {}],
|
||||
]);
|
||||
app.applySessionListLayout();
|
||||
expect(win.document.getElementById('sessionSidebarCount')?.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('forces tall rows and no wrapping in the sidebar, and leaves the strip rules alone', () => {
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar', tabTwoRows: false } });
|
||||
app.applySessionListLayout();
|
||||
const tabs = tabsEl(win);
|
||||
expect(tabs.classList.contains('tabs-show-folder')).toBe(true);
|
||||
expect(tabs.classList.contains('tabs-two-rows')).toBe(false);
|
||||
expect(tabs.classList.contains('tabs-auto-wrap')).toBe(false);
|
||||
expect(app._tallTabsEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session list layout wiring', () => {
|
||||
it('accepts sessionListLayout in the strict settings schema', () => {
|
||||
// SettingsUpdateSchema is .strict() and this key is NOT in the PUT strip-list,
|
||||
// so without the schema entry the server 400s the ENTIRE settings PUT and every
|
||||
// unrelated setting silently stops persisting.
|
||||
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar']).optional()");
|
||||
});
|
||||
|
||||
it('plumbs the setting through populate, collect, defaults and the display-key set', () => {
|
||||
expect(INDEX_HTML).toContain('id="appSettingsSessionListLayout"');
|
||||
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsSessionListLayout').value =");
|
||||
expect(SETTINGS_UI).toContain("sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,");
|
||||
expect(SETTINGS_UI).toContain("sessionListLayout: 'header',");
|
||||
expect(SETTINGS_UI).toContain("'sessionListLayout'");
|
||||
// Saving must re-apply the LAYOUT (which calls applyTabWrapSettings itself);
|
||||
// calling only applyTabWrapSettings would leave a layout change unapplied.
|
||||
expect(SETTINGS_UI).toContain('this.applySessionListLayout();');
|
||||
});
|
||||
|
||||
it('keeps the header host, the aside and the toggle out of solo windows', () => {
|
||||
expect(STYLES_CSS).toContain('body.solo-mode .session-tabs-host,');
|
||||
expect(STYLES_CSS).toContain('body.solo-mode .session-sidebar,');
|
||||
expect(STYLES_CSS).toContain('body.solo-mode .btn-sidebar-toggle,');
|
||||
});
|
||||
|
||||
it('puts the sidebar rules after the skin nesting block and adds no colour to .session-tab', () => {
|
||||
// Match the RULE (column 0 + opening brace), not the prose about it in the
|
||||
// sidebar block's own header comment.
|
||||
const skinRule = [...STYLES_CSS.matchAll(/^html:not\(\[data-skin="og"\]\) \{/gm)].pop();
|
||||
expect(skinRule).toBeDefined();
|
||||
const sidebarBlock = STYLES_CSS.indexOf('=== Collapsible session sidebar');
|
||||
expect(sidebarBlock).toBeGreaterThan(skinRule!.index!);
|
||||
});
|
||||
|
||||
it('makes the handheld sidebar an off-canvas overlay from the END of mobile.css', () => {
|
||||
// Placement is load-bearing: the compact `.session-tabs, .session-tabs.tabs-two-rows`
|
||||
// blocks earlier in the file pin max-height 36px/52px. Moving this block up
|
||||
// collapses the list into a sliver that looks like an empty list.
|
||||
const overlay = MOBILE_CSS.indexOf('SESSION SIDEBAR — off-canvas drawer');
|
||||
const compactStrip = [...MOBILE_CSS.matchAll(/^\s*\.session-tabs\.tabs-two-rows \{/gm)].pop();
|
||||
expect(compactStrip).toBeDefined();
|
||||
expect(overlay).toBeGreaterThan(compactStrip!.index!);
|
||||
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"] .session-sidebar.open');
|
||||
expect(MOBILE_CSS).toContain('transform: translateX(-100%)');
|
||||
});
|
||||
|
||||
it('translates the new sidebar copy for every language the translator supports', () => {
|
||||
for (const key of [
|
||||
'Collapse session sidebar',
|
||||
'Expand session sidebar',
|
||||
'Filter sessions',
|
||||
'Session List Layout',
|
||||
'Header tab strip',
|
||||
'Left sidebar',
|
||||
]) {
|
||||
expect(I18N).toContain(`'${key}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('pre-paints the layout before first paint and never in a solo window', () => {
|
||||
expect(INDEX_HTML).toContain('document.documentElement.dataset.sessionList');
|
||||
expect(INDEX_HTML).toContain('/^\\/session\\//.test(location.pathname)');
|
||||
});
|
||||
|
||||
it('pre-paints the collapse default off the SAME 1024px breakpoint as the JS', () => {
|
||||
// The handheld storage-key heuristic `m` is a different predicate; using it
|
||||
// here made boot contradict the pre-paint value between 768 and 1023px, so
|
||||
// the drawer animated itself open over the terminal on every load.
|
||||
expect(INDEX_HTML).toContain("dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')");
|
||||
});
|
||||
|
||||
it('keeps the sidebar toggle chord out of the PTY', () => {
|
||||
// preventDefault() in the document CAPTURE handler does not stop xterm, so
|
||||
// without this gate Alt+B would also write ESC b (readline backward-word)
|
||||
// into the live session on every toggle.
|
||||
expect(TERMINAL_UI).toContain('this.shouldToggleSessionSidebarFromShortcut?.(ev)');
|
||||
expect(APP).toContain('shouldToggleSessionSidebarFromShortcut(e) {');
|
||||
});
|
||||
|
||||
it('keeps the session drawer out of the prev/next swipe zone', () => {
|
||||
// The <aside> is a child of .main, which is where SwipeHandler binds, so a
|
||||
// swipe across the open drawer would otherwise fire nextSession().
|
||||
expect(MOBILE_HANDLERS).toContain("e.target?.closest?.('.session-sidebar')");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user