Compare commits

..
Author SHA1 Message Date
Codeman maintainer 5206a044bb feat(settings): an Animations section in App Settings
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>
2026-10-09 18:22:14 +02:00
Codeman maintainer decd263b17 feat(tiles): Tile Animations setting, entrance styles for the tile grid
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>
2026-10-09 17:43:55 +02:00
50 changed files with 1754 additions and 1380 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
+2 -2
View File
@@ -248,7 +248,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
@@ -329,7 +329,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
+7 -7
View File
@@ -19,12 +19,16 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
@@ -50,7 +54,7 @@ The installer asks before every system change, and re-running the same line upda
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
<p align="center">
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
</p>
---
@@ -422,7 +426,7 @@ The title is templated into the served HTML on first byte, so it's correct from
### Tab Alerts
<p align="center">
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
</p>
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
@@ -735,10 +739,6 @@ For AI agents and automation that control Codeman without a browser: an agent th
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
<p align="center">
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
</p>
#### Step 1: install it
| How | Command | Scope |
+5 -1
View File
@@ -24,7 +24,11 @@
</p>
<p align="center">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
</p>
<p align="center">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
</p>
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
+2 -7
View File
@@ -457,13 +457,8 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
is the number of scrollback rows tmux holds above the visible frame, which is the
most a `full=1` request can add. `truncated` describes the byte stream instead: for a
pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a
`tail` cut dropped are earlier repaints that no request returns. The capture runs
synchronous tmux calls on the server; the `Server-Timing` header reports `capture`,
`prepare` and `total`.
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

