pageKeysForGesture kept `prepaid` set after the travel had fully repaid a
pre-paid page, so the one-second debt lifetime still applied: on a 38-row
pane, 19 lines of -1 at 32 ms left { pending: 0, prepaid: true } and a -1
300 ms later sent nothing. `prepaid` now clears as soon as the pending
travel reaches or crosses zero, so the leftover is plain travel and the
150 ms gap applies again. Touch contacts are unaffected (never split by
time). The mixed parity step's reversal grows from 20 to 25 rows because
the -5 rows left after repayment are now plain travel and cancel against it.
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).
Conflicts resolved by keeping both sides:
- terminal-ui.js: master's wheelDeltaWholeLines/sgrWheelReports next to
this branch's pageKeyRows/pageKeysForGesture.
- terminal-tile.js: master's tile SGR wheel forwarding and Shift+wheel
local scroll (_shiftScrollPending) kept; tile paging stays on the
shared pageKeysForGesture state (_pageKeyGesture) with its per-tile
dialog check.
- architecture-invariants.md: this branch's hollow-paging paragraph,
with master's tile SGR forwarding and Shift+wheel sentences replacing
the old "do not forward SGR wheel" note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent skill section now shows a real run end to end: one short
prompt typed into Claude Code, the tile grid powering on with the CRT
entrance, then a DeepSeek Harness worker on a local qwen model and a
Claude Code worker powering on as new tiles, with lineage lines back to
the lead. 1280 wide, linking a 3600x2025 still.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki said a session closed in the meantime frees its place for the
ranking. A session popped out into its own window does the same (it is
never tiled while its window owns its PTY size, and since the previous fix
popping it out with the grid open keeps the count, too), so the sentence now
names both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki, decision 11 in the tile-grid plan, its Persistence section, the
layout-memory test header and two tile-grid.js comments said the Tiles
button and a page reload bring the grid back however it was closed. Only
the Tiles button does: a grid closed before the reload stays stored as
open: false, the page shows the single view, and the toggle puts the grid
back. The behaviour was right; the wording now says that.
A test pins it through the real close path: arrange the grid, close it with
the toggle, start a fresh page, and the restore leaves the single view
while the toggle still brings back the cells, count, divider sizes, focus
and zoom. ('a stored closed grid leaves the single view' in
tile-grid-restore.test.ts covers the same from a raw stored value through
handleInit.)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A page reload puts a stored open grid back inside handleInit and fills a
cell freed since (its session deleted while the grid was closed) from the
ranking. Pending approvals, the source of the needs-input group, reach the
page only through seedApprovals, an async GET that lands after the restore,
so for that one fill a session waiting on a permission dialog or an unseen
finished turn ranks with the quiet ones (working sessions still rank first).
Holding the fill back until the seed lands would open fewer tiles, which
can be another shape, and then reshape the grid and move the user's tiles a
moment after the reload, so a late seed never re-forms a restored grid. The
plan, architecture-invariants#tile-grid and the wiki now say so, and a test
pins it: the seed arrives after the restore, the ranking then puts the
needs-input session first, and the restored cells and the stored layout
stay as they came back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session popped out while the grid was open (its pop-out button, or
another dashboard tab announcing the pop-out over the window channel) left
the grid through _markDetached, which removed its tile as if the user had
closed it: the stored count dropped to five and its cell stayed a hole the
ranking never filled, so after the window re-docked Tiles off and on showed
five tiles and an empty cell. Popping out with the grid closed, or a reload
reconcile, already kept the count and refilled the cell.
_markDetached now removes the tile as `gone` (it left by itself), the same
as a deleted session or a refused socket, so the count stays and the next
activation fills the cell from the ranking.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile-grid plan records owner decision 11 (the layout is kept per
browser and comes back exactly; a fresh or filling grid ranks working,
then needing input, then most recent) and marks where it supersedes
decision 10's "the count wins over a remembered grid's size". The plan,
architecture-invariants#tile-grid and the CLAUDE.md Tile grid paragraph
now describe the stored format (`count`, freed cells, still v: 1), every
close path keeping it, and the open order; the wiki tells users what the
Tiles button brings back and how a new grid is chosen.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision: searching for a tab and dragging it into a group is a useful
flow, so the drag-off guard is reverted and the docs and changeset say drag
works during a search.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
183efacc turned drag off while the rail search narrows the list, as the
owner asked, by refusing the flat manual rail's dragstart. That also
refused a drag that never reorders anything: with the tile grid open, a
found tab dragged onto a tile or an empty cell set no draggedTabId, so
_acceptTabDrops() (tile-grid.js) saw nothing and the drop was lost. On
the PR head that drop replaced or swapped the tile. The owner's reason
covers the reorder only: a reorder drop is saved for every device,
relative to rows the search hides, while the tile grid is per-device and
lands beside nothing hidden.
dragstart is unconditional again. The rows refuse the reorder instead:
their dragover returns before preventDefault while _tabRailSearchActive()
(the browser shows no-drop and fires no drop there), and their drop
returns before touching sessionOrder, for anything above the row that
might let a drop through. `draggable` stays on, so a cleared search
reorders with the same rows. The grouped rail's pointer drag keeps its
refusal in _onTabLayoutPointerDown(): it has no drop target besides the
rail.
Test: during a search that leaves both rows showing, dragstart is not
cancelled and sets draggedTabId, dragover on another row is not
accepted, a drop there leaves sessionOrder alone and saves nothing, the
real _acceptTabDrops() binder accepts the drag on a tile and hands it
the id, and after clearing the same rows reorder. It fails on the
previous commit, without the dragover guard, and without the drop guard.
CLAUDE.md, the invariants, the Dashboard wiki row and the release
changeset now say reordering by drag is off and a tile drop still works.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A floating subagent or ultracode window whose parent session's row the
rail search hid drew its connector from the viewport's top-left corner.
The hidden row is display:none, and getBoundingClientRect() still
answers it with an all-zero DOMRect, which is truthy, so the
`if (!tabRect) continue` paths never skipped it and _tabAnchor() put the
line at (0, 0) ("M 0 0 C 350 0, 350 400, 700 400" in Chromium). The
sidebar filter already had the same defect; #580 brought it to the rail,
and the keystroke redraw from 5f373a93 now ran it on every keystroke that
hides the parent.
A new _paintedSessionTab() in app.js returns the parent's row only while
getClientRects() is non-empty, and every place a floating window
measures its parent tab goes through it:
- the shared `tab:<id>` rect cache, filled by the subagent connectors
and by both ultracode connector layers (all three fill it, so guarding
only the first would let the next one cache the zero rect itself);
- the ultracode window's spawn position, which now cascades;
- the subagent window's fly-from-tab spawn, the same defect, not in the
report, now positioned as a window without a tab;
- the ultracode genie on minimize, which now tears down at once.
Lineage lines need nothing: they measure on a cache miss and computeTree
already drops zero-size rects.
Tests: in the gate, with the page laid out by hand (jsdom has no
layout), a subagent window, an ultracode run window and an ultracode
agent window from a hidden row draw no line and draw it again from the
row once the search is cleared, an ultracode window spawns from a
painted row and cascades from a hidden one, and the genie is skipped for
a hidden row. In the browser suite, real Chromium shows the hidden row's
rect is all zero and the connector is gone, then back after Clear. All
four fail on the previous commit. CLAUDE.md and the invariants'
Connectors bullet record the rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Closing a tab took ~0.55-0.7s on an idle machine, more with child
processes or recently active subagents. Most of it was waiting:
- The web UI kept the tab until DELETE returned, then removed it on the
100ms tab-render debounce, which every session update restarts.
closeSession() is now optimistic: the tab, tile and split go and the
next session is selected before the request is sent, rendered at
once. A refused delete (checked with a GET, since a delete can land
and lose its reply) puts the row back at its old index with the
error toast. This also fixes a latent bug: _apiDelete never throws,
so an HTTP error used to report "Session closed" while the session
kept running. SSE upserts skip ids that are being closed.
- The kill path slept fixed intervals (100ms PTY grace, 200ms for the
pane's children, 100ms for the process group) and verified in 100ms
steps. waitForProcessesExit() (utils/process-exit-wait.ts) keeps
every deadline but returns once the processes are gone, counting a
zombie as exited. Signal decisions keep kill(pid, 0).
- tmux kill-session and the pane-pid lookup ran via execSync, freezing
the server for ~70ms per close. Now async.
- killSubagentsForSession() ran a full `pgrep -f claude` scan per
active/idle subagent (~85ms each with ~100 matching processes). It
now scans once for all of them.
Measured on an isolated instance: click to tab gone 540-690ms -> 58-95ms;
DELETE of a claude session ~450ms -> ~200-260ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md gained a rule for the rail's Search sessions box and linked it
to architecture-invariants#session-list-layout-header-strip-vs-left-sidebar,
which said nothing about it, and the "Collapse is per-device" bullet under
Owner tab layouts had an exception it did not record.
- Session list layout: one paragraph on the shared _applyTabListFilter()
over the pure CodemanTabSearch (classes only, layout-scoped hide rules,
rail matches the name and the sidebar name + folder, locale-independent
lower-casing), the alert-row keep (owner decision), the data-total count
restore, the tree walk and roving-stop fix-up, the projection opening
every group, the connector redraw, the global Escape claim, no drag
while searching, and the reset off the rail.
- Owner tab layouts: the collapse bullet notes that a search draws every
group open and refuses toggles without writing the stored set.
- CLAUDE.md: the Escape claim and the connector redraw as one clause on
the existing rail search rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the owner's review of #580 rather than the bot's report: "Drag
while searching: I'd turn it off. A drop is saved for every device
(session order or the tab layout), and where it lands relative to the
rows the search is hiding is something you only see after clearing it."
The PR head left drag on.
Both rail drags now refuse while the search narrows the list: the
grouped rail's pointer drag in _onTabLayoutPointerDown(), and the flat
manual rail's HTML5 drag in its dragstart listener. The flat rail is
refused in the listener, not by flipping `draggable`, because a
keystroke in the box does not re-render the rows, so a cleared search
drags again with the same rows. The sidebar filter box, the header strip
and the keyboard moves (Ctrl+Shift+{ }, the row menu) are unchanged.
Tests: a grouped-rail press during a search starts no drag and one after
clearing does; a flat-rail dragstart during a search is refused and one
after clearing goes through. CLAUDE.md and the Dashboard wiki row say
so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tab Alerts now shows a 17.5 s seamless loop of real sessions: a working
tab (spinning green ring), a red tab blocked on a real AskUserQuestion,
and a yellow tab whose turn is done, with a magnified tab strip and one
callout per state. It replaces the 2026-08-15 glow strip.
The agent skill section gets a time-lapse of a real run: one plain
English request, the codeman skill spawning three Claude Code workers as
new tabs, and the lineage lines drawing in, with numbered callouts that
appear as each step happens.
Both GIFs are 1920x1080 and link to 4800x2700 annotated stills.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The route test cleared only the registry CLIs' relocation vars, so with
COPILOT_HOME exported it wrote its fixture into that real Copilot config.
It now clears the sync-only tools' vars too, and Copilot's install probe goes
through the test's own installed set instead of the machine's PATH.
Docs: CLAUDE.md, the API reference and the Settings reference name COPILOT_HOME
and the sync-only table; a missing comma in docs/cli-registry.md; the
mcp-sync.ts overview and the Sync confirm mention Copilot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict in docs/wiki/Settings-Reference.md resolved by keeping #565's Apply
wording and adding Copilot (and COPILOT_HOME, which the bot's review found
missing from this list).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved against the release branch: docs/api-reference.md keeps
both new sections (codeman agent CLI, then Prompt uploads);
test/test-ports-guard.test.ts takes the release side (#570 already removed
the legacy list); test/paste-image-dir-shared.test.ts keeps #570's ephemeral
port and this PR's bounded-path-probe mock.
Also at merge: the Docker sentence in the route comment and the API reference
is narrowed to owned cases, since an adopted container mounts nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session with a tab alert (red action or yellow idle, whatever tabAlerts
holds, the same set a collapsed group header surfaces) now stays visible
while the rail search or the sidebar filter is narrowing the list, even
when its name does not match. A prompt waiting on you should never be
hidden by a view filter.
The pure CodemanTabSearch.filter decides it: a row passed with keep: true
is never hidden. It counts toward its group, so the group stays on screen
and the header number is the rows left showing, but not toward
matchCount, so "No sessions match" still shows above a lone alerted row.
_applyTabListFilter() flags session rows from tabAlerts; web tabs carry
no alerts and are never kept.
No new wiring: updateTabAlertFromHooks() and _onSessionWorking() already
call renderSessionTabs(), and both render paths end in the shared filter.
The README hero (both languages) is the same six-tile CRT loop, now with
the header's live stats strip: WS, CPU, memory and the Claude 5H/7D plan
usage chip, captured from a live Codeman with real numbers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The README and wiki tour image still showed the 1.7.0 UI. The new one is
a live capture of 1.40.0 (compact header pills with the plan-usage chip
beside them, File Viewer and Tiles buttons, tab logos, Run CC) with the
same three callouts: session tabs, live plan usage, one-click Run.
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.
- docs/browser-testing-guide.md still described the shrink-only legacy list
and the mobile suite's fixed ports; both are gone.
- CLAUDE.md: name the one fixed port left (codex-predictive-echo's separate
lab server on 3222), keep "Never 3000", and say the guard refuses a fixed
port rather than that it checks boundPort is read.
- The tui-client comment described the old "port + 1" dead port.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hero GIF is now six live agents (DeepSeek Harness, Claude Code, Pi,
Codex, OpenCode, a shell) powering on and off in the tile grid, captured
frame-stepped at 60fps from a real instance. Replaces the July subagent
demo in both READMEs; the old GIF file stays in docs/images.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up after the rebase onto 1.40.0, where tiles began paging a
hollow buffer through the shared CodemanTerminalInput helpers and opencode
joined the hollow check (#555).
- opencode declares transcriptPageKeys in stock.ts, so the paging list read
by both panes is claude, codex and opencode. Without it, reading the
capability instead of the old id check would have turned opencode paging
off again. The stock-set and render tests name it.
- The gesture rules (first trackpad-sized event pages at once and is owed
back, wheel notches accumulate, a pre-paid debt dies with its gesture,
pinch and mostly horizontal swipes are consumed without paging) move out
of the primary pane into the pure CodemanTerminalInput.pageKeysForGesture.
The primary pane and TerminalTile both call it with their own state, so a
Codex or opencode tile pages exactly like the main pane.
- The dialog check stays per pane: the primary pane reads tabAlerts for the
active session, a tile for its own sessionId, so a wheel over a tile never
pages into that session's permission prompt while the focused tab is clear,
and a tile is not blocked by a dialog in another tab.
Tests: pure helper cases in terminal-scroll-routing, tile cases for Codex
paging, first-event paging, notch accumulation, pinch and swipe, byte parity
with the primary pane over a mixed sequence, and the per-session dialog
check in both directions.
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.
The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.
Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.
Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.
Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up for the hollow-pane PageUp/PageDown fallback
(_maybePageCliTranscript):
- A mostly horizontal wheel swipe (both deltas present, |deltaX| > |deltaY|)
is consumed without paging. The touch path already locks its axis.
- A pinch (wheel events with ctrlKey, as Chrome reports one) is consumed
without paging.
- Only a trackpad-sized opening event (under 2 rows) pages at once. A wheel
notch (100 px, 4 rows) accumulates toward half a screen as before, so five
slow notches send one PageUp again instead of five. A debt from a pre-paid
page still dies with its gesture; plain wheel travel carries across notches.
- Nothing pages while the active tab's alert is 'action' (a pending
permission_prompt or elicitation_dialog), so a page key never reaches an
open dialog's selector.
Docs: the invariants paragraph now says which Codex versions were seen
drawing on the alt screen (0.157.1, 0.160) and that 0.147.0 still draws
inline, without claiming a cutoff.
Two changes to the last-resort page-key scroll (_maybePageCliTranscript), the
path a wheel or touch gesture takes when it is not forwarded to the CLI and
the local xterm buffer has no history (normal buffer, baseY 0).
Codex now takes that path too. Newer codex draws on tmux's alternate screen,
which the full strip hides from xterm, so a codex pane is as hollow as a
repaint-mode Claude pane and the wheel did nothing. Codex still ignores SGR
wheel reports and is still never forwarded the wheel; this sends plain
PageUp/PageDown keys, which codex does page its transcript on (measured on
0.157.1 locally and 0.160 over a remote session). Which modes may page is a
new registry capability, capabilities.transcriptPageKeys (claude and codex in
stock.ts), published to the page as window.__codemanTranscriptPageKeys, so
terminal-ui.js holds no CLI id for it.
The first event of a gesture now pages at once. The fallback used to send a
key only after half a screen of accumulated travel (19 rows, about 475px on a
38-row pane), so an ordinary trackpad flick sent nothing. The skipped travel is
owed back by the rest of the gesture, keeping one page per half screen; a pause
of more than 150 ms, a direction change or a tab switch starts a new gesture.
The hourly sweep called lstatSync and realpathSync on every live session's working directory, so a linked case on a mount that stopped answering blocked the event loop 30 s after boot and then every hour; the old sweep was fully async. uploadDirs() now probes the workspace with probePathKind() first and skips an unknown path without touching it, then uses fs.promises for the lstat and realpath. The sweep keeps the probe's stall cap; cleanupSession(), acting on one path at the user's request, passes pastCap and removes the directories with fs.rm.
Refusing an upload dir that sits strictly inside the data dir protected nothing (it only ever holds uploads, and a link is already excluded by lstat) while the route kept writing there, so uploads under a workspace like the ~/.codeman/app that install.sh clones were never swept and never removed. Only the two cases that matter stay refused: the upload dir being the data dir, or containing it.
Also: the ignore file's take-back after a failed write no longer replaces the error that caused it with its own, and the shared-dir test's header no longer names the fixed port it stopped using.
A pasted image landed in <workspace>/.claude-images/, a name that belongs to another tool, in a folder nothing ignored, so it showed up in git status of every case that ever received a paste. Uploads now go to a flat <workspace>/.codeman-uploads/ that carries a .gitignore of `*` (written once, never over a file already there): in the workspace because that is the only path identical for a local agent and a container, flat because a nested .codeman/ is the data dir itself when the workspace is the home directory.
The directory names live in paste-image-gc.ts alone. The old folder receives nothing but stays readable for one release: the hourly sweep and the delete cleanup go through uploadDirs(), the image watcher's ignore filter reads the names. uploadDirs() lists only real directories, none that is, contains or sits inside the data dir, and nothing for a remote session, since both consumers delete. The route refuses a remote (SSH) session before touching disk: the file would land on this host under the remote path, where the agent cannot read it.
test/paste-image-dir-shared.test.ts binds port 0 and leaves the port guard's legacy list.
Decided in #553.
Every animation setting now has its own App Settings section, right after
Appearance (owner: easier to find). It holds the Entrance Theme (the former
Entrance Animations row), Tile Animations, and an Open lab button that closes
settings and opens the per-surface lab (?animlab=1). Appearance keeps the skin,
identity and tab settings. New animation settings go in this section;
test/app-settings-structure.test.ts pins it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tiles become the fifth entrance surface (data-tile-anim), switched on in App
Settings > Appearance > Tile Animations and OFF by default: the default is the
grid's own quick fade (`settle`), exactly as before.
A styled tile plays in two beats: its frame enters as it mounts (fly out of
its session tab, dealt from the Tiles button, CRT power-on, beam down from its
tab, cascade, pop, soft), and its screen then plays the terminal pane style of
the entrance theme when its first capture lands, through the same
html[data-term-anim] rules on .tile-body. Each style has its own exit when the
Tiles button closes the grid (back into the tabs, a CRT switch-off, ...).
Picking an Entrance Animations theme presets the tile style (new Launch theme:
tiles fly from the tabs); the theme readout ignores the tile row, so changing
it never shows "Custom". Frames move transform and opacity only (one fit and
one PTY resize per tile), a reload's restore always settles, and nothing moves
under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade
order picker and in-place replay / close + reopen.
Also removes the terminal pane's `boot` entrance style (owner decision); a
saved `boot` falls back to off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>