On every skin but og the skin block outranks the phone :active backgrounds of the toolbar buttons, so Run showed no press at all and the others at most a faint tint, eased in over their 0.2s transition, so a quick tap showed almost nothing. A press now scales and brightens (darkens on the light skins) over 80ms, two properties nothing on these buttons sets at a higher specificity, and honours prefers-reduced-motion.
A browser test presses every phone bar control on all seven skins and pins the computed transform and filter, so a later rule that takes the press away again shows up in the browser suite.
Review follow-up for the L-shaped header strip:
- _syncTabStripCorner() keeps the plain wrap when the plain strip is
already past --tab-strip-max-height, checked before .tabs-corner goes
on: the float scrolled with the tabs while the toolbar stayed put, so a
scrolled strip put tabs under #headerRight, and a re-render of a
scrolled two-rows strip sized the float from the scrolled frame. The
"- scrollTop" in contentTop is gone (always 0 now).
- A strip just under its max-height that passes it once the float is in
falls back to the plain wrap as well.
- test/tab-strip-corner.browser.test.ts (real Chromium, real header and
styles.css), listed in BROWSER_TEST_GLOBS.
- src/web/CLAUDE.md Tab layouts entry describes the L-shaped classic strip.
Conflict in config/test-suites.ts resolved by keeping both sides: #689's
header-divider and #692's header-host-sensors browser tests are both
listed in BROWSER_TEST_GLOBS.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part 1 of #643 (local sessions only; remote follows as its own PR). An
opt-in, per-device header button opens a panel with the active session's
edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main
agent and its subagents with its diff, plus images a Bash call rendered or
the agent sent. Remote, Docker and non-claude sessions answer `unsupported`.
- Reads only on request: `?sync=1` when the panel opens; while it is open on
that session it re-reads after an edit-capable `transcript:tool_end` or a
`transcript:complete` from the existing transcript watcher. No hook, timer,
second watcher or new SSE event; a wiring guard pins it.
- Main transcript: the path the transcript watcher follows (hook-reported),
else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or
the server's config dir. A config dir set outside Codeman is found with
the next hook; until then the panel says so.
- Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at
500 entries and 4 MiB of diff text per session, deleted with the session,
blocked in isSensitivePath, never fed to /api/search. Every route goes
through findSessionOrFail with req.
- Images only through existing file surfaces: file-raw inside the
workspace, the attachment route with notify:false outside it.
- textContent rendering, bounded byte-offset reads, marker-class header
button, hidden on phones (locked in the mobile policy test).
Docs: Working-With-Files, Settings-Reference, architecture invariants, API
reference, CLAUDE.md key pattern.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The remote-history PATH bootstrap that rode along in this PR was left out
at merge (it belongs in its own PR that routes every remote tmux call
through one wrapper), so 242bbfcd's title overstates what lands here.
Conflicts in CLAUDE.md and docs/architecture-invariants.md resolved by
keeping this PR's rewrite of the wheel/touch paragraphs and re-adding
#664's touch-forwarding sentence to both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fix for #674: the first travel past the tap slop decided the
axis for the whole contact, so a scroll that opened on a thumb arc
(+9 px across, +5 px down, then straight down) locked as 'swipe'. Every
later touchmove returned before the scroll code and SwipeHandler gave
the swipe up at its 50 px drift limit, so the gesture did nothing. Past
SwipeHandler.maxVerticalDrift the drag is now handed to 'y' (and the
published axis with it), so it scrolls; a clean sideways swipe, a
vertical scroll and a long-press drag are unchanged.
The source pin in test/session-swipe-gesture.test.ts moves with the
code and also pins the drift value, and a new browser test drives the
real touch listeners: the thumb-arc drag ends as a scroll (it fails
without the fallback, axis stays 'swipe') and a clean sideways swipe
stays a swipe with nothing scrolled. The architecture note and
src/web/CLAUDE.md now say a swipe drag is consumed only up to that
limit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict resolved in terminal-tile.js's @dependency header: #601's
SHELL_LOAD_BASE_QUERY/SHELL_LOAD_QUERY and #527's pageKeysForGesture and
app.tabAlerts are all listed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #527 (Ark0N, 2026-10-11):
1. A slow touch drag paged a full screen per line. touchmove calls the
fallback once per whole cell, so a slow drag's calls were further apart
than the 150 ms gesture gap and each pre-paid a page (12 lines at 170 ms
sent 12 PageUps). The touch paths now tag every call, touchmove and
momentum frames alike, with a per-contact touchId (incremented on
touchstart), and pageKeysForGesture never splits a contact by time; a
new contact is a new gesture.
2. A pre-paid debt now survives up to PAGE_KEY_DEBT_MS (1 s) of quiet when
the next event goes the same way, so slow small wheel ticks (40 px every
250 ms, a Firefox 1-line notch) pay it back instead of each pre-paying.
A reversal inside the gap (or inside one contact) drops the debt and
accumulates without pre-paying, so a +/-3 px wobble no longer pages
back and forth. A reversal after the gap still pages at once. Main pane
and tiles share the helper, so both get it.
3. A wheel consumed by the dialog check now logs page-keys-dialog-open.
4. The Dashboard wiki names newer Codex beside OpenCode; the invariants
paragraph describes the new gesture boundaries.
Tests: unit regressions through the real _maybePageCliTranscript, helper
cases, tile/main parity for ticks and wobble, and a Chromium test that
drives the real initTerminal touch listeners (12 PageUps before, 1 after).
A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".
Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.
The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
After a session's first load per page, a tab switch fetched a 1 MiB tail of
the server's byte stream. For a fullscreen claude pane (alternate screen,
tmux `history_size` 0) that tail is old repaints of one frame, and an idle
tab parsed it twice (the cached copy, then the fresh one).
Every terminal response now records `paneHistoryLines` per session, and
`selectSession` takes `full=1` whenever the last report was 0. For such a
pane that capture is the visible frame, a few KB, which is also what a page
load already shows. A response without the field forgets the entry, so
unknown never counts as empty; a pane that starts keeping history goes back
to the tail on the next switch. Shells and the refresh path are unchanged.
Measured with real prod payloads through the real client in chromium:
median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A "Search sessions" box at the top of the vertical tab rail narrows the
list to the tabs whose name matches (case-insensitive substring; a web
tab matches by its title). It searches every group, collapsed ones
included: while a search runs the projection draws every group open and
the header will not toggle, and the stored per-device collapse state is
left alone. Groups with no match hide, an empty result says "No sessions
match", and the flat rail (no groups) filters the same way. Escape or
the clear button empties it; leaving the vertical orientation resets it.
It is a view filter only: rows get the same tab-filtered-out class the
sidebar filter box uses, through one shared _applyTabListFilter() over
the pure CodemanTabSearch matcher in constants.js. Grouping, order,
Alt+N badges and drag are untouched, nothing is persisted or sent to
the server. The sidebar keeps matching name plus working directory.
In the grouped tree, hidden rows and the headers of emptied groups leave
the roving walk and posinset/setsize, and the tab stop moves onto a
visible item. zh-CN strings added.
marked emits no heading ids and, with <base href="/">, a bare #section href points at the dashboard root, so [Install](#installation) in the File Viewer did nothing. The shared click delegate now resolves fragment links against the rendered document: GitHub-style slugs in data-md-anchor (never ids, so a heading cannot capture an app element), case-insensitive and percent-decoded, repeats numbered -1/-2, # = top, explicit ids supported, an unmatched fragment ignored instead of navigating.
Tests: slug/assign/find unit tests (CI gate) and a real-browser test clicking links in a File Viewer document, which fails without the change.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
#555 made the primary pane page opencode's transcript with PageUp/PageDown
from the wheel, because opencode draws in place on the alternate screen and
leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile,
the split's Pane B) left every wheel to xterm, so in an opencode tile the
wheel scrolled nothing, or only stale rows.
The tile now runs the primary pane's own gates aimed at itself (its terminal,
its session, never the active one): xterm's tracking mode, the Claude
forwarding gate, then the hollow-buffer test. A wheel that passes them is
consumed in the capture phase and turned into PageUp/PageDown through the
shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin
of the primary pane's queue) and sent ephemeral on the tile's own socket.
Every other wheel stays with xterm as before, the shell history pull
included. The file names no CLI: the mode rules stay in terminal-ui.js, and
terminal-tile.js joins the frontend no-id-branching guard.
A plain port of the primary's baseY === 0 test would almost never fire in a
grid. A tile's first capture is taken at the PTY's previous size (usually the
taller primary pane's) and written into a shorter xterm, and its own
row-shrinking fits (zoom-out, divider drags, tile count changes) push more
rows above the screen. The tile counts those rows as its own overflow: all of
them after a load whose capture held a single screen (the server's
captureRows), plus whatever a local fit or a PTY geometry report pushes up,
reset by a clear and clamped to baseY. The paging gate gets baseY minus that
count. Output that scrolls real lines still counts as history, so the tile
stops paging there.
#555's other half, stripping opencode's mouse DECSETs so a drag selects text,
is server-side and already reached tile sockets. It also left the tile's
xterm unable to encode opencode's clicks, so the tile now installs the
primary pane's desktop click report (bubble phase, gated on the session's
cliMouseTracking, the tile's own link hover and selection). Both listeners,
the flush timer and the page-key state are torn down in destroy().
Still out of scope, as the fileoverview now says: touch paging (tiles have
no touch path) and SGR wheel forwarding to Claude's fullscreen renderer
(tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the
wheel to xterm.
Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm
harness (session targeting, every no-page case, accumulation, the cap,
coalescing, byte parity with the primary pane, the overflow discount through
a load, a fit, a geometry report and a clear, the click report and destroy);
the discount cases fail with it removed. The fake xterm gains opt-in row
emulation. test/terminal-tile-scroll.browser.test.ts checks the same model
against a real xterm with trusted wheel events (browser suite, not the gate).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The menu opens upward (bottom: 100%) with no max-height or overflow, so with the stock CLIs plus custom endpoint entries it was taller than the room above the toolbar and its top was off-screen and unreachable, worst on phones. Cap it to the space between the header and the toolbar (dvh, vh fallback), scroll inside it with overscroll containment, and stop its overflow:auto children (history, saved URLs) being squashed.
Tests: a CI-gate source guard and a real mobile-browser test (touch swipe reaches the last entry), which fails without the CSS.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
# Conflicts:
# config/test-suites.ts
# docs/api-reference.md
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.
Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
reorderGroup, moveRef) applied to the rail at once, mirroring the server
model: a moved session takes the sessions that still follow it, and a
hand-moved child is marked placement 'manual'. normalizeLayout now keeps
placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
made in the same turn share a write; edits made while one is in flight go
out on the version it returns. A 409 replays the operations onto the
layout the server returned and retries (bounded); an operation that no
longer applies is dropped and reported. A 400 re-reads first; any other
failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
that changes nothing.
Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
Ungrouped and Move to new group in the vertical rail. Before the first
group exists it offers only "Move to new group", which is how a flat rail
becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
a hover glyph (a non-focusable aria-hidden span, so the treeitem still
holds no interactive child): Rename, New group, Move group up/down,
Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
back to its row or header), a pointer outside, Tab, focus leaving it, a
resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
the current editor releases the render guard. Enter or blur commits,
Escape cancels, IME composition keys are left to the IME, and the label
becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
row or into a group, a header drag reorders groups. Escape cancels; the
click that ends a drag neither selects nor toggles. The flat rail and the
header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
once it settles; a read otherwise rebases unsaved edits. On pagehide,
unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.
Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.
Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is
read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its
lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files,
commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window.
Which repositories: the enclosing one when there is one; otherwise every repository up to two
levels below the working directory (capped, skipping dot-folders and node_modules, never
following symlinks), each in a collapsible section, with the indicator summing them. A repository
that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is
ignored. Git-supplied text is only ever written with textContent.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
capabilities.newline replaces choosing the Shift+Enter bytes in the send-key
route. Key tester shows the keydown/keypress/keyup a browser reports.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xterm runs the custom key handler for keypress too and drops Ctrl/Alt
keypresses but not Shift-only ones, so the stray \r submitted the prompt
after the newline. Swallow every event type for Shift/Ctrl+Enter and send
only on keydown, in the primary pane and Pane B. Adds a static guard and a
real xterm + Chromium browser test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.
Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
role=tablist and label when grouping ends. The header strip, sidebar and
flat rail keep role=tablist / role=tab exactly as before (the flat rail's
markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
row a collapsed group keeps showing are level-1 items; a collapsed header
owns nothing, and the "Ungrouped" heading is a visual divider hidden from
assistive tech. aria-level, aria-setsize and aria-posinset are set on every
item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
leave the tab order, so Shift+F10 / ContextMenu open a row's actions
(session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
it, Left collapses a header or climbs from a row to its header, Enter/Space
select a row or toggle a header. With the activity sort on, the walk follows
painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
click through the same onclick and still the lineage proxy anchor.
Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
upstream; that is now pinned in real Chromium for the strip, the flat rail
and the grouped rail, together with every control (gear, detach, close,
overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
hovered (no reflow-on-hover moving the gear out from under a click).
New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
- Observe keydown in the capture phase on terminal.element, an ancestor of
the helper textarea, so the controller sees it before xterm's own capture
listener finalizes the composition and emits the commit through onData.
Finalize on exactly the keys CompositionHelper.keydown does (every keyCode
except 20/229/16/17/18), ignoring isComposing and key as xterm does.
- Bound awaitingCommit with the same 2 s fallback as the committed phase, so
a composition whose commit never reaches onData cannot turn the next
unrelated keystroke or paste into an IME commit.
- pagehide resets the controller instead of destroying it, so a back-forward
cache restore keeps the preview working.
- Give the preview an opaque background from the terminal theme.
- Route an IME commit through the ordinary printable/paste local echo branch
and complete the commit afterwards; drop the send-on-throw fallback.
- Pin the event order with an xterm stand-in registered in the capture phase
ahead of the controller, and against real xterm in a browser test.
- CLAUDE.md: note the IME commit routing and the z-index 6 preview layer.