Compare commits

..
Author SHA1 Message Date
Codeman maintainer c8f3981b0c review fixes: 1.19.0 is the real version boundary, and the preamble stamp matches its bytes again
endpoints.md named 1.18.x as the version where workspace hooks became a
setting, but 1.18.x servers do NOT have this behavior — an agent driving
one would falsely conclude its workspace has hooks. The feature ships in
1.19.0. And preamble.sh changed content this PR without bumping its
CODEMAN_PREAMBLE stamp, so a cache stamped 1.18.3 would pass the
staleness check while holding old bytes; stamp bumped to 1.19.0 in
preamble.sh and the SKILL.md heredoc together (byte-identity pin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:16:10 +02:00
Codeman maintainer 1c94995290 docs(skill): hooks are a setting now, not who created the directory
The workspace-hooks install makes the skill's central hooks rule wrong in the
cautious direction. Six places told a worker that a linked case or a raw
workingDir has no `stop`/`blocked` and that send-and-wait cannot be trusted
there, so an agent would hand-roll output-marker synchronization in exactly the
workspaces where `wait:true` now works.

Rewritten against the setting rather than directory provenance:

- verbs.md §5.1: the where-to-spawn table, the rule paragraph (now naming
  `workspaceHooksEnabled`, default ON, the add-only merge, and the boot sweep of
  recovered sessions), and the silent-failure warning. The three cases that stay
  hook-less regardless are called out: remote SSH sessions, docker cases that
  opted out, and a workspace Codeman cannot write to.
- verbs.md §5.3: the send-and-wait precondition is "the workspace has the hooks
  block", not "a case Codeman created".
- endpoints.md: the Signals-by-mode table is now keyed on the setting, with rows
  for OFF, for remote/docker-opt-out, and for a session from an older server.
  The old create-path grep list becomes a "before 1.18.x" note.
- SKILL.md §2 + the cost list, recipes.md Flow-1 contrast, messaging.md step 1.

"Check, do not assume" is kept and promoted to the load-bearing habit, because
the setting is not visible from the call and a session created by an older server
that has not restarted still has nothing.

The `spawn_worker` hooks grep STAYS: it guards the setting being off, remote
sessions, and older servers. Only its diagnostic changes, since "pick an unused
name" is no longer the fix. That text lives in both the §0 heredoc and
`preamble.sh`, which `test/agent-skill.test.ts` pins byte-identical, so both are
patched with the same bytes.

Docs only, no behavior change. 23 skill tests green, full test:ci 5109 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 07:30:10 +02:00
Codeman maintainer f485085174 feat: workspaceHooksEnabled setting as the opt-out for workspace hook installs
Installing hooks into any workspace a Claude session runs in is the right
default, but it takes a decision away from a user who deliberately removed
them: nothing on disk distinguishes "removed on purpose" from "never had any",
so they would come back on the next session create.

Adds the synced workspaceHooksEnabled setting (App Settings -> Agents & CLIs ->
Claude), default ON. OFF restores the older behavior exactly: a Codeman hooks
block that is already present is still refreshed when stale (COD-91), but one
is never added.

Every create path routes through one applyWorkspaceHooks() helper so the gate
cannot apply to some paths only, and the boot-time recovery sweep honours it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 07:05:10 +02:00
Codeman maintainer 98fa8c00d1 fix: install Codeman hooks into every claude workspace, not just cases Codeman created
A session in a linked case (or any pre-existing repo) ran with no hooks block
at all: writeHooksConfig only fires when Codeman CREATES the case directory,
and refreshStaleCodemanHooks deliberately never adds one. Every hook-driven
surface was therefore dead in exactly the place most sessions run: no tab
alert or phone-overview NEEDS YOU row when a dialog blocks the pane, no
Approvals Inbox item, no push, no definitive stop/idle_prompt for respawn,
and no stop/blocked for the agent wait endpoints.

Both session-create paths and restoreMuxSessions() now call
ensureCodemanHooks(), an add-only merge that keeps a user's own handlers and
leaves a malformed settings file untouched. Claude Code re-reads
settings.local.json, so a session already running in the workspace starts
firing hooks without a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 04:46:01 +02:00
33 changed files with 476 additions and 1768 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"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.
+1 -1
View File
@@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in session-routes.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
**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`.
-1
View File
@@ -683,7 +683,6 @@ 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) |
-6
View File
@@ -287,12 +287,6 @@ 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).
-179
View File
@@ -1,179 +0,0 @@
/**
* 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);
}
);
+16 -15
View File
@@ -47,7 +47,7 @@ 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; }
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.18.3$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -138,14 +138,14 @@ spawn_worker() {
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
# quick-start RESOLVES the name before creating: a linked case or an existing dir
# wins over a fresh scratch case, so "created => hooks" is only true after this one
# local grep (the same marker the server itself checks for). No marker means sendwait
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
# rather than run the job there.
# The server installs hooks into every claude workspace now, so this grep normally
# passes; it stays because the install is gated on a setting the operator can turn
# off, remote sessions never get hooks, and a session created by an older server
# still has none. No marker means sendwait would false-resolve on flapping idle,
# possibly inside the user's REAL repo: refuse rather than run the job there.
cp=$(jq -r '.data.casePath // empty' <<<"$q")
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
@@ -233,10 +233,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.18.3
CODEMAN_PREAMBLE=1.19.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -287,7 +287,7 @@ 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; }
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
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
@@ -343,7 +343,8 @@ Four things this block leans on, each one link away, no detour needed to run it:
`~/codeman-cases/<name>`, not your repo. A name that already means something (a
linked case, a pre-existing directory) is refused by `spawn_worker` rather than
silently reused. Spawning where the work actually is (a linked case, a git worktree)
is a different call with **no hooks**, and the costliest mistake in this skill: §5.1.
is a different call, and picking the wrong one is the costliest mistake in this
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
@@ -358,9 +359,9 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| I want to | Call | Detail |
|-----------|------|--------|
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case: full signals there. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`, and expect **no hooks**. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is only trustworthy in a **case Codeman created** (claude mode + hooks present). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
+8 -8
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -60,14 +60,14 @@ spawn_worker() {
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
# quick-start RESOLVES the name before creating: a linked case or an existing dir
# wins over a fresh scratch case, so "created => hooks" is only true after this one
# local grep (the same marker the server itself checks for). No marker means sendwait
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
# rather than run the job there.
# The server installs hooks into every claude workspace now, so this grep normally
# passes; it stays because the install is gated on a setting the operator can turn
# off, remote sessions never get hooks, and a session created by an older server
# still has none. No marker means sendwait would false-resolve on flapping idle,
# possibly inside the user's REAL repo: refuse rather than run the job there.
cp=$(jq -r '.data.casePath // empty' <<<"$q")
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
@@ -155,4 +155,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.18.3
CODEMAN_PREAMBLE=1.19.0
+26 -32
View File
@@ -251,10 +251,12 @@ that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
**It means** that session has no Codeman hooks, so `stop` can never fire and the wait
silently degraded to `idle`, which flaps mid-turn. Nothing rejected your request:
`wait:true` (and even an explicit `until=stop`) is accepted because the 400 is about
session **mode**, and the mode really is `claude`. Hooks are written only when Codeman
**creates** the directory; a linked case or a raw `workingDir` gets none (an existing
case that Codeman created earlier keeps the block it was given), see the table under
[Signals by mode](#signals-by-mode). Measured: on a
session **mode**, and the mode really is `claude`. Hooks are installed into every
claude workspace at session create (synced `workspaceHooksEnabled`, default ON) and
swept across recovered sessions at boot, so a linked case or a raw `workingDir` gets
them too; with the setting off, on a remote session, or on a session from an older
server, they are absent, see the table under
[Signals by mode](#signals-by-mode). Measured before that changed: on a
linked case whose `.claude/settings.local.json` carries env/model/permissions/statusLine
and no `hooks` block, a `wait?until=stop,exit` parked for twelve consecutive 60 s rounds
never resolved although the worker finished its turn.
@@ -360,10 +362,10 @@ loop.
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
to match a case the user linked in lands in that **real repo**, not a fresh scratch
directory. Pick distinctive scratch names, and use a linked name deliberately when you
do want a worker in an existing checkout. ⚠️ It also decides whether you get hooks:
Codeman writes them only when it **creates** the directory, so a linked case or a raw
path gives you a worker with no `stop` signal, while a scratch case Codeman created
earlier keeps working signals ([Signals by mode](#signals-by-mode)).
do want a worker in an existing checkout. It no longer decides whether you get hooks:
every claude create path installs them, so a linked case and a raw path both get a
`stop` signal unless the operator turned `workspaceHooksEnabled` off
([Signals by mode](#signals-by-mode)).
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
@@ -614,35 +616,27 @@ Three bounded long-polls. Shared semantics:
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
precondition is that the session's working directory has a Codeman hooks block**, and
whether it does depends on who created the directory:
precondition is that the session's working directory has a Codeman hooks block**, which
is now installed by default rather than depending on who created the directory:
| The worker's directory | Hooks | `stop` / `blocked` | Synchronize with |
|------------------------|-------|--------------------|------------------|
| Codeman created it (`quick-start` with a NEW `caseName`, `POST /api/cases`, clone, docker quickcreate) | written at create | fire | send-and-wait on `stop` |
| Codeman never created it (a linked case pointing at your own checkout, a raw `workingDir`) | none written | never fire | `wait-output` markers only |
| any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | fire | send-and-wait on `stop` |
| the same, with the setting OFF and no block already on disk | none added | never fire | `wait-output` markers only |
| a remote SSH session, a docker case that opted out, a workspace Codeman cannot write | none | never fire | `wait-output` markers only |
| a session created by a pre-1.19.0 server and never restarted since | whatever it had | only if present | check, then choose |
⚠️ **Docker cases are the one exception.** For a docker case, quick-start writes hooks
whenever `.claude/settings.local.json` is *missing* (`session-routes.ts:2836-2845`:
absent means write, present means refresh), regardless of who created that host
directory. There the discriminator really is "does the settings file exist". No
downstream advice changes, since docker quickcreate is already on the create side.
The install is an add-only merge, so a user's own hook entries survive and a malformed
settings file is left untouched. Sessions recovered at server boot get the same sweep,
which is what heals sessions created before this behavior existed. When in doubt, test
it rather than reason about it: grep for `/api/hook-event` in
`<casePath>/.claude/settings.local.json`.
⚠️ For every non-docker case the discriminator is **who created the directory, not
whether it exists now**. A
scratch case Codeman created last week still has its hooks block on disk, so
`quick-start` against that existing name gets working `stop` signals. Only a directory
Codeman never created lacks them. When in doubt, test it rather than reason about it:
grep for `/api/hook-event` in `<casePath>/.claude/settings.local.json`.
`writeHooksConfig()` runs only on the create paths (`case-routes.ts:341`, `:520`,
`:869`, `ralph-routes.ts:318`, `session-routes.ts:2799` inside
`if (!existsSync(resolvedCasePath))`, `:2841` for docker). Quick-start against a
directory that already exists takes the else-if branch and calls
`refreshStaleCodemanHooks()`, which returns immediately when there is no
`settings.local.json` and again when the hooks it finds are not ours
(`hooks-config.ts:706-731`); it never *adds* a hooks block. `POST /api/cases/link` is
not on that list at all: it only records a name-to-path entry. See
Before 1.19.0, `writeHooksConfig()` ran only on the create paths and `quick-start`
against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a
block, so a linked case or a raw `workingDir` had no hooks at all. `POST
/api/cases/link` still only records a name-to-path entry; what changed is that the
session-create path installs hooks regardless of how the directory got there. See
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
+8 -6
View File
@@ -196,12 +196,14 @@ idle:
The contract an orchestrator follows for any fleet of two or more messaging workers.
Every topology in the next section is this protocol plus a wiring diagram.
1. **Spawn with a name, and with hooks.** Use `quick-start` with `sessionName` (the
`--name` gate above), and let it **CREATE** the case. ⚠️ Linking does NOT install
hooks (`POST /api/cases/link` writes only the name-to-path entry), and neither does a
bare `POST /api/sessions`; a worker in a directory Codeman did not create has no
`stop`/`blocked` signals at all and every synchronization below degrades to output
markers. The discriminator is who created the directory, not whether it exists now.
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
`--name` gate above). Session create installs the hooks block into the workspace
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
an older server may have none, and without them every synchronization below degrades
to output markers. Grep `<casePath>/.claude/settings.local.json` for
`/api/hook-event` at spawn rather than inferring it from how the directory got there.
2. **Readiness before addressing.** Flow 1's ladder per worker, then the availability
probe. A worker that fails the probe is an HTTP worker for the rest of the run; that
is a routing decision, not an error.
+3 -3
View File
@@ -492,9 +492,9 @@ What breaks if you use send-and-wait anyway: `wait:true` is accepted (the 400 is
*mode*, not about hooks, and these are claude-mode sessions), so the call falls back to
the default set's `idle`, which is a heuristic that flaps mid-turn. You get a "finished"
answer for a turn still running, and `last-response` then hands you the *previous*
turn's text. The contrast is the lesson: a worker in a case Codeman created (Flow 1) has
the hooks, so `stop` there is definitive and free. In a worktree you pay one marker per
worker instead.
turn's text. The contrast is the lesson: a worker whose workspace carries the hooks
block (Flow 1, and by default any other workspace too) has a `stop` that is definitive
and free. Where the block is absent you pay one marker per worker instead.
```bash
declare -A TOK
+38 -23
View File
@@ -30,28 +30,40 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
| Where the work is | Call | Hooks, and therefore signals |
|-------------------|------|------------------------------|
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **no hooks**, unless that repo already carries a Codeman hooks block from some earlier path. Check before relying on `stop` |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | **no hooks**: no `stop`, no `blocked`, synchronize with markers ([§5.5](#55-markers-for-hook-less-workers)) |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
Read `.data.casePath` back from the `quick-start` response and check it is where you
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
the linked-cases registry **first**, so a name that collides with something the user
linked in lands in that real repo rather than a scratch dir.
**The rule is who created the directory.** Codeman writes hooks only where it created
the workspace itself: `quick-start` on a NEW case name, `POST /api/cases`, the repo
clone, the docker quick-create. Those hooks persist, so a scratch case created last
week still has them today. A directory that already existed when Codeman first pointed
at it never gets them: `POST /api/cases/link` writes only the name-to-path entry in
`linked-cases.json`, and quick-start into an existing path runs
`refreshStaleCodemanHooks()`, which by design returns immediately when there is no
Codeman hooks block to refresh. Source-verified by exhaustive call-site grep, and
measured: a worker in a linked case never resolved a parked `wait?until=stop,exit`
across twelve consecutive 60 s rounds, although it had finished its turn.
**The rule is a setting, not who created the directory.** Every claude create path
(`POST /api/sessions`, `POST /api/quick-start`, and quick-start's docker branch) now
installs the hooks block into the workspace, and the server sweeps the workspaces of
sessions it recovers at boot. So a linked case, a cloned repo and a hand-made git
worktree all get `stop`/`blocked`, not just a scratch case Codeman scaffolded. The
install is an **add-only merge**: a user's own hook entries and every other settings
key survive, and a malformed settings file is left alone.
**Check, do not assume.** Read `<casePath>/.claude/settings.local.json` with your own
file tools and look for `/api/hook-event`. Present means `stop`/`blocked` will fire;
absent means they never will.
The gate is the synced **`workspaceHooksEnabled`** setting, **default ON** (an absent
key counts as ON). Turned OFF, the old behavior returns exactly: an existing Codeman
block is still refreshed when stale, but one is never added, and the boot sweep is
skipped. Three cases stay hook-less regardless: **remote SSH sessions** (their
`workingDir` is a path on another host), **docker cases that opted out**, and any
workspace Codeman cannot write to.
Until this landed, hooks existed only where Codeman created the directory, and the
gap was invisible: a worker in a linked case never resolved a parked
`wait?until=stop,exit` across twelve consecutive 60 s rounds, although it had finished
its turn. If you are driving an older server, assume that older rule.
**Check, do not assume.** This is now the load-bearing habit, because you cannot tell
from the call which way the setting is set, and an old session created before the fix
on a server that has not restarted still has nothing. Read
`<casePath>/.claude/settings.local.json` with your own file tools and look for
`/api/hook-event`. Present means `stop`/`blocked` will fire; absent means they never
will, whatever kind of workspace it is.
⚠️ **The hook-less failure is silent, and it is the worst one in this skill.**
`"wait":true` is still **accepted** on a hook-less claude session: the 400 you may be
@@ -59,9 +71,10 @@ expecting is about session *mode*, not about hooks. With no `stop` to resolve on
default signal set falls back to the heuristic `idle`, which flaps mid-turn, so
send-and-wait returns "finished" while the worker is still working, and the
`last-response` you read next hands you the **previous** turn's text. No error is
raised anywhere. In any workspace Codeman did not create, use markers
([§5.5](#55-markers-for-hook-less-workers)) and treat send-and-wait's answer as
unreliable.
raised anywhere. Hooks are installed by default now, so this is rarer than it was, but
the failure is unchanged when it happens: in any workspace whose settings file has no
`/api/hook-event`, use markers ([§5.5](#55-markers-for-hook-less-workers)) and treat
send-and-wait's answer as unreliable.
Spawning at a raw path:
@@ -238,11 +251,13 @@ fi
### 5.3 Send a task and wait
⚠️ **Precondition: this is the call to prefer only for a claude worker in a workspace
Codeman created**, because it is trustworthy only when the `stop` hook exists. On a
linked case or a raw path it is accepted, resolves on flapping `idle`, and reports a
turn as finished while it is still running, with no error anywhere. Check hooks first
([§5.1](#51-where-to-spawn)); where they are absent, use markers
⚠️ **Precondition: a claude worker whose workspace has the hooks block**, because
this is trustworthy only when the `stop` hook exists. Every claude create path installs
it by default now, so that is the normal case, but where it is absent (the setting off,
a remote session, an older server) the call is still accepted, resolves on flapping
`idle`, and reports a turn as finished while it is still running, with no error
anywhere. Check hooks first ([§5.1](#51-where-to-spawn)); where they are absent, use
markers
([§5.5](#55-markers-for-hook-less-workers)).
It registers the waiter *before* typing,
+20 -14
View File
@@ -646,22 +646,28 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
}
/**
* Ensures an explicitly managed case has the current Codeman hooks.
* Ensures a workspace Codeman is about to run Claude in has the current Codeman hooks.
*
* Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid
* user-owned settings file. It is therefore reserved for case quick-starts,
* where the user has explicitly asked Codeman to manage that workspace. A
* malformed existing file is left untouched rather than replaced.
* Unlike `refreshStaleCodemanHooks`, this may ADD Codeman handlers to a settings
* file that has none (a linked case, a cloned repo, any directory Codeman did not
* scaffold). It merges rather than replaces, so a user's own hook entries survive,
* and a malformed existing file is left untouched rather than replaced.
*
* ⚠️ It has NO production call site: PR #233 landed it with the hook scripts and never
* wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as
* a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig`
* REPLACES a malformed settings file and rewrites unconditionally, and
* `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The
* one place it fits is quick-start's existing-case branch in session-routes.ts, and
* moving that branch onto this function is a POLICY change (hooks would come back for a
* user who deleted them from their case, and linked cases would start getting a hooks
* block they have never had), so that call is left to the owner rather than made here.
* ⚠️ That "may add" is a deliberate POLICY, adopted 2026-08-15 after the symptom it
* causes was reported: hooks were only ever written when Codeman CREATED a case
* directory, so every session in a linked case ran with no hooks at all and each
* hook-driven surface was silently dead there — an AskUserQuestion dialog blocking
* the pane while the tab and the phone overview both read a calm `idle`, no
* Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn, and
* no `stop`/`blocked` for the agent wait endpoints. The cost of the policy is the
* other direction: a user who DELETES Codeman's hooks from a workspace gets them
* back on the next session create there, because nothing on disk distinguishes
* "removed on purpose" from "never had any".
*
* Called from both session-create paths (`POST /api/sessions`, `POST /api/quick-start`)
* for claude mode, and from `restoreMuxSessions()` so sessions that predate this heal
* on the next server start. Claude Code re-reads the file, so a session ALREADY running
* in the workspace picks the hooks up without a restart (verified live, 2026-08-15).
*/
export async function ensureCodemanHooks(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
+2
View File
@@ -19,6 +19,8 @@ export interface ConfigPort {
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
getAgentSkillEnabled(): Promise<boolean>;
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
getWorkspaceHooksEnabled(): Promise<boolean>;
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
getClaudeVoiceEnabled(): Promise<boolean>;
getDefaultClaudeMdPath(): Promise<string | undefined>;
+12 -381
View File
@@ -428,16 +428,6 @@ 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',
@@ -883,9 +873,7 @@ class CodemanApp {
this.restorePlanUsageChip();
this.applySkin();
this.applyLocalization();
// 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.applyTabWrapSettings();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.();
@@ -952,7 +940,7 @@ class CodemanApp {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
this.applySessionListLayout();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
@@ -1074,7 +1062,6 @@ class CodemanApp {
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
// Use capture to handle before terminal
@@ -1096,14 +1083,6 @@ 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
@@ -3585,262 +3564,6 @@ 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
// ═══════════════════════════════════════════════════════════════
@@ -3889,16 +3612,6 @@ 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();
@@ -3923,45 +3636,6 @@ 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);
@@ -4197,9 +3871,6 @@ 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,
@@ -4209,13 +3880,6 @@ 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();
@@ -4264,15 +3928,6 @@ 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();
@@ -4283,9 +3938,6 @@ 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');
@@ -4373,7 +4025,6 @@ 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');
@@ -4396,11 +4047,6 @@ 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)
@@ -4411,13 +4057,9 @@ class CodemanApp {
}
this._tabKeydownHandler = (e) => {
// 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;
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
const tabs = [...container.querySelectorAll('.session-tab')];
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
@@ -4433,11 +4075,9 @@ 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':
@@ -4569,19 +4209,14 @@ class CodemanApp {
e.dataTransfer.dropEffect = 'move';
// 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.
// Determine drop position based on mouse position
const rect = tab.getBoundingClientRect();
const insertBefore = this.isSessionSidebarActive()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
const midpoint = rect.left + rect.width / 2;
const isLeftHalf = e.clientX < midpoint;
// Update visual indicator
tab.classList.toggle('drag-over-left', insertBefore);
tab.classList.toggle('drag-over-right', !insertBefore);
tab.classList.toggle('drag-over-left', isLeftHalf);
tab.classList.toggle('drag-over-right', !isLeftHalf);
});
tab.addEventListener('dragleave', () => {
@@ -4597,11 +4232,10 @@ class CodemanApp {
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
// Determine insertion position (same axis rule as the dragover handler)
// Determine insertion position
const rect = tab.getBoundingClientRect();
const insertBefore = this.isSessionSidebarActive()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
const midpoint = rect.left + rect.width / 2;
const insertBefore = e.clientX < midpoint;
// Reorder sessionOrder array
const fromIndex = this.sessionOrder.indexOf(draggedId);
@@ -5116,9 +4750,6 @@ 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) {
-4
View File
@@ -85,10 +85,6 @@ 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;
},
-9
View File
@@ -48,10 +48,6 @@
'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)': '重新停靠到主界面(关闭窗口)',
@@ -231,11 +227,6 @@
'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: '面板',
+12 -82
View File
@@ -51,18 +51,6 @@
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)}
@@ -70,22 +58,8 @@
.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>
@@ -96,10 +70,7 @@
<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-terminal"></div>
<div class="skeleton-toolbar"></div>
</div>
<!-- Skip link for keyboard users -->
@@ -113,27 +84,10 @@
<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. 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>
<!-- Session Tabs -->
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
</div>
<!-- Detached single-session window title (shown only in solo mode) -->
@@ -355,26 +309,6 @@
<!-- 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
@@ -753,7 +687,6 @@
<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">
@@ -761,8 +694,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> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</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>
@@ -1840,16 +1773,6 @@
<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>
@@ -2061,6 +1984,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
<div class="set-row-text">
<span class="set-row-label">Workspace Hooks</span>
<span class="set-row-desc">Install Codeman's hooks in each Claude workspace, so tab alerts, the Approvals Inbox and idle detection also work in linked cases and existing repos. Off leaves your repos untouched.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="remote auto reconnect ssh">
<div class="set-row-text">
<span class="set-row-label">Remote auto-reconnect</span>
-16
View File
@@ -168,11 +168,6 @@ 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?.();
@@ -657,7 +652,6 @@ const SwipeHandler = {
_touchStartHandler: null,
_touchEndHandler: null,
_element: null,
_ignoreGesture: false,
/** Initialize swipe handling */
init() {
@@ -686,12 +680,6 @@ 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;
@@ -699,10 +687,6 @@ 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;
-128
View File
@@ -497,20 +497,6 @@ 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
@@ -3618,117 +3604,3 @@ 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;
}
}
+1 -12
View File
@@ -130,14 +130,6 @@ 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;
@@ -211,10 +203,7 @@ Object.assign(CodemanApp.prototype, {
const strip = document.getElementById('sessionTabs');
if (!strip) return;
this._lineageScrollHandler = () => {
// 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();
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
},
+1 -4
View File
@@ -1814,10 +1814,7 @@ Object.assign(CodemanApp.prototype, {
input.value = parsed ? parsed.suffix : (session.name || '');
input.placeholder = parsed ? 'Add description...' : currentName;
input.className = 'tab-rename-input';
// 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;`;
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;';
tabName.appendChild(input);
input.focus();
+10 -20
View File
@@ -387,8 +387,6 @@ 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');
@@ -411,6 +409,9 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
// Default ON: an absent key is a user who has never seen this setting, and OFF
// for them means no tab alerts in any workspace Codeman did not scaffold.
document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
@@ -2009,7 +2010,6 @@ 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,
@@ -2020,6 +2020,7 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
@@ -2154,9 +2155,7 @@ Object.assign(CodemanApp.prototype, {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
// Re-parents #sessionTabs between header host and sidebar if the layout
// changed, then calls applyTabWrapSettings() itself — do not call both.
this.applySessionListLayout();
this.applyTabWrapSettings();
this.applyLineageLineSettings?.();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
@@ -2394,7 +2393,6 @@ 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
@@ -2639,27 +2637,19 @@ 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 = !sidebar && deviceType === 'desktop'
const twoRows = deviceType === 'desktop'
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
: false;
const showFolder = sidebar || twoRows;
const prevTallTabs = this._tallTabsEnabled;
this._tallTabsEnabled = showFolder;
this._tallTabsEnabled = twoRows;
const tabsEl = document.getElementById('sessionTabs');
if (tabsEl) {
tabsEl.classList.toggle('tabs-two-rows', twoRows);
tabsEl.classList.toggle('tabs-show-folder', showFolder);
tabsEl.classList.toggle('tabs-show-folder', twoRows);
}
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
if (prevTallTabs !== undefined && prevTallTabs !== showFolder) {
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
this._fullRenderSessionTabs();
}
},
@@ -2864,7 +2854,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'language',
'terminalWheelLocalScrollback',
-247
View File
@@ -49,9 +49,6 @@
--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);
@@ -2152,9 +2149,6 @@ html:not(.tabs-show-detach) .session-tab:not(.detached):not(.tab-show-detach) .t
/* ===== 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,
@@ -16603,244 +16597,3 @@ 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;
}
}
+12 -24
View File
@@ -401,12 +401,15 @@ Object.assign(CodemanApp.prototype, {
continue;
}
// 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);
// 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}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
@@ -746,11 +749,9 @@ Object.assign(CodemanApp.prototype, {
win.style.top = `${finalY}px`;
win.style.bottom = 'auto';
} else if (flyFromTab) {
// 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`;
const tabRect = parentTab.getBoundingClientRect();
win.style.left = `${tabRect.left}px`;
win.style.top = `${tabRect.bottom}px`;
win.style.transform = 'scale(0.3)';
win.style.opacity = '0';
win.classList.add('spawning');
@@ -1225,19 +1226,6 @@ 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)
-11
View File
@@ -326,17 +326,6 @@ 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') {
+19 -15
View File
@@ -197,13 +197,10 @@ 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) {
// _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));
const r = parentTab.getBoundingClientRect();
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
win.style.left = `${left}px`;
win.style.top = `${anchor.spawnTop + (anchor.vertical ? 14 : 0)}px`;
win.style.top = `${r.bottom + 14}px`;
} else {
const n = this.ultracodeWindows.size;
win.style.left = `${24 + n * 26}px`;
@@ -787,12 +784,16 @@ Object.assign(CodemanApp.prototype, {
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
}
// 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).
// PHASE 2: writes (curve from tab bottom-center to window top-center).
for (const { runId, parentSessionId, winRect } of winList) {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) continue;
const path = this._tabConnectorPath(this._tabAnchor(tabRect), winRect);
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 line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection');
@@ -817,13 +818,12 @@ 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.
// 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;
let px, py;
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
if (runWin && runWin.element) {
const pr = runWin.element.getBoundingClientRect();
anchor = { x: pr.left + pr.width / 2, y: pr.bottom, vertical: true };
px = pr.left + pr.width / 2;
py = pr.bottom;
} else {
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
@@ -835,9 +835,13 @@ Object.assign(CodemanApp.prototype, {
}
const tabRect = rects.get(tabKey);
if (!tabRect) continue;
anchor = this._tabAnchor(tabRect);
px = tabRect.left + tabRect.width / 2;
py = tabRect.bottom;
}
const path = this._tabConnectorPath(anchor, winRect);
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 line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
-3
View File
@@ -156,9 +156,6 @@ 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. */
+45 -10
View File
@@ -84,6 +84,7 @@ import {
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
ensureCodemanHooks,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -625,6 +626,32 @@ async function injectAgentSkill(casePath: string): Promise<void> {
}
}
/**
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
* shared by every create path, so the setting cannot apply to some of them only.
*
* ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging
* so a user's own hook entries and every other settings key survive. Hooks were
* previously written only when Codeman CREATED the case DIRECTORY, so a linked case
* or any pre-existing repo — where most sessions actually run — had none, and every
* hook-driven surface was silently dead there: no tab alert or phone-overview row
* when a dialog blocks the pane, no Approvals Inbox item, no push, no definitive
* `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for the wait endpoints.
* Measured 2026-08-15 in a linked case: an AskUserQuestion dialog on screen with the
* tab reporting a calm `idle`. Claude Code re-reads the file, so a session already
* running in that workspace starts firing hooks without a restart (verified live).
*
* OFF: the older, narrower behavior. A Codeman block that is already there is still
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
* went unconditional), but one is never added, so Codeman leaves the repo alone.
*
* Best-effort either way: a refusal or a thrown error must never fail the create.
*/
async function applyWorkspaceHooks(ctx: ConfigPort, workspace: string): Promise<void> {
const install = await ctx.getWorkspaceHooksEnabled();
await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {});
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -765,11 +792,10 @@ export function registerSessionRoutes(
await applyStatusLineConfig(workingDir, true);
}
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
// Hooks for the workspace this session runs in (install vs refresh-only is the
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks).
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleCodemanHooks(workingDir).catch(() => {});
await applyWorkspaceHooks(ctx, workingDir);
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
// .claude rationale as the statusLine above: a create must never remove the
// skill from under other live sessions in the repo. Marker-guarded, so a
@@ -2899,11 +2925,17 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (!remote && !docker && mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
// EXISTING case directory (a linked case, a cloned repo, anything Codeman did
// not scaffold): install-or-refresh per the setting (see applyWorkspaceHooks).
// Other modes keep the narrower COD-91 self-heal unconditionally: only claude
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (mode === 'claude') {
await applyWorkspaceHooks(ctx, resolvedCasePath);
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
}
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
@@ -2936,7 +2968,10 @@ export function registerSessionRoutes(
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath);
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
// A settings file with no hooks in it is the same dead-surface case as a
// linked case. This branch is already gated on `docker.hooksEnabled`, and
// applyWorkspaceHooks adds the user-level gate on top.
await applyWorkspaceHooks(ctx, resolvedCasePath);
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
+11 -2
View File
@@ -906,6 +906,17 @@ export const SettingsUpdateSchema = z
* add-only at create; a marker keeps user-authored copies untouched.
*/
agentSkillEnabled: z.boolean().optional(),
/**
* Install Codeman's hooks block into the workspace of every Claude session,
* not only into cases Codeman scaffolded itself. SYNCED, default ON: without
* it a linked case or an existing repo runs with no hooks at all, and each
* hook-driven surface is silently dead there (tab alert, Approvals Inbox,
* push, respawn's definitive idle signals, the wait endpoints' stop/blocked).
* Turning it OFF restores the older, narrower behavior — a Codeman hooks
* block that is already present is still refreshed when stale, but one is
* never added — for a user who wants Codeman to leave their repos alone.
*/
workspaceHooksEnabled: z.boolean().optional(),
/**
* Let browser dictation transcribe through this machine's Claude Code login,
* the same speech-to-text service the CLI's own `/voice` mode uses
@@ -945,8 +956,6 @@ 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(),
+50
View File
@@ -76,6 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { ensureCodemanHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -636,6 +637,7 @@ export class WebServer extends EventEmitter {
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
getWorkspaceHooksEnabled: this.getWorkspaceHooksEnabled.bind(this),
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
getLightState: this.getLightState.bind(this),
@@ -1710,6 +1712,16 @@ export class WebServer extends EventEmitter {
return settings.agentSkillEnabled === true;
}
// Whether a Claude session installs Codeman's hooks block into its workspace
// (synced `workspaceHooksEnabled` setting). Default ON — an absent key means a
// user who has never seen this setting, and OFF for them would mean no tab
// alerts, no Approvals Inbox and no respawn idle signals in every workspace
// Codeman did not scaffold itself.
private async getWorkspaceHooksEnabled(): Promise<boolean> {
const settings = await this.readSettings();
return settings.workspaceHooksEnabled !== false;
}
// Whether browser dictation may use this machine's Claude Code credentials
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
// OFF by default because turning it on spends the operator's Claude subscription
@@ -2819,6 +2831,13 @@ export class WebServer extends EventEmitter {
}
}
// Sessions recovered from a previous run predate the create-path hook
// install, and these are long-lived: by the time a server restart comes
// round a session may be days old and has been running hook-blind the
// whole time. Claude Code re-reads settings.local.json, so writing the
// block now arms the RUNNING CLI, no session restart needed.
await this.ensureHooksForRecoveredWorkspaces();
// Start stats collection for mux sessions
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
}
@@ -2845,6 +2864,37 @@ export class WebServer extends EventEmitter {
}
}
/**
* Install Codeman's hooks into the workspaces of the sessions just recovered.
*
* Deduped by workspace, because sessions in one repo share a single
* `.claude/settings.local.json` and the write is otherwise repeated per tab.
* Claude mode only (nothing else reads `.claude` hooks), never for remote
* sessions (their `workingDir` is a path on ANOTHER host, so writing it here
* would scaffold a stray directory locally), and never for a docker case that
* opted out of hooks.
*
* Failures are swallowed per workspace: `ensureCodemanHooks` already refuses
* unsafe targets with a warning, and a workspace we cannot write to must not
* stop the rest of recovery.
*
* Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a
* user can keep Codeman out of their repos, and a boot-time sweep is the last
* place that should ignore it.
*/
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
if (!(await this.getWorkspaceHooksEnabled())) return;
const workspaces = new Set<string>();
for (const session of this.sessions.values()) {
if (session.mode !== 'claude' || session.remote) continue;
if (session.docker && !session.docker.hooksEnabled) continue;
if (session.workingDir) workspaces.add(session.workingDir);
}
for (const workspace of workspaces) {
await ensureCodemanHooks(workspace).catch(() => {});
}
}
/**
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
* the dropped remote session and report the outcome back to the watcher so it
+4
View File
@@ -19,6 +19,7 @@ export function createMockRouteContext(options?: {
sessionId?: string;
agentSkillEnabled?: boolean;
claudeVoiceEnabled?: boolean;
workspaceHooksEnabled?: boolean;
}) {
const sessionId = options?.sessionId ?? 'test-session-1';
const session = createMockSession(sessionId);
@@ -96,6 +97,9 @@ export function createMockRouteContext(options?: {
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
// Writes land in the test's temp working dir, never in a real repo.
getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => {
@@ -0,0 +1,177 @@
/**
* @fileoverview Hooks are installed into the workspace a claude session starts in.
*
* Regression cover for the 2026-08-15 report: a session in a LINKED case (the user's
* own repo, where most sessions live) ran with no hooks block at all, because
* `writeHooksConfig` only fires when Codeman CREATES a case directory and the old
* self-heal call deliberately never ADDED one. The visible symptom was an
* AskUserQuestion dialog blocking the pane while the tab and the phone overview both
* showed a calm `idle` — no hook event, so no pending-hook state, so no alert.
*
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig } from '../../src/hooks-config.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
permissions?: unknown;
model?: unknown;
}
/**
* A faithful PRE-SECRET Codeman hooks block (what a case created before COD-54
* contains): it targets /api/hook-event, so it is recognisably ours, but carries
* no X-Codeman-Hook-Secret header and no -k. Used to prove the self-heal still
* runs with the setting OFF.
*/
function staleCodemanHooks() {
return {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command:
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
timeout: 5,
},
],
},
],
};
}
describe('POST /api/sessions workspace hooks', () => {
let app: FastifyInstance;
let workingDir: string;
const settingsPath = () => join(workingDir, '.claude', 'settings.local.json');
const readSettings = async (): Promise<HooksFile> => JSON.parse(await readFile(settingsPath(), 'utf-8'));
const createSession = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/sessions', payload });
/** Rebuild the app with the `workspaceHooksEnabled` gate in a given position. */
const useApp = async (workspaceHooksEnabled: boolean) => {
await app?.close();
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled }));
installRouteErrorHandler(app);
await app.ready();
};
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext());
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(workingDir, { recursive: true, force: true });
});
it('installs hooks in a workspace that has none (the linked-case bug)', async () => {
const res = await createSession({ name: 'hooks-fresh', mode: 'claude', workingDir });
expect(res.statusCode).toBe(200);
const settings = await readSettings();
const matchers = (settings.hooks?.Notification ?? []).map((entry) => entry.matcher);
// permission_prompt is the one an AskUserQuestion dialog raises; the
// elicitation pair is what CLOSES the resulting Approvals Inbox item.
expect(matchers).toEqual(
expect.arrayContaining([
'idle_prompt',
'permission_prompt',
'elicitation_dialog',
'elicitation_complete',
'elicitation_response',
])
);
expect(settings.hooks?.Stop?.length).toBeGreaterThan(0);
const serialized = JSON.stringify(settings.hooks);
// The two shapes that have historically shipped dead hooks: no secret header
// (401 once the gate went unconditional) and no -k (exit 60 on HTTPS installs).
expect(serialized).toContain('X-Codeman-Hook-Secret');
expect(serialized).toContain('curl -sk -X POST');
});
it('merges into a user-owned settings file without disturbing it', async () => {
await mkdir(join(workingDir, '.claude'), { recursive: true });
const userHook = { matcher: 'Write', hooks: [{ type: 'command', command: './my-formatter.sh' }] };
await writeFile(
settingsPath(),
JSON.stringify({ model: 'opus[1m]', permissions: { allow: ['Read'] }, hooks: { PostToolUse: [userHook] } })
);
expect((await createSession({ name: 'hooks-merge', mode: 'claude', workingDir })).statusCode).toBe(200);
const settings = await readSettings();
expect(settings.model).toBe('opus[1m]');
expect(settings.permissions).toEqual({ allow: ['Read'] });
expect(JSON.stringify(settings.hooks)).toContain('./my-formatter.sh');
expect((settings.hooks?.Notification ?? []).length).toBeGreaterThan(0);
});
it('leaves a non-claude session alone (only claude reads .claude hooks)', async () => {
expect((await createSession({ name: 'hooks-shell', mode: 'shell', workingDir })).statusCode).toBe(200);
expect(existsSync(settingsPath())).toBe(false);
});
it('leaves a malformed settings file untouched rather than replacing it', async () => {
await mkdir(join(workingDir, '.claude'), { recursive: true });
await writeFile(settingsPath(), '{ not json');
expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json');
});
it('adds nothing when workspaceHooksEnabled is OFF', async () => {
await useApp(false);
expect((await createSession({ name: 'hooks-off', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(existsSync(settingsPath())).toBe(false);
});
it('still heals a stale Codeman block when workspaceHooksEnabled is OFF', async () => {
// The setting turns off ADDING hooks, not the COD-91 self-heal: a pre-secret
// block 401s against the now-unconditional hook-secret gate, so a workspace that
// already opted in must not be left with hooks that silently fail.
await useApp(false);
await mkdir(join(workingDir, '.claude'), { recursive: true });
await writeFile(settingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
expect((await createSession({ name: 'hooks-off-stale', mode: 'claude', workingDir })).statusCode).toBe(200);
const settings = await readSettings();
expect(settings.model).toBe('opus');
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
});
it('writes the hooks the generator produces, so the two cannot drift', async () => {
expect((await createSession({ name: 'hooks-parity', mode: 'claude', workingDir })).statusCode).toBe(200);
const written = (await readSettings()).hooks ?? {};
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
});
});
-503
View File
@@ -1,503 +0,0 @@
/**
* @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')");
});
});