+4 -1
View File
@@ -97,7 +97,10 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
own `settle` style, the default of App Settings → Animations → Tile Animations, which
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
+11 -6
View File
@@ -90,7 +90,6 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -104,6 +103,16 @@ every session or only the active tab.
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
All per device, all off by default, applied as you pick them.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
@@ -146,11 +155,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
### Notifications
Master toggle, browser notifications, push subscription, audio alerts, how long a
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
how long a desktop notification stays up before Codeman closes it (**Browser
notification display time**, default 8; both per device, and your OS may close a
desktop notification sooner), the idle
Master toggle, browser notifications, push subscription, audio alerts, the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
+2 -5
View File
@@ -6,7 +6,7 @@ Most of Codeman's UI is **opt-in**. A stock install shows a deliberately small h
feature you read about here may simply not be on screen yet. Where that is the case, this
page says so and names the setting.
![Codeman dashboard](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-tour-20261010.png)
![Codeman dashboard](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-tour-20260724.png)
## Layout
@@ -212,10 +212,7 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. That notice only
appears once you scroll to the top, leaves when you scroll back down, and stays away
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
Claude) never show it, since there is nothing more to load. Automatic output
history; press **Load full history** to pull the rest explicitly. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
-9
View File
@@ -204,15 +204,6 @@ export interface PaneCaptureOptions {
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
/**
* Filled in by the implementation with the number of rows the pane holds in
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
* same query as the geometry. 0 means a full-history capture can return
* nothing beyond the visible frame: a pane in the alternate screen (a
* fullscreen CLI that keeps its transcript itself) never accumulates any.
* Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
}
/**
+3 -9
View File
@@ -106,7 +106,6 @@ import {
getClaudeBinaryPath,
spawnPtyWithHelperRepair,
resolveLocalShell,
waitForProcessesExit,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -180,10 +179,7 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
/**
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
*/
/** Graceful shutdown delay when stopping session (100ms) */
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
@@ -4805,10 +4801,8 @@ export class Session extends EventEmitter {
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
}
// Give it a moment to terminate gracefully. For a tmux-backed session this
// is the attach client, gone within a few ms of SIGTERM, and this used to be
// a fixed 100ms sleep on every close.
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
// Give it a moment to terminate gracefully
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
// Force kill with SIGKILL if still alive
try {
+6 -28
View File
@@ -761,35 +761,13 @@ export class SubagentWatcher extends EventEmitter {
* by workingDir alone would kill subagents belonging to OTHER sessions.
*/
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
const targets = this.getSubagentsForSession(workingDir).filter(
// Only kill subagents belonging to this specific session
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
);
if (targets.length === 0) return;
// ONE process scan for the lot. This used to call killSubagent() per agent, and
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
// box with ~100 matching processes), so closing a session right after a workflow
// paid that once per recently active subagent. The match rules are
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
const pidMap = await this.getClaudePids();
const signalled = new Set<number>();
for (const agent of targets) {
// The liveness checker may have completed it while the scan ran.
if (agent.status !== 'active' && agent.status !== 'idle') continue;
for (const [pid, procInfo] of pidMap) {
if (signalled.has(pid)) continue;
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
signalled.add(pid);
try {
process.kill(pid, 'SIGTERM');
} catch {
// Process may have already exited
}
break; // one process per agent, as killSubagent() does
}
const subagents = this.getSubagentsForSession(workingDir);
for (const agent of subagents) {
if (agent.status === 'active' || agent.status === 'idle') {
// Only kill subagents belonging to this specific session
if (sessionId && agent.sessionId !== sessionId) continue;
await this.killSubagent(agent.agentId);
}
this.markSubagentAsCompleted(agent);
}
}
-2
View File
@@ -56,5 +56,3 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
+27 -68
View File
@@ -94,13 +94,7 @@ import {
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
resolveLocalShell,
loginShellArgs,
waitForProcessesExit,
} from './utils/index.js';
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -150,18 +144,12 @@ const TMUX_CREATION_WAIT_MS = 100;
const GET_PID_MAX_RETRIES = 5;
const GET_PID_RETRY_MS = 200;
/**
* How long killSession gives a pane's children to exit on SIGTERM before it
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
*/
/** Delay after tmux kill command (200ms) */
const TMUX_KILL_WAIT_MS = 200;
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
/** Delay for graceful shutdown (100ms) */
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** How long killSession waits for every process it signalled to be gone before it warns. */
const KILL_VERIFY_TIMEOUT_MS = 2000;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
@@ -700,12 +688,6 @@ interface PaneCursorGeometry {
rows: number;
cursorX: number;
cursorY: number;
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
}
/**
@@ -722,7 +704,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -735,9 +717,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) {
return null;
}
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
return geometry;
return { cols, rows, cursorX, cursorY };
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -2370,28 +2350,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* {@link getPanePid} without blocking the event loop, for the kill path: a close
* must not stall every other session's I/O while tmux answers.
*/
private async getPanePidAsync(muxName: string): Promise<number | null> {
if (IS_TEST_MODE) return 99999;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
return null;
}
try {
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
const pid = parseInt(stdout.trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
/**
* Check if a tmux session exists.
*/
@@ -2646,9 +2604,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
}
// Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited.
// Check if a process is still alive
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -2658,9 +2614,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Verify all PIDs are dead, returning as soon as they are
// Verify all PIDs are dead, with retry
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
const startTime = Date.now();
const checkInterval = 100;
while (Date.now() - startTime < maxWaitMs) {
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
if (aliveCount === 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
}
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
if (stillAlive.length > 0) {
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
}
@@ -2718,7 +2685,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2735,7 +2702,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Get current PID (may have changed)
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
const currentPid = this.getPanePid(session.muxName) || session.pid;
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
@@ -2757,9 +2724,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Most children are gone within a few ms; the re-scan below still runs, to
// catch anything spawned since the first one.
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
childPids = await this.getChildPidsFresh(currentPid);
for (const childPid of childPids) {
@@ -2777,7 +2742,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) {
try {
process.kill(-currentPid, 'SIGTERM');
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
if (this.isProcessAlive(currentPid)) {
process.kill(-currentPid, 'SIGKILL');
}
@@ -2786,12 +2751,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
// Async: tmux takes tens of ms to tear a session down, and execSync held the
// whole server for that long on every close.
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
if (isValidMuxName(session.muxName)) {
try {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2835,7 +2798,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Verify all processes are dead
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
const allDead = await this.verifyProcessesDead(allPids, 2000);
if (!allDead) {
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
}
@@ -3898,7 +3861,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3910,10 +3873,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
// none, and the partial-history notice must not promise it.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
-1
View File
@@ -12,7 +12,6 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
-76
View File
@@ -1,76 +0,0 @@
/**
* @fileoverview Wait for processes to exit, and return as soon as they have.
*
* The session kill path used to sleep a FIXED interval after each signal (100 ms
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
* timers. This keeps every deadline the kill path had; it only stops waiting
* once there is nothing left to wait for.
*
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
* it, and on the kill path that parent is the tmux server or the service
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
* zombie from a running process, so on Linux the state letter in
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
*
* @module utils/process-exit-wait
*/
import { readFileSync } from 'node:fs';
/** Poll step while waiting: an exit is noticed within about one frame. */
export const PROCESS_EXIT_POLL_MS = 10;
/**
* True while `pid` names a process that has not exited. A pid we may not signal
* reads as not running, which is what the kill path's own check always did:
* there is nothing it could do about such a process anyway.
*/
export function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
} catch {
return false;
}
if (process.platform !== 'linux') return true;
let stat: string;
try {
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
} catch (err) {
// Gone between the two reads. Any other failure: trust kill(pid, 0).
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
}
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
// so the state is the field after the LAST ')'.
const close = stat.lastIndexOf(')');
const state = close === -1 ? '' : stat.charAt(close + 2);
return state !== 'Z' && state !== 'X';
}
export interface WaitForExitOptions {
/** Give up after this long; the survivors are returned, never thrown. */
timeoutMs: number;
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
pollMs?: number;
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
isRunning?: (pid: number) => boolean;
}
/**
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
* that have not. Never rejects.
*/
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
const isRunning = options.isRunning ?? isProcessRunning;
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
const deadline = Date.now() + options.timeoutMs;
let running = pids.filter((pid) => isRunning(pid));
while (running.length > 0) {
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
running = running.filter((pid) => isRunning(pid));
}
return running;
}
+23 -110
View File
@@ -2286,8 +2286,6 @@ class CodemanApp {
}
_onSessionCreated(data) {
// A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
@@ -2312,9 +2310,6 @@ class CodemanApp {
_onSessionUpdated(data) {
const session = data.session || data;
// A session this tab is closing stays closed until the server answers
// (closeSession() puts it back if the delete is refused).
if (this._closingSessions?.has(session.id)) return;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
@@ -5959,19 +5954,9 @@ class CodemanApp {
// Session Tabs
// ═══════════════════════════════════════════════════════════════
renderSessionTabs({ immediate = false } = {}) {
renderSessionTabs() {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
if (immediate) {
// For a change the user just made and is watching for (closing a tab). The
// debounce restarts on every session update, so with busy sessions around a
// debounced pass can lag well past its 100ms. A pass still pending would only
// repeat this one, so it is dropped.
clearTimeout(this._debounceTimers.sessionTabs);
this._debounceTimers.sessionTabs = null;
this._renderSessionTabsImmediate();
return;
}
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -8689,9 +8674,6 @@ class CodemanApp {
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
// tmux scrollback above the frame (null = not reported). 0 is a pane with
// nothing a pull could add, which hides the notice outright.
paneHistoryLines: payload.paneHistoryLines ?? null,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
@@ -8699,27 +8681,9 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Bring the partial-history notice up for `sessionId`, or retire it (null).
*
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
* and a bar over the top rows on every switch described history the user had
* not reached for. It now waits for the scroll gesture that reaches the top of
* the browser's buffer (the moment the missing part matters, and the same
* gesture that already re-pulls history), and goes away again once the user
* scrolls back down to live output. A tab switch retires it (selectSession).
*/
_setHistoryNoticeRevealed(sessionId) {
const next = sessionId || null;
if ((this._historyNoticeRevealedFor ?? null) === next) return;
this._historyNoticeRevealedFor = next;
this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
@@ -8729,22 +8693,13 @@ class CodemanApp {
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
* to the top of this tab's buffer) and never again for a session whose notice
* the user dismissed on this page.
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const sessionId = this.activeSessionId;
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
if (!notice.visible) {
bar.hidden = true;
return;
}
@@ -8777,9 +8732,6 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
// Sticky for this session until the page reloads: a dismissed notice used
// to come straight back on the next tab switch.
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
bar.hidden = true;
};
bar.appendChild(dismiss);
@@ -8893,9 +8845,7 @@ class CodemanApp {
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258). The switch lands at
// live output, so the notice waits for a scroll to the top again.
this._historyNoticeRevealedFor = null;
// session's notice stays on screen until then (#258).
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
@@ -9689,32 +9639,27 @@ class CodemanApp {
}
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
// delete puts the row back below.
//
// ⚠️ Everything is read BEFORE the first await, and the delete is announced
// to _onSessionDeleted through _closingSessions: the `session_deleted` SSE
// broadcast for THIS delete arrives while the request is still in flight, and
// that handler must leave the follow-up selection to this method (both
// outcomes of that race were measured on one build, 2026-08-17).
const session = this.sessions.get(sessionId);
const orderIndex = this.sessionOrder.indexOf(sessionId);
// ⚠️ Captured BEFORE the await, and the delete is announced to
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
// broadcast for THIS delete routinely lands while the request is still in
// flight, and that handler nulls activeSessionId and shows the welcome
// screen. Re-reading the field after the await therefore made the fallback
// below a coin flip: closing the tab you were on either moved you to the
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
// Captured here for the same reason as wasActive: the SSE delete can remove
// the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
let res = null;
try {
// The same teardown the SSE event runs (split pane, tile, detached window,
// WebSocket, per-session state), only now instead of when the server is
// done. It is idempotent, so the real event finds nothing left to do.
this._onSessionDeleted({ id: sessionId });
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
// The last tile leaving closes the grid (no reselect): the pick below runs.
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
@@ -9739,50 +9684,18 @@ class CodemanApp {
}
}
this.renderSessionTabs({ immediate: true });
this.renderSessionTabs();
res = await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
} catch (err) {
// `res` stays null: handled below like a delete that got no answer.
console.warn('[closeSession] close failed:', err);
} finally {
this._closingSessions.delete(sessionId);
}
// 404: already gone (closed from another tab or device), which is what was asked.
let gone = !!res && (res.ok || res.status === 404);
if (!gone) {
// Refused, or no answer. Ask rather than guess: a delete can land on the
// server and still lose its reply, and its session_deleted event has then
// already been spent while the request was in flight.
const check = await this._api(`/api/sessions/${sessionId}`);
gone = check?.status === 404;
}
if (gone) {
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
// the server, which still had the session then.
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
} catch (err) {
this.showToast('Failed to close session', 'error');
} finally {
this._closingSessions.delete(sessionId);
}
// Still on the server (or the server is unreachable, in which case the resync
// on reconnect has the last word): its row comes back where it was.
if (session && !this.sessions.has(sessionId)) {
this.sessions.set(sessionId, session);
if (!this.sessionOrder.includes(sessionId)) {
const at = orderIndex === -1 ? this.sessionOrder.length : Math.min(orderIndex, this.sessionOrder.length);
this.sessionOrder.splice(at, 0, sessionId);
this.saveSessionOrder();
}
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
}
// Request confirmation before closing a session
+3 -30
View File
@@ -110,9 +110,6 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
@@ -1744,10 +1741,7 @@ function escapeHtml(text) {
function formatHistoryBytes(bytes) {
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
if (n < 1024) return 'less than 1 KB';
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
// just under 1 MiB and used to print as "1024 KB".
const kb = Math.round(n / 1024);
if (kb < 1024) return `${kb} KB`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -1761,27 +1755,12 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
* and the button only ever ended in the downgrade refusal. Nothing to offer,
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
const historyLines =
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
? Math.max(0, state.paneHistoryLines)
: null;
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
const retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1804,15 +1783,9 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false,
};
}
// Name what a pull can actually return when the server said: the byte gap
// counts repaints and redraw bloat, and overstates it many times over.
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${formatHistoryBytes(dropped)} more may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${more}`,
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
canLoadMore: true,
};
}
+490 -43
View File
@@ -1,12 +1,13 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* @fileoverview Entrance animations for the things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
* → Animations.
*
* Four constraints shape the design:
*
@@ -34,13 +35,28 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
// ── App Settings → Animations ─────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
const current = this._currentAnimStyles();
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
+69 -36
View File
@@ -1663,6 +1663,10 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-animations">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<span>Animations</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-models">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<span>Models</span>
@@ -2053,7 +2057,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<h2>Appearance</h2>
</div>
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
<div class="set-group">
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
@@ -2077,21 +2081,6 @@
</optgroup>
</select>
</div>
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
<div class="set-row-text">
<span class="set-row-label">Entrance Animations</span>
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
</div>
</div>
@@ -2248,6 +2237,70 @@
</div>
</section>
<!-- ══ Animations ═══════════════════════════════════════════════
Every entrance animation in one place (entrance-animations.js).
All per-device localStorage keys, applied as they are picked,
never part of PUT /api/settings. -->
<section class="set-section" id="settings-animations" data-label="Animations">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<h2>Animations</h2>
</div>
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
<div class="set-group">
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
<div class="set-row-text">
<span class="set-row-label">Entrance Theme</span>
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="launch">Launch (tiles fly from tabs)</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
<div class="set-row-text">
<span class="set-row-label">Tile Animations</span>
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
</div>
<select id="appSettingsTileAnim" class="set-select">
<option value="settle">Off (default)</option>
<option value="fly">Fly from tab</option>
<option value="deal">Deal</option>
<option value="crt">CRT</option>
<option value="beam">Beam down</option>
<option value="cascade">Cascade</option>
<option value="pop">Pop</option>
<option value="soft">Soft</option>
<option value="off">None (tiles just appear)</option>
</select>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Lab</h4></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
<div class="set-row-text">
<span class="set-row-label">Animation Lab</span>
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
</div>
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
</div>
</div>
</div>
</section>
<!-- ══ Models ═══════════════════════════════════════════════════ -->
<section class="set-section" id="settings-models" data-label="Models">
<div class="set-section-head">
@@ -2723,26 +2776,6 @@
<span class="set-unit">min</span>
</div>
</div>
<div class="set-row" data-search="toast display time popup dismiss seconds notification">
<div class="set-row-text">
<span class="set-row-label">Toast display time</span>
<span class="set-row-desc">How long the corner pop-ups stay on screen.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifToastSecs" class="set-num" value="3" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
<div class="set-row" data-search="browser notification auto close dismiss seconds">
<div class="set-row-text">
<span class="set-row-label">Browser notification display time</span>
<span class="set-row-desc">How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifBrowserSecs" class="set-num" value="8" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
</div>
</div>
+3 -20
View File
@@ -4,7 +4,7 @@
* The NotificationManager class implements five notification layers:
* 1. In-app notification drawer (slide-out panel with grouped notifications)
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
* 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications)
* 3. Browser Notification API (desktop push with auto-close after 8s)
* 4. Web Push via service worker (OS-level notifications when tab is closed)
* 5. Audio alerts (Web Audio API beep, user-opt-in)
*
@@ -93,10 +93,6 @@ class NotificationManager {
browserNotifications: !isMobile,
audioAlerts: false,
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
// How long a corner toast stays on screen, and how long a browser notification
// stays up before Codeman closes it (ms; per-device like the rest of these)
toastDurationMs: DEFAULT_TOAST_DURATION_MS,
browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS,
// Legacy urgency muting (keep for backwards compat)
muteCritical: false,
muteWarning: false,
@@ -171,24 +167,11 @@ class NotificationManager {
return {
...defaults,
...prefs,
toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs),
browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs),
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
_version: 5,
};
}
/** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */
clampDuration(value, fallback) {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value)));
}
/** Display time for corner toasts that do not set their own `duration`. */
getToastDurationMs() {
return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS);
}
loadPreferences() {
try {
const storageKey = this.getStorageKey();
@@ -420,7 +403,7 @@ class NotificationManager {
};
// Auto-close
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
}
async requestPermission() {
@@ -483,7 +466,7 @@ class NotificationManager {
notif.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
}
}
// Switch to session if available
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
+2 -3
View File
@@ -5862,8 +5862,7 @@ Object.assign(CodemanApp.prototype, {
},
/**
* `duration` defaults to the "Toast display time" preference (3000ms unless changed in
* Settings → Notifications) for every toast type. A message worth
* `duration` defaults to 3000ms for every toast type. A message worth
* reading rather than glancing at (e.g. "Session started on the native
* backend — could not apply the custom endpoint: <the actual reason>")
* passes an explicit `opts.duration: 0` at its own call site instead of
@@ -5875,7 +5874,7 @@ Object.assign(CodemanApp.prototype, {
* regardless of duration.
*/
showToast(message, type = 'info', opts = {}) {
const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts;
const { duration = 3000, action } = opts;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
+1 -9
View File
@@ -563,12 +563,6 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
document.getElementById('appSettingsNotifToastSecs').value = Math.round(
(this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000
);
document.getElementById('appSettingsNotifBrowserSecs').value = Math.round(
(notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000
);
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
@@ -2673,8 +2667,6 @@ Object.assign(CodemanApp.prototype, {
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
toastDurationMs: (parseInt(document.getElementById('appSettingsNotifToastSecs').value) || 3) * 1000,
browserAutoCloseMs: (parseInt(document.getElementById('appSettingsNotifBrowserSecs').value) || 8) * 1000,
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
@@ -2744,7 +2736,7 @@ Object.assign(CodemanApp.prototype, {
_version: 5,
};
if (this.notificationManager) {
this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave);
this.notificationManager.preferences = notifPrefsToSave;
this.notificationManager.savePreferences();
}
+588 -51
View File
@@ -1270,6 +1270,26 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -2px 0 10px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
}
.anim-lab-select select {
background: var(--bg-dark, #111);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.66rem;
padding: 2px 4px;
}
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
@@ -1456,11 +1476,20 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
.terminal-container.term-enter {
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
first capture lands: the same rules, so a tile combines its frame's own
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
.terminal-container.term-enter,
.tile-body.term-enter {
animation-fill-mode: both;
}
.terminal-container.term-enter::before {
.tile-body.term-enter .xterm {
transition: none;
}
.terminal-container.term-enter::before,
.tile-body.term-enter::before {
content: "";
position: absolute;
inset: 0;
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-fill-mode: both;
}
/* A screen whose content lands while its frame is still entering waits for
it (--tile-screen-delay, entrance-animations.js): the body holds its first
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
static block). */
.tile-body.term-enter {
animation-delay: var(--tile-screen-delay, 0ms);
}
.tile-body.term-enter::before {
animation-delay: var(--tile-screen-delay, 0ms);
animation-fill-mode: forwards;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter {
html[data-term-anim="crt"] .terminal-container.term-enter,
html[data-term-anim="crt"] .tile-body.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before {
html[data-term-anim="crt"] .terminal-container.term-enter::before,
html[data-term-anim="crt"] .tile-body.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter {
html[data-term-anim="wipe"] .terminal-container.term-enter,
html[data-term-anim="wipe"] .tile-body.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
html[data-term-anim="wipe"] .tile-body.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
@@ -1565,7 +1570,8 @@ html[data-term-anim="wipe"] .terminal-container.term-enter::before {
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter {
html[data-term-anim="slide"] .terminal-container.term-enter,
html[data-term-anim="slide"] .tile-body.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
@@ -1577,7 +1583,8 @@ html[data-term-anim="slide"] .terminal-container.term-enter {
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter {
html[data-term-anim="fade"] .terminal-container.term-enter,
html[data-term-anim="fade"] .tile-body.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1615,7 +1622,8 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
before, during and after a run), and the property allowlist for every one of
these keyframes is pinned by test/entrance-animations.test.ts. */
html[data-term-anim="blur"] .terminal-container.term-enter {
html[data-term-anim="blur"] .terminal-container.term-enter,
html[data-term-anim="blur"] .tile-body.term-enter {
animation-name: term-enter-blur;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
@@ -1738,10 +1746,20 @@ html[data-line-anim="blur"] .connection-line.line-enter {
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.tile-body.term-enter,
.tile-body.term-enter::before,
.tile.tile--entering,
.tile.tile--entering::before,
.connection-line.line-enter,
.connection-line-packet {
.connection-line-packet,
.connection-line.tile-beam-line,
.session-tab.tab-launch::after {
animation: none !important;
}
.tile-beam-lines {
display: none;
}
}
.session-tab .tab-status {
@@ -20062,6 +20080,314 @@ body.tile-grid-resizing--row * {
}
}
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
Picked in App Settings → Animations (Tile Animations, or preset by the
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
and what a reload restores with. Any other style is held one frame
(.tile--enter-hold: invisible, not animating) while the entrance module
orders the cascade and measures the tile's source on the final layout, then
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
FRAME keyframes animate transform and opacity only: FitAddon reads the
untransformed layout box (#464), and six tiles run at once, so no filter
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
::before wash. Each style also has its own way out on the closing grid's
still copy (tile-leave-*, below). */
html[data-tile-anim="settle"] .tile.tile--entering {
animation-name: tile-enter;
}
.tile.tile--entering.tile--enter-themed {
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.tile.tile--entering.tile--enter-themed::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
}
.tile.tile--enter-hold {
opacity: 0;
}
.tile.tile--enter-hold,
.tile.tile--enter-hold::before {
animation: none !important;
}
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
overshoots a hair and lands. */
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-fly;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
}
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
animation-name: tile-wash-launch;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-fly {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
}
16% {
opacity: 1;
}
74% {
transform: translate(0, 0) scale(1.01);
}
100% {
opacity: 1;
transform: none;
}
}
@keyframes tile-wash-launch {
0% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
}
55% {
opacity: 0.55;
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
}
100% {
opacity: 0;
background: transparent;
box-shadow: inset 0 0 0 1px transparent;
}
}
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
squared up in its cell. */
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-deal;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
}
@keyframes tile-enter-deal {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
rotate(var(--tile-from-rot, -10deg));
}
10% {
opacity: 1;
}
70% {
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
}
88% {
transform: scale(0.996) rotate(0deg);
}
100% {
opacity: 1;
transform: none;
}
}
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-crt {
0% {
opacity: 0;
transform: scale3d(0.5, 0.006, 1);
}
22% {
opacity: 1;
transform: scale3d(1, 0.01, 1);
}
58% {
opacity: 1;
transform: scale3d(1, 1.04, 1);
}
80% {
opacity: 1;
transform: scale3d(1, 0.985, 1);
}
100% {
opacity: 1;
transform: none;
}
}
/* Beam down: holds still while a beam draws down from its tab (the entrance
module's own overlay, .tile-beam-line), then materializes out of the agent
windows' green wash. No transform, so the beam lands where the tile is. */
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-beam {
0% {
opacity: 0;
}
20% {
opacity: 0.55;
}
32% {
opacity: 0.2;
}
48% {
opacity: 0.9;
}
100% {
opacity: 1;
}
}
/* Cascade: swings down from its top edge, a diagonal at a time. */
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-cascade;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tile-enter-cascade {
0% {
opacity: 0;
transform: perspective(1400px) rotateX(-80deg);
}
55% {
opacity: 1;
transform: perspective(1400px) rotateX(8deg);
}
80% {
transform: perspective(1400px) rotateX(-2.5deg);
}
100% {
opacity: 1;
transform: perspective(1400px) rotateX(0deg);
}
}
/* Pop: springs open from its centre, rippling out from the focused tile. */
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-pop;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
58% {
opacity: 1;
transform: scale(1.03);
}
80% {
transform: scale(0.993);
}
100% {
opacity: 1;
transform: none;
}
}
/* Soft: a slow drift up into place; with the Soft focus theme each screen
then focus-pulls in (the pane's `blur` style on .tile-body). */
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-soft;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tile-enter-soft {
0% {
opacity: 0;
transform: translateY(16px) scale(0.94);
}
100% {
opacity: 1;
transform: none;
}
}
/* The tab a tile flies (or beams) out of glows as it goes. */
.session-tab.tab-launch:not(.tab-loading)::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
}
@keyframes tab-launch-flash {
0% {
opacity: 0;
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
background: transparent;
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
}
}
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
tile, held while the tile materializes, then faded. Their own overlay
(#tileBeamLines), removed by the entrance module once they are done. */
.connection-line.tile-beam-line {
stroke-dasharray: var(--line-len);
animation:
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
var(--line-enter-delay, 0ms) both,
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
}
@keyframes tile-beam-out {
from {
opacity: 0.9;
}
to {
opacity: 0;
}
}
.tile-body .xterm {
transition: opacity 160ms ease-out;
}
@@ -20136,9 +20462,220 @@ body.tile-grid-resizing--row * {
}
}
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
on the still copy only and only for the Tiles toggle's close: a re-form's
copy (--now) covers tiles that stay, so it keeps the plain fade above.
Copies leave in reading order (--tile-exit-delay), so the last one ends
last and takes the layer with it. */
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-fly {
0% {
opacity: 0.8;
transform: scale(0.985);
}
75% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
}
}
@keyframes tile-wash-return {
0% {
opacity: 0;
background: transparent;
}
100% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
}
}
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-deal {
0% {
opacity: 0.8;
transform: scale(0.985);
}
22% {
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
}
85% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
rotate(var(--tile-to-rot, -10deg));
}
}
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-crt {
0% {
opacity: 0.8;
transform: scale(0.985);
}
42% {
opacity: 1;
transform: scale3d(1, 0.012, 1);
}
72% {
opacity: 1;
transform: scale3d(0.03, 0.012, 1);
}
100% {
opacity: 0;
transform: scale3d(0, 0, 1);
}
}
@keyframes tile-wash-crt-off {
0% {
opacity: 0;
background: transparent;
}
38% {
opacity: 1;
background: rgba(220, 255, 235, 0.95);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 1);
}
}
/* Beam down: beamed back up, flickering out under a rising wash. */
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-beam {
0% {
opacity: 0.8;
}
40% {
opacity: 0.9;
}
55% {
opacity: 0.3;
}
70% {
opacity: 0.6;
}
100% {
opacity: 0;
}
}
@keyframes tile-wash-beam-up {
0% {
opacity: 0;
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
}
100% {
opacity: 1;
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
}
}
/* Cascade: folds back up on its top edge. */
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
transform-origin: 50% 0%;
}
@keyframes tile-leave-cascade {
0% {
opacity: 0.8;
transform: perspective(1400px) rotateX(0deg) scale(0.985);
}
100% {
opacity: 0;
transform: perspective(1400px) rotateX(82deg) scale(0.985);
}
}
/* Pop: a last swell, then it pops away. */
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-pop {
0% {
opacity: 0.8;
transform: scale(0.985);
}
35% {
opacity: 0.9;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Soft: sinks away slowly. */
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-soft {
0% {
opacity: 0.8;
transform: scale(0.985);
}
100% {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.tile-count-menu,
.tile.tile--entering,
.tile.tile--entering::before,
.tile.tile--needs::after,
.tile.tile--loading .tile-body::after,
.tile-grid-ghosts .tile.tile--leaving,
+2 -16
View File
@@ -3627,24 +3627,10 @@ Object.assign(CodemanApp.prototype, {
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason.
*
* It is also what brings up the partial-history notice, and what retires it
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
* The reveal waits for the pull this gesture started, so the notice describes
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
if (lines >= 0) return;
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
},
/**
+92 -21
View File
@@ -237,8 +237,15 @@ Object.assign(CodemanApp.prototype, {
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
entry.el.classList.toggle('tile--loading', state !== 'idle');
// The first capture has landed (or failed): the terminal fades in,
// whole, instead of showing its replay scroll by.
if (state === 'idle') entry.el.classList.remove('tile--revealing');
// whole, instead of showing its replay scroll by, or plays the
// terminal pane's entrance style (entrance-animations.js).
if (state === 'idle') {
entry.el.classList.remove('tile--revealing');
if (entry.screenOwed) {
entry.screenOwed = false;
this.playTileScreenEntrance?.(entry.body);
}
}
},
});
}
@@ -536,14 +543,28 @@ Object.assign(CodemanApp.prototype, {
// Zoomed, only the zoomed tile is on screen (the others are display: none
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
const zoomed = section.classList.contains('tile-grid--zoomed');
const copies = [];
for (const id of grid.ids) {
const el = grid.tiles.get(id)?.el;
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
const ghost = el.cloneNode(true);
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
ghost.classList.remove(
'tile--entering',
'tile--enter-themed',
'tile--enter-hold',
'tile--needs',
'tile--loading',
'tile--drop-target',
'tile--dragging'
);
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
ghost.classList.add('tile--leaving');
layer.appendChild(ghost);
copies.push({ ghost, el, sessionId: id });
}
// The entrance style's own way out (entrance-animations.js): back into the
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
main.appendChild(layer);
let fallback = null;
let holdCap = null;
@@ -557,10 +578,11 @@ Object.assign(CodemanApp.prototype, {
if (fallback !== null || !layer.isConnected) return;
clearTimeout(holdCap);
layer.classList.add('tile-grid-ghosts--release');
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
};
layer.addEventListener('animationend', (e) => {
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
if (/^tile-leave/.test(e.animationName)) done();
});
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
else release();
@@ -1323,11 +1345,10 @@ Object.assign(CodemanApp.prototype, {
/**
* Builds one tile (header, body, TerminalTile); where it goes is the
* caller's (grid.cells). It enters with a short fade and settle
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
* and its terminal stays transparent until its first capture lands
* (.tile--revealing, cleared by the load queue, with a backstop timer).
* Opacity and transform only, never anything its fit reads.
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
* group (_beginTileEntrance), and its terminal stays transparent until its
* first capture lands (.tile--revealing, cleared by the load queue, with a
* backstop timer). Opacity and transform only, never anything its fit reads.
*/
_mountTile(sessionId, { enterIndex = 0 } = {}) {
const grid = this._tileGrid;
@@ -1336,16 +1357,11 @@ Object.assign(CodemanApp.prototype, {
const el = document.createElement('div');
el.className = 'tile tile--revealing';
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
if (this._tileMotionAllowed()) {
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
const onEnd = (e) => {
if (e.target !== el || e.animationName !== 'tile-enter') return;
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering');
};
el.addEventListener('animationend', onEnd);
}
// Its screen plays the terminal pane's entrance once its first capture
// lands, when its frame entered in a tile style (Tile Animations): never
// with the default `settle`, nor on a reload's restore, which settles.
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
const screenOwed = !!entered && entered !== 'settle';
el.dataset.sessionId = sessionId;
// Header and body are siblings: the chrome is refreshed in place
// (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1391,12 +1407,61 @@ Object.assign(CodemanApp.prototype, {
renaming: false,
// The pid this tile last saw, so a pane that starts later is noticed.
pid: this.sessions.get(sessionId)?.pid ?? null,
// Its screen plays the terminal entrance once its first capture lands.
screenOwed,
});
// Where it goes is the caller's (grid.cells).
this._renderTileHeader(sessionId);
return true;
},
/**
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
* entrance style (entrance-animations.js, App Settings → Entrance
* Animations). `settle` is the grid's own fade and settle (styles.css
* .tile--entering), and what a reload restores with whatever the theme:
* nothing animates on page load. Any other style is timed by the entrance
* module a frame later, once the layout is final (_stageTileEntrance).
* Opacity and transform only, never anything the fit reads.
*
* @returns {string|null} the style it enters with, or null when it does not animate
*/
_beginTileEntrance(el, sessionId, enterIndex = 0) {
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
if (!this._tileMotionAllowed() || style === 'off') return null;
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
let backstop = null;
const finish = () => {
clearTimeout(backstop);
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
};
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
// never a child's animation or a wash on its ::before.
const onEnd = (e) => {
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
finish();
};
el.addEventListener('animationend', onEnd);
el._tileEnterFinish = finish;
if (style !== 'settle') {
this._stageTileEntrance?.(el, sessionId, (ms) => {
clearTimeout(backstop);
backstop = setTimeout(finish, ms);
});
}
return style;
},
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
_replayTileEntrance(el, sessionId, enterIndex = 0) {
el._tileEnterFinish?.();
void el.offsetWidth;
return this._beginTileEntrance(el, sessionId, enterIndex);
},
/**
* Makes `el` a drop target for a session tab dragged from the strip (the
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -2598,7 +2663,13 @@ Object.assign(CodemanApp.prototype, {
if (this._tilesOwnTerminal()) return false;
const stored = this._readStoredTileGrid();
if (!stored?.open || stored.ids.length === 0) return false;
return this._openStoredTileGrid(stored);
// Put back, not opened: the tiles settle in whatever the entrance theme.
this._tileEnterQuiet = true;
try {
return this._openStoredTileGrid(stored);
} finally {
this._tileEnterQuiet = false;
}
},
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
-6
View File
@@ -3225,12 +3225,6 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read.
paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined,
};
});
-2
View File
@@ -1517,8 +1517,6 @@ export const SettingsUpdateSchema = z
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
stuckThresholdMs: z.number().optional(),
toastDurationMs: z.number().optional(),
browserAutoCloseMs: z.number().optional(),
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
+25
View File
@@ -80,6 +80,31 @@ describe('App Settings modal structure', () => {
expect(system).toContain('id="appSettingsTunnelEnabled"');
});
/**
* Owner decision (2026-10-09): every animation setting has its own
* Animations section, right after Appearance, so it is easy to find. The
* selects are wired by id in entrance-animations.js, not by the load/save
* path above, so they get their own check here.
*/
it('keeps every animation setting in its own Animations section, after Appearance', () => {
const modal = settingsModal();
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
for (const list of [rail, order]) {
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
}
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
}
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(anim).toContain(`document.getElementById('${id}')`);
}
});
it('keeps Local Echo the first row of the second section', () => {
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
+123 -7
View File
@@ -31,6 +31,7 @@ const SURFACES = [
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
] as const;
/**
@@ -40,6 +41,11 @@ const SURFACES = [
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
function isCssLess(attr: string, key: string): boolean {
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
}
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
function themes(): Theme[] {
const start = animSource.indexOf('const ANIM_THEMES = [');
const body = animSource.slice(start, animSource.indexOf('];', start));
return [
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
const parsed = [
...body.matchAll(
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
// Every theme entry must parse: a theme missing a surface (or a regex that
// drifted from the source) would otherwise pass the checks below vacuously.
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
expect(parsed.length).toBeGreaterThan(0);
return parsed;
}
/**
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
describe(`${surface.attr} surface`, () => {
it('backs every style with a rule that names a keyframe block that exists', () => {
for (const key of styleKeys(surface.array)) {
if (CSS_LESS_STYLES.has(key)) {
if (isCssLess(surface.attr, key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
});
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
/**
* Tiles are the exception: six frames animate at once, so their styles stay
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
*/
it('ships the blur style on the four single-element surfaces', () => {
for (const surface of SURFACES) {
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
else expect(styleKeys(surface.array)).toContain('blur');
}
});
it('gives every theme an <option> and only styles that exist', () => {
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
describe('tile grid entrance styles', () => {
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
const tileNames = (key: string, scope: RegExp) =>
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
.filter((rule) => scope.test(rule[1]))
.flatMap((rule) =>
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
/**
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
* A tile fits once at its final size (#464); a box-model property here would
* resize its PTY mid-animation, and a filter on six live terminals at once
* is the frame-time cost the pane's `blur` takes for one.
*/
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
if (key === 'off') continue;
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
for (const { name, onPseudo } of names) {
const body = keyframeBody(name);
expect(body, `@keyframes ${name} missing`).not.toBeNull();
if (onPseudo) continue; // a wash over the tile, no layout of its own
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
}
}
}
});
/**
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
* animationend, and the still copy goes on its last tile's `tile-leave*`:
* a keyframe named otherwise would leave the class on (or the copy up)
* until a backstop timer.
*/
it('names every frame keyframe for the events tile-grid.js listens for', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
}
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
}
}
});
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
expect(timed.length).toBeGreaterThan(0);
for (const key of timed) {
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
}
expect(timed).not.toContain('settle');
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
// The legacy theme (the default) leaves the grid's own motion untouched.
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
});
/**
* App Settings → Appearance → Tile Animations: one option per style, wired
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
* own `settle` first and named as the off default.
*/
it('lists every tile style in the Tile Animations setting, off by default', () => {
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
expect(values[0]).toBe('settle');
expect(select).toContain('<option value="settle">Off (default)</option>');
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
// Nothing new for an install that never picks it: no saved key means settle.
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
});
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
it('plays every terminal pane style on a tile screen too', () => {
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
// The body itself, not only its ::before wash.
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
}
});
});
-278
View File
@@ -1,278 +0,0 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
-64
View File
@@ -45,11 +45,6 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
@@ -119,65 +114,6 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
-33
View File
@@ -23,10 +23,6 @@ type Manager = {
getStorageKey: () => string;
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
notify: (notification: Record<string, unknown>) => void;
getToastDurationMs: () => number;
unreadCount: number;
markAllRead: () => void;
clearAll: () => void;
};
const openWindows: JSDOM[] = [];
@@ -52,10 +48,6 @@ function loadManager(
STUCK_THRESHOLD_DEFAULT_MS: number;
GROUPING_TIMEOUT_MS: number;
NOTIFICATION_LIST_CAP: number;
AUTO_CLOSE_NOTIFICATION_MS: number;
DEFAULT_TOAST_DURATION_MS: number;
MIN_NOTIFICATION_DURATION_MS: number;
MAX_NOTIFICATION_DURATION_MS: number;
};
win.MobileDetection = {
getDeviceType: () => device.deviceType ?? 'desktop',
@@ -64,10 +56,6 @@ function loadManager(
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
win.GROUPING_TIMEOUT_MS = 5_000;
win.NOTIFICATION_LIST_CAP = 100;
win.AUTO_CLOSE_NOTIFICATION_MS = 8_000;
win.DEFAULT_TOAST_DURATION_MS = 3_000;
win.MIN_NOTIFICATION_DURATION_MS = 1_000;
win.MAX_NOTIFICATION_DURATION_MS = 300_000;
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
@@ -78,7 +66,6 @@ function loadManager(
}
win.eval(`
window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
${SOURCE}
window.__testNotificationManager = NotificationManager;
`);
@@ -161,23 +148,3 @@ describe('notification noise defaults', () => {
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
});
});
describe('notification display time', () => {
it('defaults to 3s toasts and 8s browser notifications', () => {
const { manager } = loadManager();
expect(manager.getToastDurationMs()).toBe(3000);
expect((manager.preferences as unknown as Record<string, number>).browserAutoCloseMs).toBe(8000);
});
it('honours a configured toast time and clamps unusable values', () => {
const { manager } = loadManager({ toastDurationMs: 15_000 });
expect(manager.getToastDurationMs()).toBe(15_000);
const clamp = (value: unknown) =>
(manager.normalizePreferences({ toastDurationMs: value }) as unknown as Record<string, number>).toastDurationMs;
expect(clamp(10)).toBe(1000);
expect(clamp(9_999_999)).toBe(300_000);
expect(clamp('soon')).toBe(3000);
expect(clamp(undefined)).toBe(3000);
});
});
-81
View File
@@ -1,81 +0,0 @@
/**
* @fileoverview The kill path's waits end when the processes do, not on a timer.
*
* Closing a session used to sleep a fixed 100 + 200 + 100 ms across its signals
* and then verify in 100 ms steps, so a session whose processes were gone in a
* few ms still took ~0.45 s to close. `waitForProcessesExit()` keeps each of
* those deadlines but returns as soon as nothing is left running, and
* `isProcessRunning()` counts a zombie as exited (it holds nothing but its pid
* until its reaper gets to it, and `kill(pid, 0)` cannot tell it apart).
*
* Port: N/A.
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { isProcessRunning, waitForProcessesExit } from '../src/utils/process-exit-wait.js';
describe('waitForProcessesExit', () => {
it('returns as soon as every pid has exited, long before the deadline', async () => {
let polls = 0;
const isRunning = (): boolean => ++polls < 4;
const started = Date.now();
const survivors = await waitForProcessesExit([101, 102], { timeoutMs: 5000, pollMs: 5, isRunning });
expect(survivors).toEqual([]);
expect(Date.now() - started).toBeLessThan(1000);
});
it('returns the survivors at the deadline instead of throwing', async () => {
const started = Date.now();
const survivors = await waitForProcessesExit([7, 8], {
timeoutMs: 60,
pollMs: 10,
isRunning: (pid) => pid === 8,
});
expect(survivors).toEqual([8]);
expect(Date.now() - started).toBeGreaterThanOrEqual(50);
});
it('does not wait at all when nothing is running', async () => {
const started = Date.now();
expect(await waitForProcessesExit([], { timeoutMs: 5000 })).toEqual([]);
expect(await waitForProcessesExit([9], { timeoutMs: 5000, isRunning: () => false })).toEqual([]);
expect(Date.now() - started).toBeLessThan(100);
});
});
describe('isProcessRunning', () => {
it('is true for a live process and false for a pid that does not exist', () => {
expect(isProcessRunning(process.pid)).toBe(true);
// Above the default pid_max on Linux and macOS alike.
expect(isProcessRunning(4_194_304 + 12_345)).toBe(false);
});
it.skipIf(process.platform !== 'linux')('counts a zombie as exited, which kill(pid, 0) cannot', async () => {
// `sleep 0` exits at once, and its parent then becomes `sleep 5`, which never
// reaps anything: the child stays a zombie until the parent itself goes.
const parent = spawn('sh', ['-c', 'sleep 0 & echo $!; exec sleep 5'], { stdio: ['ignore', 'pipe', 'ignore'] });
try {
const zombie = await new Promise<number>((resolve, reject) => {
parent.stdout.once('data', (chunk: Buffer) => resolve(parseInt(chunk.toString(), 10)));
parent.once('error', reject);
});
const state = (): string => {
const stat = readFileSync(`/proc/${zombie}/stat`, 'utf8');
return stat.charAt(stat.lastIndexOf(')') + 2);
};
for (let i = 0; i < 100 && state() !== 'Z'; i++) await new Promise((r) => setTimeout(r, 10));
expect(state()).toBe('Z');
expect(() => process.kill(zombie, 0)).not.toThrow();
expect(isProcessRunning(zombie)).toBe(false);
} finally {
parent.kill('SIGKILL');
}
});
});
-47
View File
@@ -946,53 +946,6 @@ describe('session-routes', () => {
expect(body.data.captureRows).toBeUndefined();
});
it('reports how many scrollback rows tmux holds for the captured pane', async () => {
// `truncated` measures the byte stream; this is what a `full=1` pull can
// return. 0 (a fullscreen CLI in the alternate screen) is the case where
// the client must not offer to load the rest.
harness.ctx._session.terminalBuffer = 'x'.repeat(4096);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(
_name: string,
opts?: { capturedGeometry?: { cols: number; rows: number }; capturedHistoryLines?: number }
) => {
if (opts) {
opts.capturedGeometry = { cols: 100, rows: 50 };
opts.capturedHistoryLines = 0;
}
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=1024`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.paneHistoryLines).toBe(0);
});
it('omits paneHistoryLines when the body is the byte history, not a capture', async () => {
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = 12;
return null;
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.paneHistoryLines).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
+2 -92
View File
@@ -16,11 +16,6 @@
* leaves the active-session handoff alone for a close this tab started. A delete
* from anywhere else still lands on the welcome screen.
*
* The close is also OPTIMISTIC: the tab goes and the next one is selected before
* the DELETE is even sent (the server's kill takes a few hundred ms, and a tab
* sitting there that long read as a dead button). A refused delete puts the row
* back where it was; one whose reply was lost is checked with a GET first.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
* Port: N/A.
*/
@@ -79,11 +74,7 @@ function makeApp(active: string | null, order = [A, B]): TestApp {
app.isSoloWindow = false;
app._wsSessionId = null;
app.terminal = { clear: vi.fn() };
// Shaped like the Response the real helper resolves with (or null: no answer).
app._apiDelete = vi.fn(async () => ({ ok: true, status: 200 }));
// The failure path's "is it still there?" check: yes, by default.
app._api = vi.fn(async () => ({ ok: true, status: 200 }));
app.saveSessionOrder = vi.fn();
app._apiDelete = vi.fn(async () => ({ success: true }));
// The real one touches ~20 maps; the parts this behavior depends on are the
// session map and the tab order, so those are pruned for real.
app._cleanupSessionData = vi.fn((id: string) => {
@@ -110,7 +101,7 @@ describe('closing the active session', () => {
// delete lands before the request resolves.
app._apiDelete = vi.fn(async () => {
app._onSessionDeleted({ id: A });
return { ok: true, status: 200 };
return { success: true };
});
await app.closeSession(A);
@@ -183,84 +174,3 @@ describe('closing the active session', () => {
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
});
describe('closing is optimistic', () => {
it('drops the tab and selects the next one BEFORE the server answers', async () => {
const app = makeApp(A);
let answer: (value: unknown) => void = () => {};
app._apiDelete = vi.fn(() => new Promise((r) => (answer = r)));
const closing = app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
// Rendered now, not on the debounce that every session update restarts.
expect(app.renderSessionTabs).toHaveBeenCalledWith({ immediate: true });
expect(app.showToast).not.toHaveBeenCalled();
answer({ ok: true, status: 200 });
await closing;
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a refused delete puts the row back where it was, and says so', async () => {
const app = makeApp(B, [A, B]);
app._apiDelete = vi.fn(async () => ({ ok: false, status: 500 }));
await app.closeSession(A);
expect(app._api).toHaveBeenCalledWith(`/api/sessions/${A}`);
expect(app.sessions.has(A)).toBe(true);
expect(app.sessionOrder).toEqual([A, B]);
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
it('a delete that landed but lost its reply counts as closed', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => null);
app._api = vi.fn(async () => ({ ok: false, status: 404 }));
await app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a 404 from the delete is a close that already happened', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => ({ ok: false, status: 404 }));
await app.closeSession(A);
expect(app._api).not.toHaveBeenCalled();
expect(app.sessions.has(A)).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Session closed and tmux killed', 'success');
});
it('a session update arriving mid-close does not bring the tab back', async () => {
const app = makeApp(A);
app.updateCost = vi.fn();
app.updateSubagentParentNames = vi.fn();
app._apiDelete = vi.fn(async () => {
(app as unknown as { _onSessionUpdated: (d: unknown) => void })._onSessionUpdated({ id: A, status: 'idle' });
return { ok: true, status: 200 };
});
await app.closeSession(A);
expect(app.sessions.has(A)).toBe(false);
});
it('a second close of the same tab while the first is in flight is a no-op', async () => {
const app = makeApp(A);
let answer: (value: unknown) => void = () => {};
app._apiDelete = vi.fn(() => new Promise((r) => (answer = r)));
const first = app.closeSession(A);
await app.closeSession(A);
answer({ ok: true, status: 200 });
await first;
expect(app._apiDelete).toHaveBeenCalledTimes(1);
});
});
-61
View File
@@ -68,7 +68,6 @@ import * as fs from 'fs';
import * as fsPromises from 'node:fs/promises';
import { createInterface } from 'readline';
import { execSync } from 'child_process';
import { execFile as nodeExecFile } from 'node:child_process';
/**
* Flush the microtask queue to allow async scanForSubagents() to complete.
@@ -1629,66 +1628,6 @@ describe('SubagentWatcher', () => {
expect(result).toBe(true);
expect(completedHandler).toHaveBeenCalled();
});
it("killSubagentsForSession scans the process table ONCE for all of a session's agents", async () => {
// Closing a session ran a full `pgrep -f claude` + /proc read per recently
// active subagent (~85ms each on a busy box); one scan now serves them all.
// One readline per transcript: discovery reads each agent's file in turn.
const rls: ReturnType<typeof createMockRl>[] = [];
mockCreateInterface.mockImplementation(() => {
const rl = createMockRl();
rls.push(rl);
return rl;
});
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => {
if (path.includes('subagents')) return ['agent-k1.jsonl', 'agent-k2.jsonl', 'agent-k3.jsonl'];
if (path.includes('session1')) return ['subagents'];
if (path.includes('-home-user-project')) return ['session1'];
return ['-home-user-project'];
});
mockStatSync.mockReturnValue({ isDirectory: () => true, birthtime: new Date(), mtime: new Date(), size: 100 });
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
watcher.start();
for (let i = 0; i < 3; i++) {
await flushAsyncScan();
rls.forEach((rl) => rl.emit('close'));
await vi.advanceTimersByTimeAsync(100);
}
const agents = watcher.getSubagentsForSession('/home/user/project');
expect(agents.map((a) => a.agentId).sort()).toEqual(['k1', 'k2', 'k3']);
const execFileMock = vi.mocked(nodeExecFile) as unknown as Mock;
const originalExecFile = execFileMock.getMockImplementation();
let pgrepCalls = 0;
execFileMock.mockImplementation(
(cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string) => void) => {
if (cmd === 'pgrep') pgrepCalls++;
cb(null, '4242\n4343\n');
}
);
mockReadFile.mockImplementation(async (path: string) => {
if (path === '/proc/4242/environ') return 'HOME=/x\0PARENT=session1\0';
if (path.startsWith('/proc/')) return 'HOME=/x\0';
return mockReadFileSync(path);
});
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
let killCalls: unknown[][] = [];
try {
await watcher.killSubagentsForSession('/home/user/project', 'session1');
killCalls = [...killSpy.mock.calls];
} finally {
killSpy.mockRestore();
execFileMock.mockImplementation(originalExecFile as never);
}
expect(pgrepCalls).toBe(1);
expect(agents.every((a) => a.status === 'completed')).toBe(true);
// Only the matching process, and only once.
expect(killCalls).toEqual([[4242, 'SIGTERM']]);
});
});
describe('Error Handling', () => {
-14
View File
@@ -61,20 +61,6 @@ describe('generateClaudeMd', () => {
expect(result).toContain('CODEMAN_MUX=1');
});
it('should tell the agent to print absolute paths of files it creates', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('full absolute path');
expect(result).toContain('clickable');
});
it('should point the agent at the codeman skill', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('`codeman` skill');
expect(result).toContain('codeman skill install');
});
it('should include workflow rules', () => {
const result = generateClaudeMd('my-project');
+7 -9
View File
@@ -7,9 +7,9 @@
* pick that, with the grid open, would be refused (auto never collapses the
* grid) and leave nothing focused. So the fallback is grid-aware, and it lives
* IN closeSession: the delete broadcast routinely lands while the request is in
* flight, and the delete handlers skip ids in `_closingSessions`. The close is
* optimistic, so the tile goes and the neighbour takes focus before the request
* is even sent; the broadcast then finds nothing left to do.
* flight, and the delete handlers skip ids in `_closingSessions`. The neighbour
* is captured BEFORE the await, like `wasActive`, because that broadcast may
* already have removed the tile.
*
* - closing the focused tile: next tile in grid order, else the previous one;
* `s-other` is FIRST in sessionOrder and never tiled, so the old pick would
@@ -33,8 +33,7 @@ function setup(ids = IDS, focus = ids[0]) {
app.selectSession = vi.fn();
app.markIdleAlertSeen.mockClear();
let finish: () => void = () => {};
// Resolves like the real helper's Response once `finish()` is called.
app._apiDelete = vi.fn(() => new Promise((r) => (finish = () => r({ ok: true, status: 200 }))));
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
// The real cleanup touches a lot of panels; what the fallback reads is the session list.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
@@ -78,12 +77,11 @@ describe('closeSession on the focused tile', () => {
const { app, finish } = setup(IDS, 's-b');
const closing = app.closeSession('s-b');
await settle();
// The close already moved focus to the neighbour before the request went
// out; the broadcast for it must not move it again.
expect(app.activeSessionId).toBe('s-c');
app._onSessionDeleted({ id: 's-b' });
// Only the tile went; closeSession owns the follow-up (as the split's
// wrapper does for ids in _closingSessions), so focus has not moved yet.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.activeSessionId).toBe('s-b');
expect(app.showWelcome).not.toHaveBeenCalled();
finish();
await closing;
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview tile-grid.js's side of the tile entrance styles (the entrance
* module, entrance-animations.js, picks the style and times it):
*
* - `settle` (the default, and what the harness gets with no entrance module
* loaded) is the grid's own motion, untouched: tile-grid-motion.test.ts.
* - Any other style hands each mounted tile to `_stageTileEntrance`, which
* arms the backstop that ends the entrance if animationend never comes.
* - `.tile--entering` comes off on the tile's own `tile-enter*` end only,
* never on a ::before wash ending first.
* - `off` mounts tiles with no entrance and no screen beat.
* - A reload's restore settles whatever the style, and owes no screen beat.
* - The screen beat (`playTileScreenEntrance`) plays once, when the load queue
* reports a tile's first capture done, and only for a tile style (never
* with the default `settle`).
* - The closing grid's still copy hands its copies to `_stageTileExit` and
* waits as long as it says before the fallback removes the copy.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
localStore,
main,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
/** An app whose entrance module picks `style`, with the module's hooks recorded. */
function styledApp(style: string): GridApp {
const app = makeGridApp(IDS);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app.tileEntranceStyle = () => style;
app._stageTileEntrance = vi.fn();
app.playTileScreenEntrance = vi.fn();
return app;
}
/** Swaps in a load queue that hands the grid's state callback to the test. */
function captureQueue() {
const Queue = windowStub.TileLoadQueue;
const box: { report: (tile: unknown, state: string) => void; restore: () => void } = {
report: () => {},
restore: () => {
windowStub.TileLoadQueue = Queue;
},
};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
box.report = opts.onChange;
}
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
return box;
}
beforeEach(() => {
resetGridHarness();
});
afterEach(() => {
vi.useRealTimers();
});
describe('a themed entrance', () => {
it('hands every mounted tile to the entrance module, entering and in reading order', () => {
const app = styledApp('fly');
app.openTileGrid(IDS.slice(0, 3), { focusedId: 's-b' });
expect(app._stageTileEntrance).toHaveBeenCalledTimes(3);
const calls = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.map((c) => c[1])).toEqual(['s-a', 's-b', 's-c']);
for (const [el] of calls) expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
});
it('the backstop the module arms ends the entrance when animationend never comes', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const [el, , setBackstop] = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls[0];
(el as FakeEl).classList.add('tile--enter-themed', 'tile--enter-hold');
setBackstop(800);
vi.advanceTimersByTime(799);
expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
vi.advanceTimersByTime(1);
expect((el as FakeEl).className.split(' ')).not.toContain('tile--entering');
expect((el as FakeEl).classList.contains('tile--enter-themed')).toBe(false);
expect((el as FakeEl).classList.contains('tile--enter-hold')).toBe(false);
});
it('ends on its own tile-enter-* animationend, never on a ::before wash or a child', () => {
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const el = tiles()[0];
el.dispatch('animationend', { target: el, animationName: 'win-enter-crt-flash', pseudoElement: '::before' });
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '::before' });
el.dispatch('animationend', { target: new FakeEl(), animationName: 'tile-enter-crt' });
expect(el.classList.contains('tile--entering')).toBe(true);
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '' });
expect(el.classList.contains('tile--entering')).toBe(false);
});
it('`off` mounts tiles with no entrance and owes them no screen beat', () => {
const queue = captureQueue();
try {
const app = styledApp('off');
app.openTileGrid(['s-a', 's-b']);
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('`settle` stays on the grid default: no hand-off to the module', () => {
const app = styledApp('settle');
app.openTileGrid(['s-a', 's-b']);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1']);
});
});
describe('the screen beat', () => {
it('never plays with the default settle: the grid behaves exactly as before', () => {
const queue = captureQueue();
try {
const app = styledApp('settle');
app.openTileGrid(['s-a']);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('plays once, on the first capture landing, on that tile body', () => {
const queue = captureQueue();
try {
const app = styledApp('fly');
app.openTileGrid(['s-a', 's-b']);
app._tileLoadQueue();
const tileA = app._tileFor('s-a');
queue.report(tileA, 'running');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
expect(app.playTileScreenEntrance).toHaveBeenCalledWith(app._tileGrid.tiles.get('s-a').body);
// A later load (a reconnect refresh) plays nothing again.
queue.report(tileA, 'running');
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('a reload restoring the grid', () => {
it('settles whatever the style, and owes no screen beat (nothing animates on page load)', () => {
const queue = captureQueue();
try {
localStore.set(
'codeman:tile-grid',
JSON.stringify({
v: 1,
open: true,
ids: ['s-a', 's-b'],
focused: 's-a',
zoomed: null,
colFr: [1, 1],
rowFr: [1],
})
);
const app = styledApp('deal');
expect(app._restoreTileGrid()).toBe(true);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().every((t) => t.classList.contains('tile--entering'))).toBe(true);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
// The quiet flag is gone after the restore: the next open is themed.
app.closeTileGrid({ reselect: false });
app.openTileGrid(['s-c']);
expect(app._stageTileEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('closing with the Tiles toggle', () => {
it('hands the copies to the exit, stripped of entrance state, and waits as long as it says', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 1200);
app.openTileGrid(IDS.slice(0, 2));
for (const t of tiles()) t.classList.add('tile--enter-themed', 'tile--enter-hold');
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
const [copies, opts] = (app._stageTileExit as ReturnType<typeof vi.fn>).mock.calls[0];
expect(opts).toEqual({ now: false });
expect((copies as Array<{ sessionId: string }>).map((c) => c.sessionId)).toEqual(['s-a', 's-b']);
const layer = ghosts()[0];
for (const g of layer.children) {
expect(g.classList.contains('tile--enter-themed')).toBe(false);
expect(g.classList.contains('tile--enter-hold')).toBe(false);
}
// Held 700 ms (the single view never settles here), then released: the
// fallback is the exit's 1200 ms, not the default fade's 450.
vi.advanceTimersByTime(700);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
vi.advanceTimersByTime(1199);
expect(ghosts()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(ghosts()).toHaveLength(0);
});
it('the copy goes on its last tile’s tile-leave-* end, not on a ::before wash', () => {
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 900);
app.openTileGrid(IDS.slice(0, 2));
app.toggleTileGrid();
const layer = ghosts()[0];
const last = layer.lastElementChild!;
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '::before' });
expect(ghosts()).toHaveLength(1);
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '' });
expect(ghosts()).toHaveLength(0);
});
});
+1 -43
View File
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
@@ -153,48 +153,6 @@ describe('the geometry a capture reports back', () => {
});
});
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('reads it from the one cursor query the capture already makes', () => {
// No extra tmux call: every capture is a synchronous exec on the server.
expect(methodBody).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'");
expect(methodBody.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
it('parses the fifth field as historyLines', () => {
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
cursorX: 3,
cursorY: 5,
cols: 120,
rows: 40,
historyLines: 812,
});
// A pane in the alternate screen (fullscreen claude) holds none.
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
});
it('never lets a missing or bad history field void the caret', () => {
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
expect(geometry && 'historyLines' in geometry).toBe(false);
}
});
it('still returns null when the geometry itself is bad', () => {
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
expect(
queryPaneCursor(() => {
throw new Error('no pane');
})
).toBeNull();
});
});
describe('why a capture has to report its height', () => {
it('a snapshot addresses rows the receiving terminal may not have', () => {
// formatPaneSnapshot positions every row absolutely. A terminal shorter
-65
View File
@@ -1,65 +0,0 @@
/**
* @fileoverview showToast() display time and drawer logging: a toast with no explicit
* `duration` uses the notification preference, an explicit `duration` (0 = sticky) still wins,
* and every toast is recorded in the notification drawer.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const windows: JSDOM[] = [];
function loadApp(notificationManager?: unknown) {
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
windows.push(dom);
const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
CodemanApp,
document: dom.window.document,
requestAnimationFrame: (cb: () => void) => cb(),
setTimeout,
clearTimeout,
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.notificationManager = notificationManager;
return app;
}
afterEach(() => {
vi.useRealTimers();
for (const dom of windows.splice(0)) dom.window.close();
});
describe('showToast', () => {
it('uses the configured display time when no duration is given', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 10_000 });
app.showToast('hello');
expect(windows[0].window.document.querySelectorAll('.toast')).toHaveLength(1);
vi.advanceTimersByTime(9_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
vi.advanceTimersByTime(1_500);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('falls back to 3s without a notification manager', () => {
vi.useFakeTimers();
const app = loadApp();
app.showToast('hello');
vi.advanceTimersByTime(3_100);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('lets an explicit duration of 0 stay until dismissed', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 1_000 });
app.showToast('sticky', 'error', { duration: 0 });
vi.advanceTimersByTime(60_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
});
});