Backfill the two regression gaps flagged on master after the recent
hostname-title and tmux-flicker fixes shipped without server-side
assertions.
* test/server-index-title.test.ts (8 tests) — exercises WebServer's
index.html templating path: default os.hostname(), --title-hostname
override, HTML-escape against `<script>`-style breakout, ampersand
non-double-encoding, exact-once substitution, and byte-identical
template-tail invariance.
* test/tmux-window-size-query.test.ts (15 tests) — mocks
child_process.execFileSync and walks the helper through the
browser-resize-between-attaches happy path, query-then-die race,
zero/negative/empty/non-numeric output, plus argv-form/timeout
assertions to lock down the no-shell-interpolation guarantee.
* src/session.ts — extracts the inline 14-line tmux size query into
a named `queryTmuxWindowSize()` export so the test surface is a
pure function. Behavior unchanged.
* src/web/public/notification-manager.js — Browser Notification API
(layer 3) now uses `${this.originalTitle}: ${title}` so OS-level
desktop pop-ups carry the same `codeman:<host>` prefix that the
tab title and Web Push payloads already do, finishing the
hostname plumb-through started in #82.
* CLAUDE.md, README.md — document the dual-CLI env-prefix discipline
(CLAUDE_CODE_* vs OPENCODE_*), expand the xterm-zerolag-input
duplication gotcha to mention the published-package side-effect,
and note that the hostname prefix now applies uniformly to tab
title, tab-flash, and OS notifications.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Web Push gap left by #82: in-page Notification API and tab
title flash both showed `codeman:<host>` after that PR, but OS-level
notifications dispatched via the service worker — the surface that
matters most when the tab is closed and the user is reading their
system notification center across multiple Codeman instances —
still hardcoded the literal "Codeman" prefix.
Service workers run in an isolated context with no access to
document.title or any in-page state, so the hostname has to ride
along in the push payload itself.
Server (server.ts:sendPushNotifications): emit `hostTitle: this.windowTitle`
in the JSON payload alongside the existing `title` (event-specific text
like "Permission Required"). The two stay separate so the SW can compose
them — the server knows the host, the SW knows the OS context.
Service worker (sw.js): compose `${hostTitle}: ${title}` when both
present, mirroring the in-page Notification format from
notification-manager.js. Fall back to `title || hostTitle || 'Codeman'`
so older servers (which omit hostTitle) keep working — the field is
purely additive on the wire.
Tests (test/push-payload-host-title.test.ts): mock the `web-push` module
via vi.hoisted(), instantiate WebServer without binding a port, stub
the push store with one fake subscription, and verify the JSON payload
shipped to webpush.sendNotification carries the right hostTitle for
both --title-hostname overrides and the os.hostname() default. Also
mirrors the SW's title-composition logic in a small helper so any
future change to the format breaks the test instead of being caught
only by users running multiple Codeman instances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up fixes to the inline rename input introduced in #81:
1. IME composition guard. Pressing Enter to confirm a Chinese pinyin
candidate (or any IME composition) was committing the half-composed
text as the session name. Skip the keydown handler when isComposing
is true or when keyCode is the legacy 229 sentinel that older
Safari/Edge versions report on the Enter that triggers compositionend.
2. Ghost tab on mid-rename deletion. If a session was deleted via SSE
while its tab was being renamed, the render-skip flag suppressed
_renderSessionTabs() and the orphaned <input> stayed on screen until
blur — at which point the rename PUT 404'd against the dead session.
Replace the boolean _inlineRenameActive with a _activeRename
{sessionId, cancel} object so _cleanupSessionData can abort an
in-flight rename targeting the deleted session, and finishRename
skips the API call when the session is gone.
3. Stuck-flag risk. Move the settle-once guard into a closure-local
`settled` boolean so blur / Enter / Escape / external cancel all
converge to a single idempotent path. Register _activeRename only
after the input is fully wired so a throw earlier in setup can't
strand state.
Adds test/inline-rename.test.ts with 7 Playwright tests that drive
startInlineRename via page.evaluate() against a stubbed session and
synthetic .tab-name node — no real PTY/tmux needed, runs in ~1.3s.
Also fixes test/mobile/helpers/server.ts which imported the WebServer
via a path one directory short of the repo root, breaking the entire
mobile test suite under the main vitest config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.
Implementation:
- src/cli.ts: new --title-hostname <hostname> flag overrides the
detected hostname (handy for cosmetic naming or when os.hostname()
returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
constructor arg (defaults to os.hostname()), composes
windowTitle = codeman:${titleHostname}, and serves / and
/index.html by templating that title into the cached index.html
template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
this.originalTitle instead of the hardcoded "Codeman" literal, so
the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
expectations updated from === "Codeman" to a startsWith("codeman:")
predicate so they pass regardless of host.
The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.
Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the inline session-rename input is open, any incoming SSE event
that triggers renderSessionTabs() (a sibling session updating, a hook
firing, a status change) destroys the input element mid-keystroke and
the user loses what they were typing.
Add a _inlineRenameActive flag that:
- guards the two render paths (renderSessionTabs and
_fullRenderSessionTabs) so they bail out early while a rename is
in progress;
- is set true when the inline input mounts (session-ui.js);
- is cleared in finishRename, which then explicitly calls
renderSessionTabs to restore the normal tab structure.
Also add a re-entrance guard at the top of finishRename so the blur
event and the Enter keydown do not both fire it (was a latent
double-call).
Drive-by: replace tabName.innerHTML = "" with explicit child removal.
The preceding textContent = "" already clears the element; this avoids
an innerHTML write on a node that takes user-supplied content on the
next line.
Follow-up to the inline-rename feature cherry-picked from #60.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Default layout was a single nowrap row with path + date + size, which on
narrow screens truncated both the first prompt and the directory suffix
(where project names actually live). The /Users/ home shorthand was also
never applied on macOS.
Changes:
- Title now uses 2-line clamp so more of the first prompt is visible.
- Subtitle resolves workingDir against known cases: exact match shows
"#caseName", subpath shows "#caseName/sub", otherwise falls back to
basename. Case labels are styled distinctly.
- Normalize both /home/<user>/ and /Users/<user>/ prefixes to "~/".
- Each history item gets a "..." toggle that expands an in-place detail
panel with the full prompt, full path, timestamp, size, and short
session id. Collapses back on second click; clicking the card body
still triggers resume.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
* fix: restore clear message separation + proper table layout in response viewer
* fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback
Session constructor seeded _claudeSessionId with Codeman's session.id as a
placeholder, and the message-driven update was gated on !_claudeSessionId —
meaning Claude CLI's actual session UUID was never adopted. This broke
/api/sessions/:id/last-response JSONL lookups, silently falling through to
the terminal-buffer path whose ANSI regex missed \x1b[>c / \x1b[>q queries.
- session.ts: update _claudeSessionId whenever a message's session_id differs
from current (covers placeholder and stale-resume cases)
- app.js: extract _cleanTerminalBuffer with proper CSI regex (param bytes
0x30-0x3F now covers > ? < =) plus a chrome filter for status bar,
progress bar, spinner, shell prompt, and hint lines
* fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint
* feat: add per-block wrap toggle on ASCII-diagram code blocks
* fix: wrap by default, pin toggle button outside scroll container
* fix: narrow diagram detection to box-drawing + block elements only
* feat: show last-response viewer eye icon on desktop too
The response viewer button was mobile-only via a display:none default with a
mobile.css override. Flip the default to inline-flex and drop the override so
the eye icon appears in the header on every form factor — desktop users get
the same quick "Last Response" pane as mobile.
* fix(response-viewer): restore HTML sanitizer + fix undefined `src` in _renderMarkdown
- `_renderMarkdown` referenced an undefined `src` (should be `text`),
causing a ReferenceError on every markdown render. The try/catch
swallowed it, so the new table-wrap and ASCII-diagram features
never actually ran — output silently fell through to plain-text.
app.js is excluded from ESLint, so this wasn't caught at lint time.
- `_sanitizeHtml` was removed when refactoring the response viewer,
leaving `marked.parse()` output going straight into `innerHTML`
without sanitization (XSS regression vs. master). Restored the
helper and re-applied it before any post-processing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved conflict in src/web/public/session-ui.js by keeping this
PR's buildEnvOverrides() helper — it already covers both
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and
CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline
block is fully replaced.
Also fixed test/session-manager.test.ts MockSession to add a
getEnvOverridesForPersist() stub — without it,
SessionManager.updateSessionState's new call breaks 19 tests with
"TypeError: session.getEnvOverridesForPersist is not a function".
Verified: typecheck, lint, format:check, build, and
test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts
all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
UI dropdown → POST /api/sessions { envOverrides }
→ new Session({ envOverrides })
→ this._envOverrides
→ tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`
Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.
Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
Add XHigh option to Thinking Effort dropdown (between High and Max),
and add a Max quick button to the mobile keyboard accessory bar that
sends /effort max as a slash command.
POST /api/clipboard with { text } broadcasts to all connected browsers
via SSE. Browser attempts navigator.clipboard.writeText, falling back
to a modal with manual copy button if blocked.
Enables remote clipboard workflows: CLI tools can push text to the
user's browser clipboard across the network.
- Active tab: bright green border with color-matched glow per session color
- Tab number badges (1-9) showing Alt+N shortcut hints
- Badges update on tab reorder and re-render
On Android tablets, pressing Shift+A produces "AA" because the input
event listener re-sends characters that xterm already processed via
its keydown handler. Track keydown timestamps and skip input events
that fire within 50ms of a handled keydown.
Only affects touch devices (listener gated by isTouchDevice()).
Add marked.js (39KB) for rich text display in the response viewer.
Renders headings, code blocks, lists, tables, blockquotes, and
inline formatting with dark theme styling.
Falls back to escaped plain text if marked.js fails to load.
Claude Code's Ink framework uses alternate screen buffer + VPA cursor
positioning, resulting in near-zero xterm.js scrollback on mobile.
Instead of fighting terminal scrollback, this adds a native scrollable
overlay that reads structured responses from Claude's JSONL transcripts.
- New API: GET /api/sessions/:id/last-response reads transcript JSONL
- ?context=full returns full conversation thread (user + assistant)
- Fallback to terminal buffer with ANSI stripping if no transcript
- Response viewer panel: bottom sheet with native iOS/Android scroll
- "More" button loads full conversation context as threaded view
- Eye icon in header bar (mobile only), no toolbar space impact
Add app icons, update manifest with icon entries, and add app-shell
caching to service worker for offline/instant startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show Codeman logo on mobile as compact home button (was hidden)
- Add "Show More" button for history sessions (initial 4, expand all)
- Deduplicate by projectKey instead of workingDir (lossy decode fix)
- Fix project key decoding: handle '_' encoded as '-' with look-ahead
- Pre-validate resumeSessionId before passing to Claude CLI
- Apply content validation to all session files regardless of size
Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before
returning them from GET /api/cases. Prevents XSS via maliciously-named
directories reaching frontend inline onclick handlers where escapeHtml
is insufficient (HTML-decoded back to quotes before JS execution).
Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a "Manage" tab to the create-case modal with up/down reorder
buttons and delete for each case. Linked cases are unlinked (folder
preserved); CASES_DIR cases are permanently deleted.
Backend:
- DELETE /api/cases/:name — unlink or delete
- PUT /api/cases/order — persist ordering to settings.json
- GET /api/cases now respects saved caseOrder
Frontend:
- Third "Manage" tab in createCaseModal with case list
- Delete button in mobile case picker bottom sheet
- SSE events: case:deleted, case:order-changed
Previously, restoreMuxSessions() only created Session objects without
attaching PTY processes. Sessions stayed at pid=null until the client
manually selected them, causing terminals to appear "closed" after deploy.
Now the server calls startInteractive() for each recovered session during
startup, so all sessions resume capturing output immediately. The frontend
auto-attach condition is also relaxed from (pid===null && status==='idle')
to (pid===null && !_ended) as a safety net for edge cases.
Three fixes from the WIP flicker branch that were lost during master merges:
1. Move viewport clear (\x1b[3J\x1b[H\x1b[2J) inside the dimension-change
guard so it only fires when cols/rows actually change. Previously every
resize event cleared the screen even at identical dimensions, causing
visible flicker with no subsequent Ink redraw to repaint.
2. Sync _lastResizeDims in sendResize() so restoreTerminalSize() doesn't
trigger a redundant viewport clear on the next throttledResize tick.
3. Add didScroll tracking to touch events — tap (no scroll) now refocuses
xterm's hidden textarea, fixing mobile keyboard input routing after
tapping the terminal area.
Mobile users cannot press Shift+Tab on virtual keyboards. Add a ⇧Tab
button that sends the escape sequence (\x1b[Z) to the PTY, enabling
mode switching on mobile devices.
Also fix accessory bar overflow on narrow screens by making it
horizontally scrollable with hidden scrollbar.
Allow users to leave the default model unset, so sessions use whatever
the Claude CLI defaults to rather than forcing a specific model.
- Add empty-value "Default (CLI default)" option to the model dropdown
- Treat empty string as undefined when passing model to Session
- Apply consistently across session creation, quick-start, and Ralph