mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b66f3510 | ||
|
|
98ba1fd49c | ||
|
|
9df310c30a | ||
|
|
50b8f1d9a0 | ||
|
|
1255e28f6f | ||
|
|
9d12fc7f94 | ||
|
|
5d406c9705 | ||
|
|
a8782b364f | ||
|
|
d8da1bd3ff | ||
|
|
06871eb7e3 | ||
|
|
5d59c1764d | ||
|
|
cfcd9d288b | ||
|
|
9c22114b5a | ||
|
|
98b2124d7e | ||
|
|
bdaec320f5 | ||
|
|
dfe20a3742 | ||
|
|
de5216b83f | ||
|
|
57eefd7aa5 | ||
|
|
2c81bbc08b | ||
|
|
b374121c18 | ||
|
|
b1c4330680 | ||
|
|
47359e4002 | ||
|
|
a8e7d60db4 | ||
|
|
8dc70a5f1d | ||
|
|
4d129086d1 | ||
|
|
566c65c3c9 | ||
|
|
cd7d8c7329 | ||
|
|
c55af9ec39 | ||
|
|
1a54217bfb | ||
|
|
70742d400a | ||
|
|
d5809d1808 |
@@ -1,5 +1,95 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix the connection indicator flashing "Sending 1B…" on every keystroke. The reliable input-delivery layer (1.1.16) marks each keystroke as briefly pending until its ACK arrives a few milliseconds later, which made the indicator flash on every character while typing on a healthy connection. The indicator is now hidden whenever the connection is healthy and only appears for an actual problem (reconnecting/offline), where it still shows the queued byte count so you know buffered input will be sent.
|
||||
|
||||
## 1.1.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile image uploads, reliable input delivery, and gesture window dragging.
|
||||
|
||||
**Mobile image uploads (camera-roll picker / drag-drop / paste).** The "🖼 Image" button now handles real photo batches: up to 20 images per batch uploaded with bounded concurrency and a live "Uploading N/M…" progress toast (with a summary of successes, failures, and whether the 20-cap trimmed the selection). The per-file limit is raised from 10MB to 50MB (`MAX_PASTE_IMAGE_BYTES`, env-overridable via `CODEMAN_MAX_PASTE_IMAGE_BYTES`) so full-resolution phone photos and large screenshots are accepted. Very large images are downscaled to ≤4096px on the longest edge before upload, fixing iOS Safari's ~16.7M-px `<canvas>` limit that previously made huge photos fail to re-encode. Also fixes a latent concurrency bug the batch path exposed where the first parallel uploads to a session raced on creating `.claude-images/` and failed with EEXIST.
|
||||
|
||||
**Reliable, exactly-once input delivery.** A "sent" prompt could be silently lost on a flaky connection (e.g. a train): a half-open WebSocket accepts `ws.send()` without error while discarding the frame, and nothing was queued or resent. Input is now recorded durably (localStorage) with a stable clientId + monotonic per-session sequence before delivery, and only dropped once the server ACKs it — delivered over the WebSocket (acked via `{t:'ia',seq}`) or, when the socket is down, over POST in order. A 2s sweep force-reconnects a half-open socket; pending input survives reconnects and page reloads. The server applies each `(clientId, seq)` at most once (`Session.shouldApplyInput`), so an at-least-once resend can never type the prompt twice. Untagged input (curl/legacy) is unchanged. See `docs/reliable-input-delivery.md`.
|
||||
|
||||
**Gesture beta: drag agent windows.** With the camera hand-tracking overlay, you can now pinch and move the floating subagent and ultracode run/transcript windows. They keep their glowing connector line to the session tab while moving and can travel across a multi-monitor seam.
|
||||
|
||||
## 1.1.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Security: harden all frontend inline `onclick`/`ondblclick` handlers against a stored-XSS double-context bug.
|
||||
|
||||
Many inline handlers interpolated values as `'${escapeHtml(value)}'` — a JavaScript string literal sitting inside an HTML attribute. The browser HTML-decodes the attribute value _before_ parsing the handler source, so `escapeHtml`'s `'` reverts to a literal `'` and a quote-bearing id/name/path/URL breaks out of the JS string into executable code. `escapeHtml` alone is insufficient for this JS-string-within-HTML-attribute context.
|
||||
|
||||
All affected handlers now use `escapeHtml(JSON.stringify(value))`: `JSON.stringify` JS-encodes and quote-wraps the value, then `escapeHtml` handles the HTML-attribute layer, so the value round-trips as a single inert string argument.
|
||||
- ultracode run/agent cards and minimized-tab badges (`ultracode-panel.js`, `ultracode-windows.js`) — PR #132.
|
||||
- Session tabs (click/rename/gear/detach/close), notifications, subagent windows + dropdowns, the agents/tools/log-viewer/image-popup panels, mux-session monitor rows, and case-management buttons (`app.js`, `notification-manager.js`, `subagent-windows.js`, `panels-ui.js`, `session-ui.js`).
|
||||
- Two non-`escapeHtml` variants of the same class: a pre-escaped mux-session id in `panels-ui.js` (`selectSession`/`killMuxSession`) and a fully raw, unescaped `phase.id` in `orchestrator-panel.js` (`orchestratorSkipPhase`/`orchestratorRetryPhase`).
|
||||
|
||||
The most realistic exploitation vector was file paths in the project-insights log-viewer link, since filenames can legally contain a single quote. Purely numeric interpolations and developer-literal handler strings were left unchanged.
|
||||
|
||||
## 1.1.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode (Workflow-tool) floating windows — agent transcripts in-page, and minimize-to-tab.
|
||||
- **Agent transcripts open in-page, connected, instead of a detached browser popup.** Clicking an agent card (in a run window or the dock panel) now opens the agent's live transcript as its own draggable floating window, tied by a connector line to its parent run window (falling back to the run's session tab if that window has since closed) — the same line idiom the run windows use. Re-clicking a card focuses the existing window; closing it removes the window and its line. (Previously this spawned a separate `window.open` browser popup.)
|
||||
- **The window "−" button now minimizes into the originating session tab**, mirroring the subagent-window idiom. The window genie-animates into its tab and is tracked there; the tab shows an `ULTRA` badge whose hover/click dropdown lists each minimized item (🧬 run windows, 📄 agent transcripts). Click an item to restore its floating window, or dismiss it with ×. A run minimized while still active keeps tracking in the background and its badge auto-clears shortly after the run finishes. Both run windows and agent-transcript windows minimize into the same merged badge.
|
||||
- Removed the old collapse-to-header behavior that the "−" button previously triggered (now superseded by minimize-to-tab).
|
||||
|
||||
## 1.1.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the `/compact` button in the extended (full) mobile keyboard accessory bar; only the simple bar drops it. (1.1.12 had removed it from both.)
|
||||
|
||||
## 1.1.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the `/compact` button from the mobile keyboard accessory bar. It had been reintroduced in 1.1.10; this removes the button from both the simple and full accessory-bar layouts (the underlying command handler is left in place as inert plumbing).
|
||||
|
||||
## 1.1.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode (Workflow-tool) run visualization — much better live tracking.
|
||||
|
||||
While a run is in flight, the watcher previously showed empty agent slots ("agent N", 0 tokens, raw `wf_…` id as the title) because the detailed completion JSON only lands when the run finishes. The live path now enriches in-flight runs directly from the on-disk transcript tree:
|
||||
- **Real per-agent stats mid-run** — tokens and tool-call counts are parsed from each `agent-<id>.jsonl` transcript (tool counts match the final accounting exactly; token totals land within ~1% of the completion value), with model and a prompt preview. All mtime-cached (transcripts, journal, and script meta) so idle polls do no extra reads.
|
||||
- **Readable window/run title** — workflow name, summary, and phases are derived from the persisted `workflows/scripts/<name>-<runId>.js` instead of showing the raw run id.
|
||||
- **Agent status colors** — done agents show green, working agents show yellow (this also fixes the run/agent status badges, which referenced undefined `--success`/`--warning` CSS variables and were rendering with no color).
|
||||
- **Connector line** — the floating-window → session-tab line now uses the session-tab accent blue (was purple).
|
||||
- **Click a run to open its floating window** — clicking a workflow in the dock panel opens (or focuses) its floating window with the connector line, in addition to the auto-popped windows.
|
||||
- Agents are ordered by journal launch order; concurrent run-detail fetches are de-duplicated.
|
||||
|
||||
## 1.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile CJK input, iPad keyboard accessory bar, and terminal touch interaction fixes (PRs #130, #131).
|
||||
|
||||
Mobile / CJK (#130):
|
||||
- Restore reliable real-time CJK (e.g. Pinyin) composition in the always-visible textarea, and refocus input when the terminal is tapped.
|
||||
- Stop clearing the textarea during `compositionstart` — some IMEs include existing text in the composition region, and clearing it mid-composition corrupted input.
|
||||
- iPad-specific fixes: `#cjkInput` positioning, paste-dialog placement, and duplicated voice-dictation output.
|
||||
- Split CJK keyboard positioning by device size (phones vs iPad use different keyboard offsets).
|
||||
- iPad accessory-bar styling/positioning: moved the accessory-bar and paste-overlay base styles out of the `max-width:1023px`-gated mobile stylesheet so iPad landscape (≥1024px) renders them correctly.
|
||||
- Raise the toolbar stacking context while the case-settings popover is open so the popover is no longer hidden behind the toolbar.
|
||||
- Restore the `/compact` button to the keyboard accessory bar (with double-tap confirmation, like `/clear`); the paste dialog now submits pasted text on "Send".
|
||||
|
||||
Terminal touch + forced redraw (#131):
|
||||
- Enable terminal touch interaction on all touch devices and show the stop button on touch devices.
|
||||
- Add an 8px tap threshold so micro-drift is treated as a tap, not a scroll, fixing cases where a tap failed to register.
|
||||
- Tap-to-position the cursor via a synthesized mouse report, gated on the live mouse-tracking mode so it never triggers local text selection when tracking is off; let SGR mouse reports through to the PTY even while the CJK input field owns focus.
|
||||
- Suppress the cursor/momentum side effects of a sub-threshold tap so a jittery tap no longer both positions the cursor and starts a momentum fling.
|
||||
- New opt-in, per-device "Redraw Terminal" header button (`showRedrawButton`, default off) that forces an xterm redraw via a resize jitter to clear occasional rendering glitches; the resize path now accepts a `force` flag (threaded through the session, HTTP, and WebSocket resize routes) that guarantees a SIGWINCH/redraw at the current device's size without bypassing multi-client resize arbitration.
|
||||
|
||||
## 1.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.1.9 (must match `package.json`)
|
||||
**Version**: 1.1.17 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -132,7 +132,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -211,18 +211,18 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
~127 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
### API Routes
|
||||
|
||||
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~147 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Reliable input delivery (exactly-once, durable)
|
||||
|
||||
## The bug this fixes
|
||||
|
||||
With local echo on, pressing Enter cleared the overlay and then sent the prompt
|
||||
over the WebSocket **fire-and-forget** (`ws.send({t:'i',d})`). On a flaky link
|
||||
(e.g. a moving train) the socket is frequently *half-open*: `readyState === OPEN`
|
||||
so `ws.send()` does **not** throw, but the underlying TCP is dead, so the frame is
|
||||
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
|
||||
prompt was already wiped, and `navigator.onLine` stays `true` — so a long typed
|
||||
prompt vanished with no trace and no resend.
|
||||
|
||||
## The guarantee
|
||||
|
||||
Every byte of user input is **recorded durably before delivery** and **only
|
||||
dropped once the server ACKs it** — so a half-open socket, a reconnect, or a page
|
||||
reload can never lose input. Redelivery is **exactly-once**: the server applies
|
||||
each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
|
||||
## How it works
|
||||
|
||||
### Client (`app.js`)
|
||||
|
||||
- A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this
|
||||
browser to the server's dedup across reconnects and reloads.
|
||||
- Each input frame gets a **monotonic per-session `seq`**. Frame records
|
||||
(`{seq,data,useMux,ts,tries,sentAt}`) live in `_pendingDeliveries`
|
||||
(`Map<sessionId, record[]>`), persisted (debounced, + flushed on `pagehide`/
|
||||
`visibilitychange`) to `localStorage['codeman:pendingInput']`. The seq counters
|
||||
persist too, so seqs stay monotonic across reloads (never reset — a reset would
|
||||
let the server treat fresh input as an already-applied duplicate).
|
||||
- **Delivery** (`_drainSession`):
|
||||
- **WS path** — when the socket is `OPEN` for the session, send each not-yet-sent
|
||||
record (`sentAt === 0`) in seq order over the single ordered stream. Records
|
||||
stay pending until the server's `{t:'ia',seq}` ACK removes them.
|
||||
- **POST path** — when no WS, POST records in order, awaiting each (the HTTP 2xx
|
||||
*is* the ACK). A 404/410 (session gone) drops the record rather than retry
|
||||
forever.
|
||||
- **Half-open recovery** (`_redeliverSweep`, every 2s): if the active WS session's
|
||||
oldest record is unacked past `_reliableAckTimeoutMs` (4s), the socket is assumed
|
||||
dead — `ws.close()` forces a fast reconnect; `onopen` (`_onWsReady`) resets
|
||||
`sentAt = 0` and re-sends everything pending. Also re-drains background sessions
|
||||
over POST, and fires on SSE-reconnect / `online`.
|
||||
- The connection indicator shows pending count/bytes (`_pendingBytes`).
|
||||
|
||||
### Server
|
||||
|
||||
- **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per
|
||||
`(clientId, seq)`: the first time a seq strictly greater than that client's
|
||||
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
||||
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
||||
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
||||
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
|
||||
client drops it). Untagged frames apply unconditionally (no behavior change).
|
||||
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
||||
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
the client's redelivery of that same seq could re-apply it (a rare duplicate).
|
||||
This is a deliberate trade-off: favor *never losing input* over a rare duplicate
|
||||
across the narrow restart window.
|
||||
|
||||
## Tests
|
||||
|
||||
- `test/reliable-input-dedup.test.ts` — `Session.shouldApplyInput` exactly-once
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.9",
|
||||
"version": "1.1.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.9",
|
||||
"version": "1.1.17",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.9",
|
||||
"version": "1.1.17",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
|
||||
// release over the tab strip to re-dock it (panel goes away, the tab stays).
|
||||
// This is the capability the old OS-window detach lost.
|
||||
// • Agent-window "grab-to-move" — pinch any floating *subagent* or *ultracode*
|
||||
// run/transcript window (the dashboard's own `.subagent-window` /
|
||||
// `.ultracode-window` floats) and move it anywhere. These windows stay owned
|
||||
// by app.js — we only nudge their `style.left/top` and ask app.js to redraw
|
||||
// the glowing connector line back to their session tab (its redraw reads live
|
||||
// rects, so the line tracks without us touching app.js internals). This is the
|
||||
// multi-monitor verb that lets these windows cross the physical monitor seam.
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
@@ -36,12 +43,29 @@ import type { HandState } from '../gesture/types.ts';
|
||||
declare global {
|
||||
interface Window {
|
||||
__codemanGesture?: GestureBridge;
|
||||
/** The Codeman dashboard singleton (app.js, `window.app`). The gesture layer
|
||||
* reaches into it to redraw the floating-window connector lines and bump a
|
||||
* grabbed window's z-order while moving the subagent / ultracode windows.
|
||||
* Loosely typed — only the few members we touch. */
|
||||
app?: {
|
||||
updateConnectionLines?: () => void;
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
* transcript windows). All three carry one of these classes, position via
|
||||
* `style.left/top`, and redraw their connector line from
|
||||
* `window.app.updateConnectionLines()` — so the hand can pick one up and move it
|
||||
* without app.js knowing. (`.ultracode-agent-window` also carries
|
||||
* `.ultracode-window`, so this matches it too.) */
|
||||
const WINDOW_SELECTOR = '.subagent-window, .ultracode-window';
|
||||
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
|
||||
const DOCK_SELECTOR = '.session-tabs';
|
||||
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
|
||||
@@ -93,6 +117,17 @@ type Grab =
|
||||
dy: number;
|
||||
/** Cursor currently over the tab strip → releasing re-docks. */
|
||||
overDock: boolean;
|
||||
}
|
||||
| {
|
||||
/** A dashboard-owned floating agent window (subagent / ultracode) being
|
||||
* moved. We never remove or re-parent it — just reposition + redraw its
|
||||
* connector. The element ref can go stale mid-grab (SSE reconnect tears
|
||||
* ultracode windows down), so every move guards on `el.isConnected`. */
|
||||
kind: 'window';
|
||||
el: HTMLElement;
|
||||
/** Cursor→window-top-left offset at grab, so it doesn't snap. */
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
|
||||
@@ -122,6 +157,8 @@ class GestureBridge {
|
||||
private taps = new Map<string, Tap>();
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
private floats = new Map<string, FloatingPanel>();
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
private connectorRedrawScheduled = false;
|
||||
|
||||
constructor() {
|
||||
injectStyles();
|
||||
@@ -187,7 +224,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -242,6 +279,22 @@ class GestureBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// A dashboard-owned floating agent window (subagent / ultracode run or
|
||||
// transcript) → pick it up and move it. Priority below cg-float panels
|
||||
// (which sit far above), above tabs/buttons. We grab anywhere on the window
|
||||
// (not just its titlebar) since the hand is choosing the whole window.
|
||||
const win = this.hitClosest(x, y, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
// Match app.js's own drag: drop any bottom-anchor so left/top take effect.
|
||||
win.style.bottom = 'auto';
|
||||
win.classList.add('cg-win-grabbed');
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: 'window', el: win, dx: x - rect.left, dy: y - rect.top });
|
||||
this.status.textContent = 'moving window';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -292,12 +345,16 @@ class GestureBridge {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.moveWindow(grab.el, x - grab.dx, y - grab.dy);
|
||||
return;
|
||||
}
|
||||
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +376,23 @@ class GestureBridge {
|
||||
else this.flash('placed');
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
// Clear the coalescer so the final placement always redraws, even if a
|
||||
// mid-drag rAF was throttled (tab briefly backgrounded) and left it latched.
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
// Persist subagent-window positions like app.js's own drag end does
|
||||
// (a no-op for ultracode windows, which aren't position-persisted).
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.flash('placed window');
|
||||
return;
|
||||
}
|
||||
// Release over the same button → fire its real click handler.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
@@ -373,6 +447,59 @@ class GestureBridge {
|
||||
float.el.style.top = `${t}px`;
|
||||
}
|
||||
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
private moveWindow(el: HTMLElement, left: number, top: number): void {
|
||||
if (!el.isConnected) return;
|
||||
const w = el.offsetWidth || 380;
|
||||
const h = el.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4));
|
||||
const t = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4));
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
private redrawWindowConnectors(): void {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
/* app.js may not expose it (standalone playground) */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
private bringWindowToFront(el: HTMLElement): void {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el.classList.contains('ultracode-window')) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
/* cosmetic only */
|
||||
}
|
||||
}
|
||||
|
||||
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
|
||||
ghost.style.left = `${x}px`;
|
||||
ghost.style.top = `${y}px`;
|
||||
@@ -385,17 +512,19 @@ class GestureBridge {
|
||||
if (grab.kind === 'tab') {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
} else {
|
||||
} else if (grab.kind === 'panel') {
|
||||
grab.panel.el.style.pointerEvents = '';
|
||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||
} else {
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.clear();
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed'));
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -491,6 +620,10 @@ function injectStyles(): void {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -96,3 +96,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
* which is enough to extract metadata from the first few JSONL lines.
|
||||
*/
|
||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
|
||||
// ============================================================================
|
||||
// Paste-Image Upload Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single image uploaded via POST
|
||||
* /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths
|
||||
* send one file per request (the client uploads up to MAX_PASTE_IMAGES of them
|
||||
* per batch), so this caps each individual file, not the batch. Generous enough
|
||||
* for full-resolution phone photos and large screenshots; the client downscales
|
||||
* very large images before upload, so legitimate uploads land well under this.
|
||||
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
+39
-2
@@ -2213,6 +2213,42 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-client highest-applied input sequence, for exactly-once input delivery.
|
||||
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
|
||||
* long-lived session can't grow it without limit (insertion order = MRU, so
|
||||
* eviction drops the least-recently-active client).
|
||||
*/
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
|
||||
|
||||
/**
|
||||
* Decide whether an input frame should be applied to the PTY or skipped as a
|
||||
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
|
||||
* first time a seq strictly greater than the client's last-applied is seen.
|
||||
* A redelivery of an already-applied seq (the client never got our ACK and
|
||||
* resent) returns false. Callers should ACK regardless — a duplicate is, from
|
||||
* the client's view, "delivered" — and only `write()` the PTY when this is
|
||||
* true. Relies on the client delivering one client's frames in seq order over
|
||||
* a single ordered stream, so `seq <= last` ⇒ already applied.
|
||||
*
|
||||
* Without this, the client's at-least-once redelivery (needed because a
|
||||
* half-open socket silently drops frames with no error) would type a prompt
|
||||
* twice whenever an ACK is lost after the write landed.
|
||||
*/
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
// Re-insert to move this client to the MRU end for fair eviction.
|
||||
if (last !== undefined) this._appliedInputSeq.delete(clientId);
|
||||
this._appliedInputSeq.set(clientId, seq);
|
||||
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
|
||||
const oldest = this._appliedInputSeq.keys().next().value;
|
||||
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||
*
|
||||
@@ -2321,7 +2357,7 @@ export class Session extends EventEmitter {
|
||||
* @param cols - Number of columns (width in characters)
|
||||
* @param rows - Number of rows (height in lines)
|
||||
*/
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
|
||||
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
||||
if (options.viewportType === 'desktop') {
|
||||
this._lastDesktopDims = { cols, rows };
|
||||
@@ -2334,7 +2370,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
}
|
||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
|
||||
+315
-89
@@ -468,13 +468,28 @@ class CodemanApp {
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
// Offline input queue
|
||||
this._inputQueue = new Map(); // Map<sessionId, string>
|
||||
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
||||
// Reliable, durable input delivery (replaces the old best-effort queue).
|
||||
// Every input byte is recorded with a stable clientId + a monotonic
|
||||
// per-session seq, persisted to localStorage, and only dropped once the
|
||||
// server ACKs that exact seq — so a half-open socket silently dropping a
|
||||
// frame, a reconnect, or a page reload can never lose a typed prompt.
|
||||
// Exactly-once: the server applies each (clientId, seq) at most once.
|
||||
this._connectionStatus = 'connected';
|
||||
|
||||
// Sequential input send chain — ensures keystroke ordering across async fetches
|
||||
this._inputSendChain = Promise.resolve();
|
||||
this._clientId = '';
|
||||
this._seqCounters = new Map(); // sessionId -> last issued seq
|
||||
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
this._persistReliableTimer = null;
|
||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||
this._loadReliableState();
|
||||
this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000);
|
||||
// Flush the durable queue synchronously when the page is hidden/closed —
|
||||
// debounced persistence may have a pending write we mustn't lose on reload.
|
||||
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||
});
|
||||
|
||||
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||
@@ -1931,8 +1946,10 @@ class CodemanApp {
|
||||
setConnectionStatus(status) {
|
||||
this._connectionStatus = status;
|
||||
this._updateConnectionIndicator();
|
||||
if (status === 'connected' && this._inputQueue.size > 0) {
|
||||
this._drainInputQueues();
|
||||
if (status === 'connected') {
|
||||
// Reconnected (SSE) — push any durably-queued input out immediately
|
||||
// instead of waiting for the next 2s sweep.
|
||||
this._redeliverSweep();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1965,6 +1982,9 @@ class CodemanApp {
|
||||
// went over HTTP, which never claims (see ws-routes sizingToken).
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
this._startMobileResizeRetry(sessionId);
|
||||
// Flush any durably-queued input over the fresh socket (covers frames a
|
||||
// prior half-open socket silently dropped, and input typed while offline).
|
||||
this._onWsReady(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1979,6 +1999,10 @@ class CodemanApp {
|
||||
this._onSessionClearTerminal({ id: sessionId });
|
||||
} else if (msg.t === 'r') {
|
||||
this._onSessionNeedsRefresh({ id: sessionId });
|
||||
} else if (msg.t === 'ia') {
|
||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||
// the durable queue so it can never be re-delivered/lost.
|
||||
this._onWsInputAck(msg.seq);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
@@ -2062,79 +2086,270 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to server without blocking the keystroke flush cycle.
|
||||
* Uses a sequential promise chain to preserve character ordering
|
||||
* across concurrent async fetches.
|
||||
* Public input entry point — name/signature kept for all call sites.
|
||||
* Records the input durably, then delivers it reliably (exactly-once). Never
|
||||
* blocks the keystroke flush; never silently drops on a half-open socket.
|
||||
* @param {string} sessionId
|
||||
* @param {string} input
|
||||
* @param {{useMux?: boolean}} [opts] - useMux only affects the POST fallback.
|
||||
*/
|
||||
_sendInputAsync(sessionId, input) {
|
||||
// Queue immediately if offline
|
||||
if (!this.isOnline || this._connectionStatus === 'disconnected') {
|
||||
this._enqueueInput(sessionId, input);
|
||||
_sendInputAsync(sessionId, input, opts) {
|
||||
if (!sessionId || !input) return;
|
||||
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||
}
|
||||
|
||||
/** Record one input frame and kick delivery. The record lives until ACKed. */
|
||||
_reliableSend(sessionId, data, useMux) {
|
||||
const seq = this._nextSeq(sessionId);
|
||||
const rec = { seq, data, useMux: !!useMux, ts: Date.now(), tries: 0, sentAt: 0 };
|
||||
let list = this._pendingDeliveries.get(sessionId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this._pendingDeliveries.set(sessionId, list);
|
||||
}
|
||||
list.push(rec);
|
||||
this._persistReliableState();
|
||||
this._updateConnectionIndicator();
|
||||
this._drainSession(sessionId);
|
||||
}
|
||||
|
||||
_nextSeq(sessionId) {
|
||||
const next = (this._seqCounters.get(sessionId) || 0) + 1;
|
||||
this._seqCounters.set(sessionId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Deliver all unacked records for a session, in seq order. */
|
||||
_drainSession(sessionId) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (!list || list.length === 0) return;
|
||||
|
||||
// Fast path: WebSocket open for this session — fire each not-yet-sent record
|
||||
// over the single ordered stream. They stay pending until the server ACKs
|
||||
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
for (const rec of list) {
|
||||
if (rec.sentAt !== 0) continue;
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||
rec.sentAt = Date.now();
|
||||
rec.tries++;
|
||||
} catch {
|
||||
break; // socket died mid-send — reconnect/POST drainer retries
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path: WebSocket — fire-and-forget, inherently ordered (single TCP stream).
|
||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||
// Slow path: no WS — POST records in order, awaiting each (the HTTP 2xx is
|
||||
// the ACK). Serialized per session so seq order survives async fetches.
|
||||
if (this._postDraining.has(sessionId)) return;
|
||||
this._postDraining.add(sessionId);
|
||||
(async () => {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
this.clearPendingHooks(sessionId);
|
||||
return;
|
||||
} catch {
|
||||
// WS send failed — fall through to HTTP POST
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: HTTP POST — chain on dispatch only, don't wait for response.
|
||||
// The server handles writeViaMux as fire-and-forget anyway.
|
||||
this._inputSendChain = this._inputSendChain.then(() => {
|
||||
const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input }),
|
||||
keepalive: input.length < 65536,
|
||||
});
|
||||
|
||||
// Handle response asynchronously — don't block next keystroke on response
|
||||
fetchPromise.then(resp => {
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
} else {
|
||||
this.clearPendingHooks(sessionId);
|
||||
for (;;) {
|
||||
const cur = this._pendingDeliveries.get(sessionId);
|
||||
if (!cur || cur.length === 0) break;
|
||||
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
break;
|
||||
}
|
||||
const rec = cur[0];
|
||||
rec.tries++;
|
||||
rec.sentAt = Date.now();
|
||||
let resp = null;
|
||||
try {
|
||||
const body = { input: rec.data, seq: rec.seq, clientId: this._clientId };
|
||||
if (rec.useMux) body.useMux = true;
|
||||
resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
keepalive: rec.data.length < 65536,
|
||||
});
|
||||
} catch {
|
||||
resp = null;
|
||||
}
|
||||
if (resp && resp.ok) {
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
} else if (resp && (resp.status === 404 || resp.status === 410)) {
|
||||
// Session no longer exists — the input can never land. Drop it
|
||||
// rather than retry forever (not a "lost" prompt: the target is gone).
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
} else {
|
||||
break; // offline / 5xx — leave queued; sweep + reconnect retry later
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
this._enqueueInput(sessionId, input);
|
||||
});
|
||||
|
||||
// Return immediately after fetch is dispatched (don't await response)
|
||||
});
|
||||
} finally {
|
||||
this._postDraining.delete(sessionId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
_enqueueInput(sessionId, input) {
|
||||
const existing = this._inputQueue.get(sessionId) || '';
|
||||
let combined = existing + input;
|
||||
// Enforce 64KB cap — keep most recent keystrokes
|
||||
if (combined.length > this._inputQueueMaxBytes) {
|
||||
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
|
||||
}
|
||||
this._inputQueue.set(sessionId, combined);
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
|
||||
async _drainInputQueues() {
|
||||
if (this._inputQueue.size === 0) return;
|
||||
// Snapshot and clear
|
||||
const queued = new Map(this._inputQueue);
|
||||
this._inputQueue.clear();
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
for (const [sessionId, input] of queued) {
|
||||
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
|
||||
if (!resp?.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
/** Drop an ACKed record (by exact seq) and persist. */
|
||||
_ackDelivery(sessionId, seq) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (list) {
|
||||
const idx = list.findIndex((r) => r.seq === seq);
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
if (list.length === 0) this._pendingDeliveries.delete(sessionId);
|
||||
// When nothing is left pending anywhere, flush durable state immediately
|
||||
// (not debounced) so a reload in the next 250ms can't redeliver an
|
||||
// already-delivered frame — otherwise localStorage briefly still shows it.
|
||||
if (this._pendingDeliveries.size === 0) this._persistReliableNow();
|
||||
else this._persistReliableState();
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
}
|
||||
this._updateConnectionIndicator();
|
||||
this.clearPendingHooks?.(sessionId);
|
||||
}
|
||||
|
||||
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
|
||||
_onWsInputAck(seq) {
|
||||
if (this._wsSessionId && Number.isInteger(seq)) this._ackDelivery(this._wsSessionId, seq);
|
||||
}
|
||||
|
||||
/** Called from ws.onopen — flush everything pending over the fresh socket. */
|
||||
_onWsReady(sessionId) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (list) for (const r of list) r.sentAt = 0; // fresh socket ⇒ re-send all
|
||||
this._drainSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic retry. For the active WS session, an oldest frame unacked past the
|
||||
* timeout means the socket is (half-)dead — close it to force a fast reconnect
|
||||
* (onclose → reconnect → onopen → _onWsReady re-sends). Other sessions just
|
||||
* (re)drain over POST.
|
||||
*/
|
||||
_redeliverSweep() {
|
||||
if (this._pendingDeliveries.size === 0) return;
|
||||
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (!list || list.length === 0) continue;
|
||||
const isActiveWs =
|
||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
||||
if (isActiveWs) {
|
||||
const oldest = list[0];
|
||||
if (oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs) {
|
||||
try {
|
||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this._drainSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Total bytes/count still awaiting ACK across all sessions (for the indicator). */
|
||||
_pendingBytes() {
|
||||
let bytes = 0;
|
||||
let count = 0;
|
||||
for (const list of this._pendingDeliveries.values()) {
|
||||
for (const r of list) {
|
||||
bytes += r.data.length;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return { bytes, count };
|
||||
}
|
||||
|
||||
// ---- durable persistence (localStorage; quota- and disabled-storage-safe) --
|
||||
|
||||
_loadReliableState() {
|
||||
// Stable client identity for server-side dedup across reconnects/reloads.
|
||||
try {
|
||||
this._clientId = localStorage.getItem('codeman:clientId') || '';
|
||||
} catch {
|
||||
this._clientId = '';
|
||||
}
|
||||
if (!this._clientId) {
|
||||
this._clientId = 'c-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
|
||||
try {
|
||||
localStorage.setItem('codeman:clientId', this._clientId);
|
||||
} catch {
|
||||
/* storage disabled — dedup degrades to per-load, still no loss */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem('codeman:pendingInput');
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
if (saved && saved.seqs) {
|
||||
for (const [s, n] of Object.entries(saved.seqs)) {
|
||||
if (Number.isFinite(n)) this._seqCounters.set(s, n);
|
||||
}
|
||||
}
|
||||
if (saved && saved.pending) {
|
||||
for (const [s, recs] of Object.entries(saved.pending)) {
|
||||
if (Array.isArray(recs) && recs.length) {
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
this._pendingDeliveries.set(
|
||||
s,
|
||||
recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* corrupt/parse error — start clean rather than throw */
|
||||
}
|
||||
}
|
||||
|
||||
_persistReliableState() {
|
||||
// Debounced — typing without local echo calls this per keystroke.
|
||||
if (this._persistReliableTimer) return;
|
||||
this._persistReliableTimer = setTimeout(() => {
|
||||
this._persistReliableTimer = null;
|
||||
this._persistReliableNow();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
_persistReliableNow() {
|
||||
if (this._persistReliableTimer) {
|
||||
clearTimeout(this._persistReliableTimer);
|
||||
this._persistReliableTimer = null;
|
||||
}
|
||||
try {
|
||||
const seqs = {};
|
||||
for (const [s, n] of this._seqCounters) seqs[s] = n;
|
||||
const pending = {};
|
||||
let bytes = 0;
|
||||
for (const [s, list] of this._pendingDeliveries) {
|
||||
if (!list.length) continue;
|
||||
pending[s] = list.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: r.useMux,
|
||||
ts: r.ts,
|
||||
tries: r.tries,
|
||||
}));
|
||||
for (const r of list) bytes += r.data.length;
|
||||
}
|
||||
// Bound the persisted backlog. On extreme overflow keep the seq counters
|
||||
// (so future input stays monotonic and dedup-safe) but skip the payloads —
|
||||
// the in-memory queue still delivers; only cross-reload durability is lost.
|
||||
const payload =
|
||||
bytes > this._reliableMaxBytes ? { seqs } : { seqs, pending };
|
||||
localStorage.setItem('codeman:pendingInput', JSON.stringify(payload));
|
||||
} catch {
|
||||
/* QuotaExceeded or disabled storage — in-memory delivery is unaffected */
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
@@ -2143,28 +2358,27 @@ class CodemanApp {
|
||||
const text = this.$('connectionText');
|
||||
if (!indicator || !dot || !text) return;
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const v of this._inputQueue.values()) totalBytes += v.length;
|
||||
|
||||
const status = this._connectionStatus;
|
||||
const hasQueue = totalBytes > 0;
|
||||
|
||||
// Connected with empty queue — hide
|
||||
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
|
||||
// While the connection is healthy, never surface the input queue. With the
|
||||
// reliable-delivery layer every keystroke is briefly "pending" until its ACK
|
||||
// lands a few ms later — showing that flashed "Sending 1B…" on every single
|
||||
// character. The indicator is only meaningful for an actual connection
|
||||
// problem (reconnecting / offline), where the queued byte count reassures
|
||||
// the user their typing is safely buffered and will be sent.
|
||||
if (status === 'connected' || status === 'connecting') {
|
||||
indicator.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { bytes: totalBytes, count } = this._pendingBytes();
|
||||
const hasQueue = count > 0;
|
||||
indicator.style.display = 'flex';
|
||||
dot.className = 'connection-dot';
|
||||
|
||||
const formatBytes = (b) => b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`;
|
||||
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
|
||||
|
||||
if (status === 'connected' && hasQueue) {
|
||||
// Draining
|
||||
dot.classList.add('draining');
|
||||
text.textContent = `Sending ${formatBytes(totalBytes)}...`;
|
||||
} else if (status === 'reconnecting') {
|
||||
if (status === 'reconnecting') {
|
||||
dot.classList.add('reconnecting');
|
||||
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
||||
} else {
|
||||
@@ -2179,6 +2393,8 @@ class CodemanApp {
|
||||
this.isOnline = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connectSSE();
|
||||
// Network came back — drain durably-queued input right away.
|
||||
this._redeliverSweep();
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
@@ -2206,7 +2422,6 @@ class CodemanApp {
|
||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
|
||||
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
|
||||
}
|
||||
@@ -2774,12 +2989,16 @@ class CodemanApp {
|
||||
const minimizedCount = minimizedAgents?.size || 0;
|
||||
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
||||
|
||||
// Ultracode runs + agent transcripts minimized to this tab (ultracode-windows.js
|
||||
// renders one merged ULTRA badge; returns '' when nothing is minimized).
|
||||
const ultracodeBadge = this.renderUltracodeTabBadge ? this.renderUltracodeTabBadge(id) : '';
|
||||
|
||||
// Show folder name if session has a custom name AND tall tabs setting is enabled
|
||||
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -2793,9 +3012,10 @@ class CodemanApp {
|
||||
</span>
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
${ultracodeBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
@@ -3682,7 +3902,13 @@ class CodemanApp {
|
||||
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
this._inputQueue.delete(sessionId);
|
||||
// Drop any durably-queued input for a session that's actually gone (deleted/
|
||||
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
||||
// real session removal, never on a tab switch.
|
||||
this._pendingDeliveries?.delete(sessionId);
|
||||
this._seqCounters?.delete(sessionId);
|
||||
this._postDraining?.delete(sessionId);
|
||||
this._persistReliableState();
|
||||
this.ralphStates.delete(sessionId);
|
||||
this.ralphClosedSessions.delete(sessionId);
|
||||
this.projectInsights.delete(sessionId);
|
||||
|
||||
@@ -4449,6 +4449,7 @@ var GestureController = class {
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
var CLICK_SELECTOR = "#runBtn, .btn-shell";
|
||||
var Z2 = 2147483e3;
|
||||
@@ -4476,6 +4477,8 @@ var GestureBridge = class {
|
||||
__publicField(this, "taps", /* @__PURE__ */ new Map());
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
__publicField(this, "floats", /* @__PURE__ */ new Map());
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
__publicField(this, "connectorRedrawScheduled", false);
|
||||
injectStyles();
|
||||
this.surface = el("div", "cg-surface");
|
||||
this.canvas = el("canvas", "cg-canvas");
|
||||
@@ -4530,7 +4533,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4575,6 +4578,16 @@ var GestureBridge = class {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const win = this.hitClosest(x2, y2, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
win.style.bottom = "auto";
|
||||
win.classList.add("cg-win-grabbed");
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: "window", el: win, dx: x2 - rect.left, dy: y2 - rect.top });
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4620,11 +4633,15 @@ var GestureBridge = class {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.moveWindow(grab.el, x2 - grab.dx, y2 - grab.dy);
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
@@ -4645,6 +4662,18 @@ var GestureBridge = class {
|
||||
else this.flash("placed");
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
}
|
||||
this.flash("placed window");
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
this.taps.delete(hand);
|
||||
@@ -4694,6 +4723,54 @@ var GestureBridge = class {
|
||||
float.el.style.left = `${l}px`;
|
||||
float.el.style.top = `${t2}px`;
|
||||
}
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
moveWindow(el2, left, top) {
|
||||
if (!el2.isConnected) return;
|
||||
const w2 = el2.offsetWidth || 380;
|
||||
const h2 = el2.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w2 - 4));
|
||||
const t2 = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h2 - 4));
|
||||
el2.style.left = `${l}px`;
|
||||
el2.style.top = `${t2}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
redrawWindowConnectors() {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
bringWindowToFront(el2) {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el2.classList.contains("ultracode-window")) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
positionGhost(ghost, x2, y2) {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
@@ -4703,15 +4780,17 @@ var GestureBridge = class {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
} else {
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
} else {
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4797,6 +4876,10 @@ function injectStyles() {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
async _uploadAndInsertImages(files) {
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
_maxBatchImages: 20,
|
||||
// How many uploads to run concurrently. Small enough that decoding several
|
||||
// large images through <canvas> at once won't OOM a phone, large enough that
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
||||
// but fails its magic-byte check ("bytes do not match declared type").
|
||||
// Decoding through the browser and re-encoding guarantees the bytes
|
||||
// match the extension we send.
|
||||
const normalized = await this._normalizeImageForUpload(file);
|
||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
if (files.length > this._maxBatchImages) {
|
||||
files = files.slice(0, this._maxBatchImages);
|
||||
capped = true;
|
||||
}
|
||||
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG (and downscale very large images)
|
||||
// before upload. Galleries on some phones (notably Android/MIUI) hand
|
||||
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
|
||||
// passes the server's extension allowlist but fails its magic-byte
|
||||
// check. Decoding through the browser and re-encoding guarantees the
|
||||
// bytes match the extension we send — and shrinks huge photos so they
|
||||
// fit the upload limit and iOS's <canvas> area cap.
|
||||
const normalized = await this._normalizeImageForUpload(files[i]);
|
||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn('Image upload failed:', err);
|
||||
results[i] = null;
|
||||
} finally {
|
||||
done++;
|
||||
if (done < total) progress();
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
const pathStr = paths.join(' ');
|
||||
await this.sendInput(pathStr);
|
||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
||||
}
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const height = img.naturalHeight;
|
||||
if (!width || !height) return file;
|
||||
|
||||
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
|
||||
// render a <canvas> larger than ~16.7M px (it returns a blank/null
|
||||
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
|
||||
// to the original — which then trips the server's magic-byte check for
|
||||
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
|
||||
// under the size limit. Cap the longest edge so area stays safely below
|
||||
// the canvas limit while still uploading a large, high-quality image.
|
||||
const MAX_EDGE = 4096;
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
||||
const w = Math.max(1, Math.round(width * scale));
|
||||
const h = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
@@ -1058,6 +1059,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowRedrawButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
|
||||
+123
-123
@@ -14,13 +14,21 @@
|
||||
* This means compositionstart fires even for English text, and compositionend
|
||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||
*
|
||||
* We use InputEvent.inputType to distinguish:
|
||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
||||
* During composition, all input events are ignored — only compositionend
|
||||
* triggers a flush (CJK candidate selection).
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
* ## iOS dictation challenge (WebKit Bug 261764)
|
||||
*
|
||||
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
|
||||
* as bare input events with isComposing === false. Dictation refinement is
|
||||
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
|
||||
* all within a few ms. Flushing on every input event would send irrevocable
|
||||
* provisional text to the PTY, causing duplication when the IME replaces it.
|
||||
*
|
||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||
* Keyboard typing of single printable characters still goes through the
|
||||
* keydown handler (immediate, no debounce).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
@@ -41,16 +49,30 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _terminalContainer = null;
|
||||
let _xtermTextarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
let _flushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
const _listeners = {};
|
||||
|
||||
// Zero-width space: always present in textarea so Android backspace has
|
||||
// something to delete, triggering the `input` event we need to detect it.
|
||||
const PHANTOM = '\u200B';
|
||||
const PHANTOM = '';
|
||||
|
||||
// Two-tier debounce for non-composition input:
|
||||
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||
// composition events even for keyboard CJK typing)
|
||||
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
|
||||
// refinement cycles without composition events — WebKit Bug 261764)
|
||||
//
|
||||
// Dictation is detected by deleteContentBackward on non-empty text or
|
||||
// insertReplacementText — signals that the IME is rewriting provisional
|
||||
// text. Once detected, dictation mode persists for 3s (covers multi-word
|
||||
// dictation with natural pauses between words).
|
||||
const DEBOUNCE_KEYBOARD_MS = 150;
|
||||
const DEBOUNCE_DICTATION_MS = 1500;
|
||||
const DICTATION_DECAY_MS = 3000;
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -66,35 +88,15 @@ const CjkInput = (() => {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
/** Strip phantom characters from a string */
|
||||
function _strip(str) {
|
||||
return str.replace(/\u200B/g, '');
|
||||
return str.replace(//g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
function _isMobileComposer() {
|
||||
return !!(
|
||||
_textarea &&
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
_textarea.classList.contains('cjk-input-visible')
|
||||
);
|
||||
}
|
||||
|
||||
function _resetInput() {
|
||||
if (_isMobileComposer()) {
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_resetToPhantom();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
@@ -108,61 +110,77 @@ const CjkInput = (() => {
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
/** Cancel any pending debounced flush */
|
||||
function _cancelDebouncedFlush() {
|
||||
if (_flushTimer) {
|
||||
clearTimeout(_flushTimer);
|
||||
_flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark that dictation rewriting is in progress */
|
||||
function _enterDictationMode() {
|
||||
_dictationActive = true;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationDecayTimer = setTimeout(() => {
|
||||
_dictationActive = false;
|
||||
_dictationDecayTimer = null;
|
||||
}, DICTATION_DECAY_MS);
|
||||
}
|
||||
|
||||
/** Schedule a flush after input settles */
|
||||
function _debouncedFlush() {
|
||||
_cancelDebouncedFlush();
|
||||
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
|
||||
_flushTimer = setTimeout(() => {
|
||||
_flushTimer = null;
|
||||
_flush();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_flushTimer = null;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
_terminalContainer = document.getElementById('terminalContainer');
|
||||
|
||||
// Seed the phantom character for the hidden/immediate CJK path.
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
if (_isMobileComposer() && _textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
return;
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
if (!_textarea.classList.contains('cjk-input-visible')) {
|
||||
window.cjkActive = false;
|
||||
}
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
|
||||
// Reset composing state — some IMEs fire compositionstart without a
|
||||
// matching compositionend, leaving _composing stuck true and blocking
|
||||
// all subsequent input events.
|
||||
_composing = false;
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
_listeners.xtermFocusRedirect = () => {
|
||||
if (!_isMobileComposer()) return;
|
||||
_textarea.focus();
|
||||
};
|
||||
if (_terminalContainer) {
|
||||
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
|
||||
if (_xtermTextarea) {
|
||||
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition tracking ──
|
||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value === PHANTOM) _textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
}
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
if (_isMobileComposer()) return;
|
||||
_cancelDebouncedFlush();
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
@@ -172,56 +190,40 @@ const CjkInput = (() => {
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
// Enter: flush accumulated text (or bare Enter if empty).
|
||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
||||
// the IME from also handling Enter (which could double-send or do nothing).
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_cancelDebouncedFlush();
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_resetInput();
|
||||
_cancelDebouncedFlush();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
if (_isMobileComposer()) {
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME).
|
||||
// Also check isComposing/keyCode 229 — the first keydown of a CJK
|
||||
// sequence arrives BEFORE compositionstart, so _composing is still false.
|
||||
if (_composing || e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
@@ -236,62 +238,62 @@ const CjkInput = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
// Single printable character: send immediately to PTY.
|
||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||
// still enters the textarea and fires an input event — _keydownSentAt
|
||||
// tells the input handler to skip that echo.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||
|
||||
// ── Input event: the primary path for Android virtual keyboards ──
|
||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value.includes(PHANTOM)) {
|
||||
_textarea.value = _strip(_textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
// press/repeat fires an input event that we can catch here.
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_composing) return;
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_cancelDebouncedFlush();
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// User is editing their own text in the textarea — let it be.
|
||||
// Ensure phantom is still present for the NEXT backspace.
|
||||
// Delete on non-empty text outside composition = dictation rewrite.
|
||||
// The IME is revising provisional text — switch to long debounce.
|
||||
_enterDictationMode();
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
_debouncedFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_composing) {
|
||||
// insertText during composition = IME committed final text
|
||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
||||
// Flush immediately — this text won't change.
|
||||
if (e.inputType === 'insertText') {
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
||||
// English prediction). Wait for compositionend to flush.
|
||||
// insertReplacementText = dictation/autocorrect refinement
|
||||
if (e.inputType === 'insertReplacementText') {
|
||||
_enterDictationMode();
|
||||
_debouncedFlush();
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — just clear the
|
||||
// textarea echo that the IME inserted despite preventDefault.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
// If dictation mode was detected (delete/replacement events seen
|
||||
// recently), use long debounce. Otherwise short debounce for keyboard.
|
||||
_debouncedFlush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
@@ -300,18 +302,16 @@ const CjkInput = (() => {
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationActive = false;
|
||||
if (_textarea) {
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
|
||||
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_terminalContainer = null;
|
||||
_xtermTextarea = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
@@ -100,6 +100,7 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -129,7 +130,7 @@ const KeyboardAccessoryBar = {
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
if (refocusActions.has(action) ||
|
||||
(action === 'clear' && this._confirmAction)) {
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
@@ -192,11 +193,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand('/clear');
|
||||
this.sendCommand(cmd);
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
@@ -300,7 +302,10 @@ const KeyboardAccessoryBar = {
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) app.sendInput(text);
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
|
||||
@@ -291,57 +291,60 @@ const KeyboardHandler = {
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
|
||||
// Only adjust on mobile
|
||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
||||
if (!MobileDetection.isTouchDevice()) {
|
||||
this.resetLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
// Move toolbar and accessory bar above keyboard.
|
||||
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
|
||||
// the bars are naturally positioned via their CSS bottom values —
|
||||
// just clear the transforms. Never dismiss keyboard state here;
|
||||
// that's handleViewportResize's job.
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
||||
// the visual viewport; adding its height here creates a large blank area
|
||||
// above the mobile toolbar on iPhone.
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
|
||||
if (isSmallMedium) {
|
||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||
// via CSS. Use translateY to lift them above the keyboard.
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
}
|
||||
} else if (keyboardHeight > 0) {
|
||||
// iPad: use direct bottom positioning (translateY unreliable —
|
||||
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// CJK textarea positioning (always position:fixed on touch devices).
|
||||
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
|
||||
if (isSmallMedium) {
|
||||
// Phones: use translateY like toolbar/accessory bar.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
cjkInput.style.bottom = '';
|
||||
} else {
|
||||
// iPad: direct bottom = keyboard + accessory bar height.
|
||||
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
|
||||
cjkInput.style.transform = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -360,9 +363,11 @@ const KeyboardHandler = {
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
accessoryBar.style.bottom = '';
|
||||
}
|
||||
if (cjkInput) {
|
||||
cjkInput.style.transform = '';
|
||||
cjkInput.style.bottom = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
|
||||
+4
-168
@@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Paste overlay for iOS clipboard access */
|
||||
.paste-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Image attach button — left-aligned, accent outline */
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.paste-new {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-send {
|
||||
background: var(--accent-color, #7aa2f7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Paste overlay styles extracted to universal section below (line ~2293+) */
|
||||
|
||||
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
|
||||
.toolbar-select {
|
||||
@@ -2177,95 +2099,9 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Keyboard Accessory Bar — all mobile/tablet sizes
|
||||
Visual styles extracted from phone breakpoint so they apply on iPad too.
|
||||
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
|
||||
============================================================================ */
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 80px;
|
||||
padding: 10px 8px;
|
||||
background: #334d6e;
|
||||
border-color: rgba(100, 150, 200, 0.4);
|
||||
color: #c0d4e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3d5f85;
|
||||
}
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
Phone-specific overrides remain in @media (max-width: 430px) above. */
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
|
||||
@@ -273,7 +273,7 @@ class NotificationManager {
|
||||
const readClass = n.read ? '' : ' unread';
|
||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
|
||||
<div class="notif-item-header">
|
||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||
|
||||
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let actions = '';
|
||||
if (orchState === 'executing' || orchState === 'failed') {
|
||||
if (phase.status === 'pending') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
|
||||
}
|
||||
if (phase.status === 'failed') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-17
@@ -753,8 +753,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||
html.push(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
|
||||
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
|
||||
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
title="Double-click to open tracking window">
|
||||
<div class="subagent-header">
|
||||
${agentIcon}
|
||||
@@ -762,8 +762,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
${teammateBadge}
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${hasWindow ? '✕' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -810,7 +810,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="name">${escapeHtml(a.tool)}</span>
|
||||
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
@@ -859,7 +859,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
|
||||
View Full Transcript
|
||||
</button>
|
||||
</div>
|
||||
@@ -1195,7 +1195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
parentDiv.dataset.parentSession = parentSessionId;
|
||||
parentDiv.innerHTML = `
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
|
||||
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', parentDiv);
|
||||
}
|
||||
@@ -1687,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status running">terminal</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
|
||||
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
||||
@@ -2200,7 +2200,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const fileName = path.split('/').pop();
|
||||
html.push(`
|
||||
<span class="project-insight-filepath"
|
||||
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
|
||||
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
|
||||
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
||||
`);
|
||||
}
|
||||
@@ -3099,7 +3099,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status streaming">streaming</span>
|
||||
</div>
|
||||
<div class="log-viewer-window-actions">
|
||||
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
|
||||
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||||
@@ -3275,14 +3275,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="size-badge">${sizeKB} KB</span>
|
||||
</div>
|
||||
<div class="image-popup-actions">
|
||||
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
|
||||
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-popup-body">
|
||||
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
||||
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
|
||||
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3505,9 +3505,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||
}
|
||||
|
||||
const sid = escapeHtml(muxSession.sessionId);
|
||||
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
|
||||
html += `
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
|
||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
@@ -3520,7 +3520,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3563,7 +3563,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1385,11 +1385,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1484,14 +1484,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const isSelected = c.name === currentCase;
|
||||
html += `
|
||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
||||
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
|
||||
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
||||
<span class="mobile-case-item-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
||||
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
|
||||
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
|
||||
@@ -318,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1417,6 +1418,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1734,6 +1736,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showRedrawButton: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1873,6 +1876,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
}
|
||||
|
||||
const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
const redrawBtn = document.querySelector('.btn-redraw-terminal');
|
||||
if (redrawBtn) {
|
||||
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -2106,7 +2115,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
|
||||
|
||||
+329
-19
@@ -996,6 +996,12 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Redraw terminal header button: hidden by default (opt-in via App Settings →
|
||||
Display → "Header Displays"). Pure client-side toggle. */
|
||||
.btn-redraw-terminal--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2229,6 +2235,34 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Touch devices: prevent browser from claiming the touch gesture before
|
||||
our JS touchmove handler fires. Without this, the browser starts native
|
||||
scrolling during the first few px of finger travel and ignores our
|
||||
subsequent preventDefault() — breaking custom terminal scroll. */
|
||||
.touch-device .terminal-container,
|
||||
.touch-device .terminal-container .xterm,
|
||||
.touch-device .terminal-container .xterm-viewport,
|
||||
.touch-device .terminal-container .xterm-screen {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures.
|
||||
xterm re-enables selectable DOM text in its accessibility tree, so force the
|
||||
whole terminal subtree back to non-selectable on touch devices. Synthetic mouse
|
||||
events for tap-to-position still dispatch normally through JavaScript. */
|
||||
body.touch-device .terminal-container .xterm,
|
||||
body.touch-device .terminal-container .xterm .xterm-viewport,
|
||||
body.touch-device .terminal-container .xterm .xterm-screen,
|
||||
body.touch-device .terminal-container .xterm .xterm-rows,
|
||||
body.touch-device .terminal-container .xterm .xterm-accessibility-tree,
|
||||
body.touch-device .terminal-container .xterm .xterm-accessibility-tree *,
|
||||
body.touch-device .terminal-container .xterm .xterm-selection,
|
||||
body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-webkit-touch-callout: none !important;
|
||||
}
|
||||
|
||||
/* Welcome Overlay */
|
||||
.welcome-overlay {
|
||||
position: absolute;
|
||||
@@ -2719,6 +2753,13 @@ body.solo-mode .btn-lifecycle-log {
|
||||
contain: style;
|
||||
}
|
||||
|
||||
/* backdrop-filter creates a stacking context, trapping the popover's
|
||||
z-index inside the toolbar. When the popover is open, raise the toolbar
|
||||
above the CJK input (z-index 52) so the popover is interactable. */
|
||||
.toolbar:has(.case-settings-popover:not(.hidden)) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
@@ -3007,6 +3048,11 @@ body.solo-mode .btn-lifecycle-log {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Touch devices (iPad, tablets): show stop button — no physical Ctrl+C */
|
||||
.touch-device .btn-toolbar.btn-stop {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Mobile app settings gear - hidden on desktop, shown on mobile */
|
||||
.btn-settings-mobile {
|
||||
display: none;
|
||||
@@ -5527,6 +5573,45 @@ kbd {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Ultracode run minimized-to-tab badge — same chip shape as the subagent badge but a
|
||||
distinct purple (matches the ultracode window accent), and a SEPARATE class so the
|
||||
incremental tab-update path (which keys on .tab-subagent-badge) never touches it.
|
||||
Reuses the shared .subagent-dropdown for its restore/dismiss list. */
|
||||
.session-tab .tab-ultracode-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
background: #a855f7;
|
||||
color: white;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.1s, background 0.15s;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.session-tab .tab-ultracode-badge:hover {
|
||||
background: #9333ea;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
.session-tab .tab-ultracode-badge .subagent-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
/* Per-item type glyph in the ULTRA dropdown (🧬 run vs 📄 transcript). */
|
||||
.subagent-dropdown-item .ultracode-dd-icon {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
/* Subagent Dropdown - compact, hover-triggered */
|
||||
.subagent-dropdown {
|
||||
display: none;
|
||||
@@ -8427,14 +8512,14 @@ kbd {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-status.completed {
|
||||
background: var(--success);
|
||||
background: var(--green);
|
||||
}
|
||||
.ultracode-status.active {
|
||||
background: var(--warning);
|
||||
background: var(--yellow);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-status.failed {
|
||||
background: var(--error, #b3261e);
|
||||
background: var(--red, #b3261e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -8485,6 +8570,16 @@ kbd {
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
/* At-a-glance agent state: yellow while working, green when done. */
|
||||
.ultracode-agent-card.uw-state-working {
|
||||
border-left-color: var(--yellow);
|
||||
background: color-mix(in srgb, var(--yellow) 9%, var(--bg-input));
|
||||
}
|
||||
.ultracode-agent-card.uw-state-done {
|
||||
border-left-color: var(--green);
|
||||
background: color-mix(in srgb, var(--green) 9%, var(--bg-input));
|
||||
}
|
||||
.ultracode-agent-card--clickable {
|
||||
cursor: pointer;
|
||||
@@ -8493,6 +8588,14 @@ kbd {
|
||||
.ultracode-agent-card--clickable:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
/* Keep the state tint on hover (equal specificity to the hover rule, so it must
|
||||
come after it) — a tad stronger so the hover still reads as interactive. */
|
||||
.ultracode-agent-card--clickable.uw-state-working:hover {
|
||||
background: color-mix(in srgb, var(--yellow) 16%, var(--bg-card));
|
||||
}
|
||||
.ultracode-agent-card--clickable.uw-state-done:hover {
|
||||
background: color-mix(in srgb, var(--green) 16%, var(--bg-card));
|
||||
}
|
||||
.ultracode-agent-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8518,10 +8621,10 @@ kbd {
|
||||
color: white;
|
||||
}
|
||||
.ultracode-agent-state.completed {
|
||||
background: var(--success);
|
||||
background: var(--green);
|
||||
}
|
||||
.ultracode-agent-state.active {
|
||||
background: var(--warning);
|
||||
background: var(--yellow);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-agent-state.idle {
|
||||
@@ -8583,13 +8686,6 @@ kbd {
|
||||
transform: scale(0.92);
|
||||
opacity: 0;
|
||||
}
|
||||
.ultracode-window.collapsed {
|
||||
height: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
.ultracode-window.collapsed .ultracode-window-body {
|
||||
display: none;
|
||||
}
|
||||
.ultracode-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8661,14 +8757,15 @@ kbd {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */
|
||||
/* Ultracode connector line — the session-tab accent blue (dashed to stay distinct
|
||||
from the solid subagent lines, per the same-blue-as-the-tab request). */
|
||||
.connection-line.ultracode-connection {
|
||||
stroke: #a855f7;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 6 3;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 0 5px rgba(168, 85, 247, 0.85))
|
||||
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
|
||||
drop-shadow(0 0 5px rgba(var(--accent-rgb), 0.85))
|
||||
drop-shadow(0 0 10px rgba(var(--accent-rgb), 0.5));
|
||||
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.connection-line.ultracode-connection:hover {
|
||||
@@ -8679,6 +8776,32 @@ kbd {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Agent-transcript window: spawned from an agent card (in a run window or the dock
|
||||
panel), tied to its parent run window by its own connector line. Reuses the
|
||||
.ultracode-window chrome; wider with a monospace transcript body. */
|
||||
.ultracode-agent-window {
|
||||
width: 470px;
|
||||
height: 420px;
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
.ultracode-agent-window .ultracode-window-body .uw-transcript {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
}
|
||||
/* Distinguish the parent→agent line from the tab→run line: solid cyan, no dashes.
|
||||
Must follow .ultracode-connection (equal specificity → source order wins). */
|
||||
.connection-line.ultracode-agent-connection {
|
||||
stroke: #38bdf8;
|
||||
stroke-dasharray: none;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)) drop-shadow(0 0 5px rgba(56, 189, 248, 0.85));
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
@@ -9307,9 +9430,9 @@ kbd {
|
||||
bottom: calc(var(--safe-area-bottom) + 40px);
|
||||
z-index: 52;
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
padding: 12px 10px;
|
||||
min-height: 34px;
|
||||
max-height: 68px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -9328,6 +9451,193 @@ body.touch-device.cjk-input-visible .main {
|
||||
padding-bottom: calc(84px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Keyboard Accessory Bar
|
||||
Base styles in styles.css (always loaded) so iPad landscape (≥1024px,
|
||||
where mobile.css doesn't load) still gets dark styling. Phone overrides
|
||||
remain in mobile.css @media (max-width: 430px).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch devices: fixed positioning to float above keyboard.
|
||||
Phone CSS (mobile.css) adds bottom + safe-area padding;
|
||||
iPad JS sets bottom dynamically via updateLayoutForKeyboard(). */
|
||||
.touch-device .keyboard-accessory-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 100px;
|
||||
padding: 10px 8px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Paste Overlay
|
||||
Base styles in styles.css so iPad landscape works.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.paste-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.paste-new {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-send {
|
||||
background: var(--accent-color, #7aa2f7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||||
const statusClass = agent?.status || 'idle';
|
||||
agentItems.push(`
|
||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
|
||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
|
||||
<span class="subagent-dropdown-status ${statusClass}"></span>
|
||||
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">×</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
||||
this._appendUltracodeConnectionLines(svg, rects);
|
||||
}
|
||||
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -695,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
parentSessionId && parentSessionName
|
||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
|
||||
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status ${agent.status}">${agent.status}</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
|
||||
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
|
||||
</div>
|
||||
</div>
|
||||
${parentHeader}
|
||||
|
||||
+126
-85
@@ -19,6 +19,10 @@
|
||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||
// back to the bottom while the user is inspecting earlier output.
|
||||
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
|
||||
// Mobile browsers synthesize trusted mouse events after touchend. During this
|
||||
// short window, only the app's synthetic tap-to-position mouse event should
|
||||
// reach xterm.
|
||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
@@ -46,6 +50,7 @@
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._installMobileTapMouseGuard();
|
||||
|
||||
// Suppress xterm key handling during CJK IME composition.
|
||||
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
||||
@@ -365,11 +371,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
let pixelAccum = 0;
|
||||
|
||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||
let touchStartY = 0;
|
||||
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
touchStartY = touchLastY;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
@@ -388,9 +397,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
ev.preventDefault();
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
||||
didScroll = true;
|
||||
}
|
||||
// Below the tap threshold, treat the gesture as a potential tap:
|
||||
// don't preventDefault (iOS needs click synthesis to show the
|
||||
// keyboard) and don't accumulate scroll distance or velocity. Without
|
||||
// this guard, sub-threshold micro-drift still scrolls a line and
|
||||
// leaves a non-zero velocity that touchend turns into a momentum
|
||||
// fling, so a jittery tap would both position the cursor AND scroll.
|
||||
if (!didScroll) return;
|
||||
ev.preventDefault();
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
@@ -410,20 +428,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
container.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
(ev) => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
// ── Tap-to-position cursor ──────────────────────────────────
|
||||
// Synthesize a click from the real touch point so the foreground app
|
||||
// moves its cursor to the tapped cell (iOS doesn't reliably do this
|
||||
// itself under touch-action:none). CRITICAL: only when mouse tracking
|
||||
// is ON. xterm disables its local SelectionService while mouse events
|
||||
// are active, so the synthetic click is forwarded to the PTY as an SGR
|
||||
// report (cursor moves). But when tracking is OFF, that same click
|
||||
// drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a
|
||||
// tap on CJK text would select & copy it instead of positioning. So
|
||||
// gate strictly on the live mouse-tracking mode.
|
||||
const touch = ev.changedTouches && ev.changedTouches[0];
|
||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||
if (touch) {
|
||||
this._suppressTrustedTapMouseEvents();
|
||||
}
|
||||
if (touch && mouseTrackingOn) {
|
||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||
}
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
// Route subsequent typing to the right place: keep the CJK input
|
||||
// field focused when Chinese input is on, otherwise the terminal.
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.focus();
|
||||
} else {
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
this.terminal.focus();
|
||||
}
|
||||
}
|
||||
@@ -585,8 +621,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
// CJK input has focus — block xterm from sending to PTY
|
||||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
// is on, because cjkActive stays true the whole time the field is visible.
|
||||
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
|
||||
// CJK input has focus — block xterm from sending keystrokes to PTY
|
||||
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
|
||||
if (this.activeSessionId) {
|
||||
// Filter terminal query replies generated by xterm.js itself.
|
||||
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||
@@ -1577,71 +1618,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// CJK textarea already provides visual feedback — bypass local echo
|
||||
// buffering so each composed word reaches the PTY immediately.
|
||||
_handleCjkInput(text) {
|
||||
if (!this.activeSessionId) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
|
||||
if (!useLocalEcho) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === '\x7f') {
|
||||
const source = this._localEchoOverlay.removeChar();
|
||||
if (source === 'flushed') {
|
||||
// Sync app-level flushed Maps (per-session state for tab switching),
|
||||
// mirroring the onData backspace path — otherwise switching tabs away
|
||||
// and back restores a stale, too-long flushed overlay.
|
||||
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
|
||||
if (this._flushedOffsets?.has(sessionId)) {
|
||||
if (count === 0) {
|
||||
this._flushedOffsets.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
} else {
|
||||
this._flushedOffsets.set(sessionId, count);
|
||||
this._flushedTexts?.set(sessionId, flushedText);
|
||||
}
|
||||
}
|
||||
this._sendInputAsync(sessionId, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/[\r\n]+$/.test(text)) {
|
||||
const committed = text.replace(/[\r\n]+$/g, '');
|
||||
if (committed) this._localEchoOverlay.appendText(committed);
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
|
||||
// the composer) — forward to the PTY without touching overlay state,
|
||||
// mirroring the onData path. Appending it to pending text would type raw
|
||||
// ESC bytes into the prompt on the next Enter.
|
||||
if (text.length > 1 && text.charCodeAt(0) === 27) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 1 && text.charCodeAt(0) < 32) {
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
this._localEchoOverlay.appendText(text);
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1995,9 +1976,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
try {
|
||||
// Send resize to restore proper dimensions (with minimum enforcement).
|
||||
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
|
||||
await this.sendResize(this.activeSessionId);
|
||||
// Force resize even when dimensions match the server's last known state —
|
||||
// another device may have changed the PTY size since this client last sent,
|
||||
// and force guarantees a SIGWINCH → Ink redraw at the current device's size.
|
||||
await this.sendResize(this.activeSessionId, { force: true });
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
} catch (err) {
|
||||
@@ -2054,6 +2036,62 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Synthetic tap → mouse report
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to
|
||||
// xterm's root element. xterm's mouse-reporting handler reads the event's
|
||||
// client coords, maps them to a terminal cell relative to .xterm-screen, and
|
||||
// — when the foreground app has mouse tracking active (DECSET 1000/1002/1006,
|
||||
// which Claude's input enables) — encodes an SGR mouse report to the PTY.
|
||||
// That is the same path a real desktop click takes; on touch devices the
|
||||
// browser's own compatibility-event synthesis is unreliable (and suppressed
|
||||
// by touch-action:none), so we drive it explicitly. With mouse tracking off
|
||||
// it degrades to a harmless zero-length click (no drag → no text selection).
|
||||
_dispatchSyntheticTerminalClick(clientX, clientY) {
|
||||
const el = this.terminal?.element;
|
||||
if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
||||
// xterm registers its mouseup listener on document during mousedown, so a
|
||||
// bubbling mouseup reaches it; dispatch both to the root element in order.
|
||||
const base = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX,
|
||||
clientY,
|
||||
screenX: clientX,
|
||||
screenY: clientY,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
};
|
||||
try {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
|
||||
} catch {
|
||||
/* MouseEvent constructor unavailable — tap-to-position simply no-ops */
|
||||
}
|
||||
},
|
||||
|
||||
_installMobileTapMouseGuard() {
|
||||
const el = this.terminal?.element;
|
||||
if (!el || el._codemanTapMouseGuardInstalled) return;
|
||||
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return;
|
||||
el._codemanTapMouseGuardInstalled = true;
|
||||
const suppressTrustedCompatMouse = (ev) => {
|
||||
const suppressUntil = this._trustedTapMouseSuppressUntil || 0;
|
||||
if (!ev.isTrusted || performance.now() > suppressUntil) return;
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
};
|
||||
el.addEventListener('mousedown', suppressTrustedCompatMouse, true);
|
||||
el.addEventListener('mouseup', suppressTrustedCompatMouse, true);
|
||||
},
|
||||
|
||||
_suppressTrustedTapMouseEvents() {
|
||||
const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450;
|
||||
this._trustedTapMouseSuppressUntil = performance.now() + ms;
|
||||
},
|
||||
|
||||
increaseFontSize() {
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
@@ -2103,8 +2141,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Send resize to a session with minimum dimension enforcement.
|
||||
* @param {string} sessionId
|
||||
* @param {{ forceHttp?: boolean }} [options]
|
||||
* @returns {Promise<void>}
|
||||
* @param {{ forceHttp?: boolean, force?: boolean }} [options]
|
||||
* @returns {Promise<boolean>} Whether dimensions changed from the last send
|
||||
*/
|
||||
async sendResize(sessionId, options = {}) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
@@ -2133,16 +2171,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fast path: WebSocket resize
|
||||
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
|
||||
const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType };
|
||||
if (options.force) msg.f = true;
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
return changed;
|
||||
} catch {
|
||||
// Fall through to HTTP POST
|
||||
}
|
||||
}
|
||||
const body = { ...dims, viewportType };
|
||||
if (options.force) body.force = true;
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...dims, viewportType }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
@@ -2153,12 +2195,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendInput(input) {
|
||||
if (!this.activeSessionId) return;
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, useMux: true }),
|
||||
});
|
||||
if (!this.activeSessionId || !input) return;
|
||||
// Route through the durable, exactly-once delivery layer (useMux for the
|
||||
// POST fallback) so voice / keyboard-accessory / paste input also survives a
|
||||
// dropped link instead of being lost in a single best-effort fetch.
|
||||
this._sendInputAsync(this.activeSessionId, input, { useMux: true });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -96,6 +96,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.renderUltracodeAgentsPanel();
|
||||
// Clicking a run also pops its floating window (with connector line to the
|
||||
// session tab), like the auto-popped one — an explicit open, so it ignores the
|
||||
// floating-windows auto-pop toggle (ultracode-windows.js).
|
||||
if (typeof this.openUltracodeWindowForRun === 'function') this.openUltracodeWindowForRun(runId);
|
||||
},
|
||||
selectWorkflowPhase(phaseIndex) {
|
||||
this._ensureWorkflowState();
|
||||
@@ -105,6 +109,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async _fetchWorkflowRunDetail(runId) {
|
||||
// De-dupe concurrent fetches for the same run — selecting a run can trigger both
|
||||
// a panel refresh and a floating-window open, which would otherwise double-fetch.
|
||||
if (!this._wfDetailInFlight) this._wfDetailInFlight = new Set();
|
||||
if (this._wfDetailInFlight.has(runId)) return;
|
||||
this._wfDetailInFlight.add(runId);
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
|
||||
const env = await res.json();
|
||||
@@ -117,15 +126,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
} catch {
|
||||
/* transient — next update retries */
|
||||
} finally {
|
||||
this._wfDetailInFlight.delete(runId);
|
||||
}
|
||||
},
|
||||
|
||||
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
|
||||
// Phase 4: fetch an agent's live transcript by agentId. The workflow agent's
|
||||
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
||||
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
||||
async openWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return;
|
||||
// Returns { formatted: string[], entryCount } or null when nothing is available
|
||||
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
|
||||
// in-page floating window lives in ultracode-windows.js (openUltracodeAgentWindow) —
|
||||
// we no longer spawn a detached browser popup.
|
||||
async _fetchWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return null;
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
||||
@@ -136,21 +150,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const ok = data && data.success && data.data;
|
||||
const formatted = ok ? data.data.formatted : null;
|
||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||
if (!formatted || !entryCount) {
|
||||
alert(
|
||||
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=860,height=640');
|
||||
if (!win) return; // popup blocked
|
||||
win.document.write(
|
||||
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
|
||||
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
|
||||
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
|
||||
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
if (!formatted || !entryCount) return null;
|
||||
return { formatted, entryCount };
|
||||
},
|
||||
|
||||
// ----- Render (debounced) -----
|
||||
@@ -203,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||
}
|
||||
return (
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun(${escapeHtml(JSON.stringify(r.runId))})">` +
|
||||
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||
@@ -251,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a, runId)).join('');
|
||||
})
|
||||
.join('');
|
||||
detail.innerHTML = html;
|
||||
},
|
||||
|
||||
_workflowAgentCardHtml(a) {
|
||||
_workflowAgentCardHtml(a, runId) {
|
||||
const state = String(a.state || 'start');
|
||||
const stateCls = this._workflowAgentStateClass(state);
|
||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
||||
@@ -274,10 +275,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
|
||||
// no agentId yet, so they stay non-clickable.
|
||||
const clickable = !!a.agentId;
|
||||
// At-a-glance state tint on the whole card: green when done, yellow while working.
|
||||
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
|
||||
const cardAttrs = clickable
|
||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
|
||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
||||
: ` class="ultracode-agent-card"`;
|
||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
|
||||
` title="View transcript" onclick="app.openUltracodeAgentWindow(${escapeHtml(JSON.stringify(a.agentId))},${escapeHtml(JSON.stringify(runId || ''))})"`
|
||||
: ` class="ultracode-agent-card${cardStateCls}"`;
|
||||
return (
|
||||
`<div${cardAttrs}>` +
|
||||
`<div class="ultracode-agent-top">` +
|
||||
|
||||
@@ -33,9 +33,12 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
||||
_ensureUltracodeWindowState() {
|
||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
|
||||
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
||||
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
||||
if (!this.ultracodeAgentWindows) this.ultracodeAgentWindows = new Map(); // agentId -> { element, runId, dragListeners }
|
||||
if (!this.minimizedUltracodeRuns) this.minimizedUltracodeRuns = new Map(); // sessionId -> Set<runId> minimized to a tab
|
||||
if (!this.minimizedUltracodeAgents) this.minimizedUltracodeAgents = new Map(); // sessionId -> Map<agentId,{runId,label}>
|
||||
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
||||
},
|
||||
|
||||
@@ -77,12 +80,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
_syncUltracodeFloatingWindow(run, opts) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!run || !run.runId) return;
|
||||
if (!this._ultracodeFloatingEnabled()) return;
|
||||
const runId = run.runId;
|
||||
const existing = this.ultracodeWindows.get(runId);
|
||||
// Auto-pop is gated on the floating-windows toggle, but an ALREADY-open window
|
||||
// (e.g. one opened by clicking the run in the dock) keeps refreshing regardless.
|
||||
if (!existing && !this._ultracodeFloatingEnabled()) return;
|
||||
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
|
||||
|
||||
const active = this._isWorkflowRunActive(run);
|
||||
const existing = this.ultracodeWindows.get(runId);
|
||||
|
||||
// Minimized to a tab — keep it there (don't re-pop a window). Clear the tab badge a
|
||||
// short while after the run finishes, mirroring the floating window's finish grace.
|
||||
if (this._isUltracodeRunMinimized(runId)) {
|
||||
if (!active && !this.ultracodeWindowCloseTimers.has(runId)) {
|
||||
const timer = setTimeout(() => {
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
this._removeMinimizedUltracodeRun(runId);
|
||||
this.renderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
}, 8000);
|
||||
this.ultracodeWindowCloseTimers.set(runId, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
// Run is alive — cancel any pending auto-close.
|
||||
@@ -120,6 +140,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicitly open (or focus) the floating window for a run — the click-through
|
||||
* from the dock panel's run list. Unlike auto-pop this ignores the floating-windows
|
||||
* toggle and clears any prior dismissal (it's a direct user action), then draws the
|
||||
* connector line from the run's session tab.
|
||||
*/
|
||||
openUltracodeWindowForRun(runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!runId) return;
|
||||
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
if (!run) return;
|
||||
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
|
||||
this._removeMinimizedUltracodeRun(runId); // …and a past minimize-to-tab
|
||||
const existing = this.ultracodeWindows.get(runId);
|
||||
if (existing) {
|
||||
// Already open — bring to front and refresh.
|
||||
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.updateConnectionLines();
|
||||
} else {
|
||||
this.createUltracodeWindow(run);
|
||||
}
|
||||
},
|
||||
|
||||
/** Build and mount a floating window for a run, positioned near its parent tab. */
|
||||
createUltracodeWindow(run) {
|
||||
this._ensureUltracodeWindowState();
|
||||
@@ -140,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="uw-status"></span>
|
||||
</div>
|
||||
<div class="ultracode-window-actions">
|
||||
<button class="uw-min" type="button" title="Collapse">─</button>
|
||||
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||
<button class="uw-close" type="button" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleUltracodeWindowCollapse(runId);
|
||||
this.minimizeUltracodeWindowToTab(runId);
|
||||
});
|
||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -184,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
|
||||
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
||||
}
|
||||
|
||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
|
||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
|
||||
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Collapse/expand the window to header-only (line stays connected). */
|
||||
toggleUltracodeWindowCollapse(runId) {
|
||||
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
|
||||
|
||||
/** Is this run currently minimized to a tab (so auto-pop should leave it alone)? */
|
||||
_isUltracodeRunMinimized(runId) {
|
||||
if (!this.minimizedUltracodeRuns) return false;
|
||||
for (const set of this.minimizedUltracodeRuns.values()) {
|
||||
if (set.has(runId)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Drop a run from the minimized-to-tab tracking (all sessions, or a specific one). */
|
||||
_removeMinimizedUltracodeRun(runId, sessionId) {
|
||||
if (!this.minimizedUltracodeRuns) return;
|
||||
if (sessionId) {
|
||||
const set = this.minimizedUltracodeRuns.get(sessionId);
|
||||
if (set) {
|
||||
set.delete(runId);
|
||||
if (!set.size) this.minimizedUltracodeRuns.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [sid, set] of this.minimizedUltracodeRuns) {
|
||||
if (set.delete(runId) && !set.size) this.minimizedUltracodeRuns.delete(sid);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Minimize the floating run window into its originating session tab: record it as
|
||||
* minimized (so a badge renders on the tab), genie-animate the window toward that
|
||||
* tab, then remove the floating element. Restorable from the tab badge dropdown.
|
||||
*/
|
||||
minimizeUltracodeWindowToTab(runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const data = this.ultracodeWindows.get(runId);
|
||||
if (!data) return;
|
||||
data.collapsed = !data.collapsed;
|
||||
data.element.classList.toggle('collapsed', data.collapsed);
|
||||
|
||||
let parentSessionId = data.parentSessionId;
|
||||
if (!parentSessionId) {
|
||||
const summary = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
}
|
||||
// No tab to fly into → fall back to a plain close so the window isn't orphaned.
|
||||
if (!parentSessionId) {
|
||||
this.closeUltracodeWindow(runId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending finish auto-close — the badge owns the run's lifecycle now.
|
||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||
if (pending) {
|
||||
clearTimeout(pending);
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
}
|
||||
|
||||
if (!this.minimizedUltracodeRuns.has(parentSessionId)) this.minimizedUltracodeRuns.set(parentSessionId, new Set());
|
||||
this.minimizedUltracodeRuns.get(parentSessionId).add(runId);
|
||||
|
||||
const element = data.element;
|
||||
const dragListeners = data.dragListeners;
|
||||
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||
this._teardownUltracodeDrag(dragListeners);
|
||||
if (element) element.remove();
|
||||
this.ultracodeWindows.delete(runId);
|
||||
// Full rebuild so the tab badge renders (the incremental path only knows subagent badges).
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
});
|
||||
},
|
||||
|
||||
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
|
||||
_animateUltracodeWindowToTab(element, sessionId, done) {
|
||||
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
|
||||
if (!tab || !element) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
const w = element.getBoundingClientRect();
|
||||
const t = tab.getBoundingClientRect();
|
||||
const dx = t.left + t.width / 2 - (w.left + w.width / 2);
|
||||
const dy = t.top + t.height / 2 - (w.top + w.height / 2);
|
||||
element.style.transformOrigin = 'center center';
|
||||
element.style.transition = 'transform 0.26s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.26s ease';
|
||||
element.style.pointerEvents = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
element.style.transform = `translate(${dx}px, ${dy}px) scale(0.06)`;
|
||||
element.style.opacity = '0';
|
||||
});
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
done();
|
||||
};
|
||||
element.addEventListener('transitionend', finish, { once: true });
|
||||
setTimeout(finish, 320); // fallback in case transitionend doesn't fire
|
||||
},
|
||||
|
||||
/** Tab badge (with restore/dismiss dropdown) for runs minimized to this session's tab. */
|
||||
renderUltracodeTabBadge(sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const runSet = this.minimizedUltracodeRuns.get(sessionId);
|
||||
const agentMap = this.minimizedUltracodeAgents.get(sessionId);
|
||||
const total = (runSet ? runSet.size : 0) + (agentMap ? agentMap.size : 0);
|
||||
if (total === 0) return '';
|
||||
|
||||
const trunc = (s) => (s.length > 25 ? s.slice(0, 25) + '…' : s);
|
||||
const items = [];
|
||||
// Minimized run windows (🧬) first…
|
||||
if (runSet) {
|
||||
for (const runId of runSet) {
|
||||
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
const name = run ? run.workflowName || run.summary || runId : runId;
|
||||
const statusCls = this._workflowStatusClass(run ? String(run.status || '') : '');
|
||||
items.push(
|
||||
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeRunFromTab(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore run">` +
|
||||
`<span class="subagent-dropdown-status ${statusCls}"></span>` +
|
||||
`<span class="ultracode-dd-icon">🧬</span>` +
|
||||
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeRun(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
// …then minimized agent transcripts (📄).
|
||||
if (agentMap) {
|
||||
for (const [agentId, entry] of agentMap) {
|
||||
const name = (entry && entry.label) || agentId;
|
||||
items.push(
|
||||
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeAgentFromTab(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore transcript">` +
|
||||
`<span class="subagent-dropdown-status"></span>` +
|
||||
`<span class="ultracode-dd-icon">📄</span>` +
|
||||
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeAgent(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
const label = total === 1 ? 'ULTRA' : `ULTRA (${total})`;
|
||||
return (
|
||||
`<span class="tab-ultracode-badge" onmouseenter="app.showSubagentDropdown(this)" onmouseleave="app.scheduleHideSubagentDropdown(this)" onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">` +
|
||||
`<span class="subagent-label">${label}</span>` +
|
||||
`<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">${items.join('')}</div>` +
|
||||
`</span>`
|
||||
);
|
||||
},
|
||||
|
||||
/** Restore a minimized run from its tab badge: re-open the floating window. */
|
||||
restoreUltracodeRunFromTab(runId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.openUltracodeWindowForRun(runId);
|
||||
},
|
||||
|
||||
/** Dismiss a minimized run from its tab badge (don't re-pop it). */
|
||||
dismissMinimizedUltracodeRun(runId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||
this.ultracodeWindowsClosed.add(runId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
// ── Minimize an agent transcript window into its tab (same idiom as run windows) ──
|
||||
|
||||
/** Is this agent transcript currently minimized to a tab? */
|
||||
_isUltracodeAgentMinimized(agentId) {
|
||||
if (!this.minimizedUltracodeAgents) return false;
|
||||
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||
if (map.has(agentId)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Look up a minimized agent's {runId,label} entry (across sessions). */
|
||||
_getMinimizedUltracodeAgent(agentId) {
|
||||
if (!this.minimizedUltracodeAgents) return null;
|
||||
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||
if (map.has(agentId)) return map.get(agentId);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Drop an agent from minimized tracking (all sessions, or a specific one). */
|
||||
_removeMinimizedUltracodeAgent(agentId, sessionId) {
|
||||
if (!this.minimizedUltracodeAgents) return;
|
||||
if (sessionId) {
|
||||
const map = this.minimizedUltracodeAgents.get(sessionId);
|
||||
if (map) {
|
||||
map.delete(agentId);
|
||||
if (!map.size) this.minimizedUltracodeAgents.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [sid, map] of this.minimizedUltracodeAgents) {
|
||||
if (map.delete(agentId) && !map.size) this.minimizedUltracodeAgents.delete(sid);
|
||||
}
|
||||
},
|
||||
|
||||
/** Minimize an agent transcript window into the run's originating session tab. */
|
||||
minimizeUltracodeAgentWindowToTab(agentId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info) return;
|
||||
const runId = info.runId;
|
||||
const summary = runId && this.workflowRuns ? this.workflowRuns.get(runId) : null;
|
||||
let parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
if (!parentSessionId && this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
|
||||
parentSessionId = this.activeSessionId;
|
||||
}
|
||||
// No tab to fly into → plain close rather than orphan it.
|
||||
if (!parentSessionId) {
|
||||
this.closeUltracodeAgentWindow(agentId);
|
||||
return;
|
||||
}
|
||||
const labelEl = info.element.querySelector('.uw-name');
|
||||
const label = labelEl ? labelEl.textContent : agentId;
|
||||
if (!this.minimizedUltracodeAgents.has(parentSessionId))
|
||||
this.minimizedUltracodeAgents.set(parentSessionId, new Map());
|
||||
this.minimizedUltracodeAgents.get(parentSessionId).set(agentId, { runId, label });
|
||||
|
||||
const element = info.element;
|
||||
const dragListeners = info.dragListeners;
|
||||
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||
this._teardownUltracodeDrag(dragListeners);
|
||||
if (element) element.remove();
|
||||
this.ultracodeAgentWindows.delete(agentId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
});
|
||||
},
|
||||
|
||||
/** Restore a minimized agent transcript from its tab badge: re-open its window. */
|
||||
restoreUltracodeAgentFromTab(agentId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const entry = this._getMinimizedUltracodeAgent(agentId);
|
||||
const runId = entry ? entry.runId : null;
|
||||
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.openUltracodeAgentWindow(agentId, runId);
|
||||
},
|
||||
|
||||
/** Dismiss a minimized agent transcript from its tab badge. */
|
||||
dismissMinimizedUltracodeAgent(agentId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
@@ -233,19 +520,149 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Agent-transcript windows ────────────────────────────────────────────────
|
||||
// Clicking an agent card (in a run window OR the dock panel) opens the agent's
|
||||
// live transcript as its OWN in-page floating window, line-tied to its parent run
|
||||
// window (or the run's session tab when that window is closed). Replaces the old
|
||||
// detached `window.open` browser popup so the transcript stays inside the same
|
||||
// draggable, connector-line floating-window system as the run windows.
|
||||
|
||||
/** Open (or focus) the floating transcript window for a workflow agent. */
|
||||
async openUltracodeAgentWindow(agentId, runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!agentId) return;
|
||||
this._removeMinimizedUltracodeAgent(agentId); // an explicit open overrides a past minimize
|
||||
const existing = this.ultracodeAgentWindows.get(agentId);
|
||||
if (existing && existing.element) {
|
||||
// Already open — bring to front and refresh transcript.
|
||||
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
this.updateConnectionLines();
|
||||
} else if (!this.createUltracodeAgentWindow(agentId, runId)) {
|
||||
return;
|
||||
}
|
||||
// Body shows a loading state until the fetch lands (re-fetch on focus too, so a
|
||||
// still-running agent's transcript grows as you re-click).
|
||||
const data = this._fetchWorkflowAgentTranscript ? await this._fetchWorkflowAgentTranscript(agentId) : null;
|
||||
this.renderUltracodeAgentWindowContent(agentId, data);
|
||||
},
|
||||
|
||||
/** Build and mount the floating agent-transcript window shell near its parent. */
|
||||
createUltracodeAgentWindow(agentId, runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (this.ultracodeAgentWindows.has(agentId)) return this.ultracodeAgentWindows.get(agentId).element;
|
||||
|
||||
const label = this._ultracodeAgentLabel(agentId, runId) || agentId;
|
||||
const win = document.createElement('div');
|
||||
win.className = 'ultracode-window ultracode-agent-window spawning';
|
||||
win.id = `ultracode-agent-window-${agentId}`;
|
||||
win.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
win.innerHTML = `
|
||||
<div class="ultracode-window-header">
|
||||
<div class="ultracode-window-title" title="${escapeHtml(label)} — transcript">
|
||||
<span class="icon">📄</span>
|
||||
<span class="uw-name">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
<div class="ultracode-window-actions">
|
||||
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||
<button class="uw-close" type="button" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ultracode-window-body">
|
||||
<div class="subagent-empty">Loading transcript…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position: offset from the parent run window if it's open, else cascade.
|
||||
const parentWin = runId ? this.ultracodeWindows.get(runId) : null;
|
||||
if (parentWin && parentWin.element) {
|
||||
const r = parentWin.element.getBoundingClientRect();
|
||||
win.style.left = `${Math.max(8, Math.min(r.left + 40, window.innerWidth - 472))}px`;
|
||||
win.style.top = `${Math.max(8, Math.min(r.top + 40, window.innerHeight - 160))}px`;
|
||||
} else {
|
||||
const n = this.ultracodeAgentWindows.size;
|
||||
win.style.left = `${Math.min(140 + n * 28, Math.max(8, window.innerWidth - 472))}px`;
|
||||
win.style.top = `${110 + n * 28}px`;
|
||||
}
|
||||
|
||||
document.body.appendChild(win);
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.minimizeUltracodeAgentWindowToTab(agentId);
|
||||
});
|
||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeUltracodeAgentWindow(agentId);
|
||||
});
|
||||
|
||||
this.ultracodeAgentWindows.set(agentId, { element: win, runId, dragListeners });
|
||||
this.updateConnectionLines();
|
||||
return win;
|
||||
},
|
||||
|
||||
/** Resolve a human label for an agent from the run's fetched detail.agents[]. */
|
||||
_ultracodeAgentLabel(agentId, runId) {
|
||||
const detail = this.workflowRunDetails && runId ? this.workflowRunDetails.get(runId) : null;
|
||||
const agents = detail && Array.isArray(detail.agents) ? detail.agents : null;
|
||||
if (agents) {
|
||||
const found = agents.find((a) => a.agentId === agentId);
|
||||
if (found && found.label) return found.label;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Fill an agent window's body with the fetched transcript (or a friendly empty state). */
|
||||
renderUltracodeAgentWindowContent(agentId, data) {
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info || !info.element) return;
|
||||
const body = info.element.querySelector('.ultracode-window-body');
|
||||
if (!body) return;
|
||||
if (!data || !data.formatted || !data.entryCount) {
|
||||
body.innerHTML =
|
||||
'<div class="subagent-empty">No transcript available yet — the agent may be queued, aged out of tracking, or subagent tracking is disabled.</div>';
|
||||
return;
|
||||
}
|
||||
const text = escapeHtml(data.formatted.join('\n'));
|
||||
body.innerHTML = `<div class="uw-summary">${data.entryCount} entries</div><pre class="uw-transcript">${text}</pre>`;
|
||||
},
|
||||
|
||||
/** Close one floating agent-transcript window. */
|
||||
closeUltracodeAgentWindow(agentId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info) return;
|
||||
this._teardownUltracodeDrag(info.dragListeners);
|
||||
if (info.element) info.element.remove();
|
||||
this.ultracodeAgentWindows.delete(agentId);
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
||||
removeAllUltracodeWindows() {
|
||||
this._ensureUltracodeWindowState();
|
||||
const had = this.ultracodeWindows.size > 0;
|
||||
const hadMinimized = this.minimizedUltracodeRuns.size > 0 || this.minimizedUltracodeAgents.size > 0;
|
||||
const had = this.ultracodeWindows.size > 0 || this.ultracodeAgentWindows.size > 0;
|
||||
for (const [, data] of this.ultracodeWindows) {
|
||||
this._teardownUltracodeDrag(data.dragListeners);
|
||||
if (data.element) data.element.remove();
|
||||
}
|
||||
this.ultracodeWindows.clear();
|
||||
for (const [, info] of this.ultracodeAgentWindows) {
|
||||
this._teardownUltracodeDrag(info.dragListeners);
|
||||
if (info.element) info.element.remove();
|
||||
}
|
||||
this.ultracodeAgentWindows.clear();
|
||||
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
||||
this.ultracodeWindowCloseTimers.clear();
|
||||
this.minimizedUltracodeRuns.clear();
|
||||
this.minimizedUltracodeAgents.clear();
|
||||
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
||||
if (had) this.updateConnectionLines();
|
||||
// Drop any now-stale tab badges.
|
||||
if (hadMinimized) this.renderSessionTabs();
|
||||
},
|
||||
|
||||
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
||||
@@ -328,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
|
||||
})
|
||||
.join('');
|
||||
return head + grid;
|
||||
@@ -381,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
|
||||
svg.appendChild(line);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Append agent-window → parent connector lines into the shared SVG. Parent is the
|
||||
* agent's run floating window when open, else the run's session tab. Called right
|
||||
* after `_appendUltracodeConnectionLines` so it shares the same batched pass + the
|
||||
* tab-rect cache.
|
||||
*/
|
||||
_appendUltracodeAgentConnectionLines(svg, rects) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!svg || !this.ultracodeAgentWindows.size) return;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
for (const [agentId, info] of this.ultracodeAgentWindows) {
|
||||
if (!info.element) continue;
|
||||
const winRect = info.element.getBoundingClientRect();
|
||||
// Anchor: parent run window bottom-center if open, else the run's tab.
|
||||
let px, py;
|
||||
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
|
||||
if (runWin && runWin.element) {
|
||||
const pr = runWin.element.getBoundingClientRect();
|
||||
px = pr.left + pr.width / 2;
|
||||
py = pr.bottom;
|
||||
} else {
|
||||
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
|
||||
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
if (!parentSessionId) continue;
|
||||
const tabKey = 'tab:' + parentSessionId;
|
||||
if (!rects.has(tabKey)) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||
}
|
||||
const tabRect = rects.get(tabKey);
|
||||
if (!tabRect) continue;
|
||||
px = tabRect.left + tabRect.width / 2;
|
||||
py = tabRect.bottom;
|
||||
}
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (py + y2) / 2;
|
||||
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
|
||||
line.setAttribute('data-agent-id', agentId);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
@@ -662,7 +663,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const inputStr = String(input);
|
||||
@@ -673,6 +674,13 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
|
||||
// client's ACK, so a tagged duplicate redelivery must still return 200 but
|
||||
// skip the write. Untagged requests (curl/legacy) always apply.
|
||||
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
if (useMux) {
|
||||
@@ -746,14 +754,10 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
|
||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (viewportType) {
|
||||
session.resize(cols, rows, { viewportType });
|
||||
} else {
|
||||
session.resize(cols, rows);
|
||||
}
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -1707,7 +1711,7 @@ export function registerSessionRoutes(
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
|
||||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
// CSRF defense: state-changing routes must come from same origin.
|
||||
@@ -1744,7 +1748,7 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
||||
// — even an authenticated attacker can otherwise loop 10MB POSTs.
|
||||
// — even an authenticated attacker can otherwise loop large image POSTs.
|
||||
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
@@ -1758,8 +1762,9 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||
}
|
||||
|
||||
// Read the single file part. @fastify/multipart enforces the 10MB size cap
|
||||
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
|
||||
// Read the single file part. @fastify/multipart enforces the per-file size
|
||||
// cap (MAX_PASTE_IMAGE_BYTES) and the 1-file/4-field count limits (server.ts),
|
||||
// replacing a hand-rolled
|
||||
// boundary scanner with several bugs: literal boundary matches anywhere in
|
||||
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
||||
// offsets), no part-count cap.
|
||||
@@ -1783,7 +1788,8 @@ export function registerSessionRoutes(
|
||||
imageBytes = await part.toBuffer();
|
||||
} catch (err: unknown) {
|
||||
reply.code(413);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
|
||||
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || `File too large (max ${maxMb}MB)`);
|
||||
}
|
||||
if (imageBytes.length === 0) {
|
||||
reply.code(400);
|
||||
@@ -1847,9 +1853,22 @@ export function registerSessionRoutes(
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
|
||||
// the leaf. session.workingDir is guaranteed to exist (live session).
|
||||
await fs.mkdir(imageDir);
|
||||
// Non-recursive mkdir: does not follow symlinks for the leaf.
|
||||
// session.workingDir is guaranteed to exist (live session).
|
||||
try {
|
||||
await fs.mkdir(imageDir);
|
||||
} catch (mkErr: unknown) {
|
||||
// Concurrent uploads (a batch of photos) race to create .claude-images —
|
||||
// the losers get EEXIST. Treat an already-present REAL directory as
|
||||
// success, but re-verify it isn't a symlink a racing actor planted
|
||||
// (preserve the symlink-safety guarantee above).
|
||||
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
|
||||
const raceStat = await fs.lstat(imageDir);
|
||||
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||
|
||||
+23
-11
@@ -21,9 +21,12 @@
|
||||
* {"t":"o","d":"..."} — terminal output
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
||||
* {"t":"z","c":N,"r":N} — resize terminal
|
||||
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||
* optional reliable-delivery tags: the server applies each
|
||||
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
|
||||
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
@@ -123,10 +126,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
// (legacy/other tools) are applied as-is — no behavior change.
|
||||
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||
if (apply) {
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
}
|
||||
if (seq !== null && socket.readyState === 1) {
|
||||
socket.send(`{"t":"ia","seq":${seq}}`);
|
||||
}
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
Number.isInteger(msg.c) &&
|
||||
@@ -146,11 +161,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
}
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
} else {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
|
||||
@@ -186,6 +186,7 @@ export const ResizeSchema = z.object({
|
||||
cols: z.number().int().min(1).max(500),
|
||||
rows: z.number().int().min(1).max(200),
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -384,6 +385,7 @@ export const SettingsUpdateSchema = z
|
||||
// (client-side), but telemetry COLLECTION is server-side, so the per-device
|
||||
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
showRedrawButton: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
@@ -467,6 +469,15 @@ export const SettingsUpdateSchema = z
|
||||
export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
useMux: z.boolean().optional(),
|
||||
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||
// and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped
|
||||
// by a half-open WebSocket on a flaky link). The server applies each (clientId,
|
||||
// seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the
|
||||
// prompt twice. `.optional()` (not `.nullish()`) — the client omits them when
|
||||
// unset rather than sending null. See docs/reliable-input-delivery.md.
|
||||
seq: z.number().int().nonnegative().optional(),
|
||||
clientId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
// ========== Session Mutation Routes ==========
|
||||
|
||||
+3
-2
@@ -128,6 +128,7 @@ import {
|
||||
} from '../utils/index.js';
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
@@ -679,8 +680,8 @@ export class WebServer extends EventEmitter {
|
||||
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
|
||||
await this.app.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB per file
|
||||
files: 1, // paste-image only ever sends one file
|
||||
fileSize: MAX_PASTE_IMAGE_BYTES, // per file (default 50MB) — large phone photos / screenshots
|
||||
files: 1, // paste-image sends one file per request (clients batch up to 20 requests)
|
||||
fields: 4, // small headroom for accompanying form fields
|
||||
},
|
||||
});
|
||||
|
||||
+380
-49
@@ -53,16 +53,99 @@ const RUN_FILE_PREFIX = 'wf_';
|
||||
const RUN_FILE_SUFFIX = '.json';
|
||||
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
||||
const LIVE_AGENT_PREFIX = 'agent-';
|
||||
const LIVE_TRANSCRIPT_SUFFIX = '.jsonl';
|
||||
const SCRIPTS_SUBDIR = 'scripts';
|
||||
|
||||
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
|
||||
const PROMPT_PREVIEW_MAX = 200;
|
||||
const RESULT_PREVIEW_MAX = 240;
|
||||
/** Cap a live script read so a runaway/huge embedded script can't blow memory. */
|
||||
const SCRIPT_READ_MAX_BYTES = 512 * 1024;
|
||||
/** LRU cap on cached per-agent transcript stats across all live runs. */
|
||||
const MAX_CACHED_AGENT_STATS = 2000;
|
||||
|
||||
function truncate(value: string | undefined, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
/** First text from a transcript message's `content` (string or content-block array). */
|
||||
function extractMessageText(content: unknown): string | undefined {
|
||||
if (typeof content === 'string') return content.trim() || undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
|
||||
const t = (block as { text?: string }).text;
|
||||
if (typeof t === 'string' && t.trim()) return t.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolate the `meta = { … }` object literal from a workflow script so that later
|
||||
* declarations (e.g. JSON-Schema objects with their own `description:`/`title:`
|
||||
* fields, or prose containing `phases: [`) can't be mistaken for meta. Brace-matches
|
||||
* naively — a `{`/`}` inside a string value can still confuse it, in which case we
|
||||
* return undefined and the caller falls back to scanning the whole body. Best-effort.
|
||||
*/
|
||||
function extractMetaBlock(body: string): string | undefined {
|
||||
const m = /(?:^|[^A-Za-z0-9_])(?:export\s+const|const|let|var)\s+meta\s*=\s*\{/.exec(body);
|
||||
if (!m) return undefined;
|
||||
const open = body.indexOf('{', m.index);
|
||||
if (open < 0) return undefined;
|
||||
let depth = 0;
|
||||
for (let i = open; i < body.length; i++) {
|
||||
if (body[i] === '{') depth++;
|
||||
else if (body[i] === '}' && --depth === 0) return body.slice(open, i + 1);
|
||||
}
|
||||
return undefined; // unterminated (truncated script) — caller falls back to whole body
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of a single/double-quoted `key: '...'` value from a workflow
|
||||
* `meta` literal (the script is JS we must not eval). The key is anchored to a key
|
||||
* position (`{`/`,`/whitespace before it) so e.g. `description` can't match the tail
|
||||
* of `..._description`; pass the scoped meta block so later same-named schema fields
|
||||
* can't win. Returns undefined on no match.
|
||||
*/
|
||||
function extractMetaString(body: string, key: string): string | undefined {
|
||||
const re = new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"])((?:\\\\.|(?!\\1).)*)\\1`);
|
||||
const m = re.exec(body);
|
||||
return m ? m[2].replace(/\\(['"\\`])/g, '$1') : undefined;
|
||||
}
|
||||
|
||||
/** Best-effort `meta.phases: [{title, detail}, …]` extraction (pass the scoped meta block). */
|
||||
function extractMetaPhases(body: string): WorkflowRunPhase[] {
|
||||
const keyMatch = /(?:^|[^A-Za-z0-9_])phases\s*:\s*\[/.exec(body);
|
||||
if (!keyMatch) return [];
|
||||
const open = body.indexOf('[', keyMatch.index);
|
||||
if (open < 0) return [];
|
||||
// Brace-match to the array's closing ] so nested arrays don't end it early.
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = open; i < body.length; i++) {
|
||||
if (body[i] === '[') depth++;
|
||||
else if (body[i] === ']' && --depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0) return [];
|
||||
const block = body.slice(open, end + 1);
|
||||
const phases: WorkflowRunPhase[] = [];
|
||||
const re = /title\s*:\s*(['"])((?:\\.|(?!\1).)*)\1(?:\s*,\s*detail\s*:\s*(['"])((?:\\.|(?!\3).)*)\3)?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(block)) !== null && phases.length < 20) {
|
||||
phases.push({
|
||||
title: m[2].replace(/\\(['"\\`])/g, '$1'),
|
||||
detail: (m[4] || '').replace(/\\(['"\\`])/g, '$1'),
|
||||
});
|
||||
}
|
||||
return phases;
|
||||
}
|
||||
|
||||
/** Drop the heavy `agents[]` for list/snapshot use. */
|
||||
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
|
||||
const { agents: _agents, ...summary } = info;
|
||||
@@ -85,6 +168,34 @@ interface DiscoveredLiveRun {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** Per-agent stats parsed from one `agent-<id>.jsonl` transcript (mtime-cached). */
|
||||
interface AgentTranscriptStats {
|
||||
/** Tokens for the agent's LAST usage-bearing message (in+out+cache) ≈ the completion-JSON `tokens`. */
|
||||
tokens: number;
|
||||
/** Count of `tool_use` blocks across the transcript (matches the completion-JSON `toolCalls`). */
|
||||
toolCalls: number;
|
||||
/** Most-recent model id seen. */
|
||||
model: string;
|
||||
/** Name of the most-recent `tool_use` block. */
|
||||
lastToolName?: string;
|
||||
/** First user-message text, truncated — a hint of what the agent was asked. */
|
||||
promptPreview?: string;
|
||||
}
|
||||
|
||||
/** Workflow meta derived live from `workflows/scripts/<name>-<runId>.js`. */
|
||||
interface LiveWorkflowMeta {
|
||||
workflowName?: string;
|
||||
summary?: string;
|
||||
phases: WorkflowRunPhase[];
|
||||
}
|
||||
|
||||
/** A live agent file on disk (transcript and/or meta), keyed by its `<id>` stem. */
|
||||
interface LiveAgentFile {
|
||||
agentId: string;
|
||||
transcriptPath?: string;
|
||||
transcriptMtime?: number;
|
||||
}
|
||||
|
||||
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
|
||||
interface RawProgressEntry {
|
||||
type?: string;
|
||||
@@ -123,6 +234,17 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
private liveDirMtimes = new Map<string, number>();
|
||||
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
||||
private runIdToLiveDir = new Map<string, string>();
|
||||
/** transcript abs path -> { mtimeMs, stats }; re-parse a transcript only when its mtime moves. */
|
||||
private agentStatCache = new LRUMap<string, { mtimeMs: number; stats: AgentTranscriptStats }>({
|
||||
maxSize: MAX_CACHED_AGENT_STATS,
|
||||
});
|
||||
/** runId -> derived script meta (immutable per run; re-derived only until a name is found). */
|
||||
private liveMetaCache = new Map<string, LiveWorkflowMeta>();
|
||||
/** journal abs path -> { mtimeMs, parsed }; re-parse the journal only when it grows. */
|
||||
private journalCache = new Map<
|
||||
string,
|
||||
{ mtimeMs: number; parsed: { startedOrder: string[]; doneIds: Set<string> } }
|
||||
>();
|
||||
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
|
||||
private dirWatchers = new Map<string, ChokidarWatcher>();
|
||||
|
||||
@@ -160,6 +282,9 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
this.runIdToPath.clear();
|
||||
this.liveDirMtimes.clear();
|
||||
this.runIdToLiveDir.clear();
|
||||
this.agentStatCache.clear();
|
||||
this.liveMetaCache.clear();
|
||||
this.journalCache.clear();
|
||||
}
|
||||
|
||||
/** All cached runs (no recency filter), most-recently-active first. */
|
||||
@@ -243,7 +368,18 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
if (path) this.fileMtimes.delete(path);
|
||||
this.runIdToPath.delete(runId);
|
||||
const liveDir = this.runIdToLiveDir.get(runId);
|
||||
if (liveDir) this.liveDirMtimes.delete(liveDir);
|
||||
if (liveDir) {
|
||||
this.liveDirMtimes.delete(liveDir);
|
||||
// Drop cached transcript stats + journal parse under this run's live dir.
|
||||
const prefix = liveDir.endsWith('/') ? liveDir : liveDir + '/';
|
||||
for (const key of Array.from(this.agentStatCache.keys())) {
|
||||
if (key.startsWith(prefix)) this.agentStatCache.delete(key);
|
||||
}
|
||||
for (const key of Array.from(this.journalCache.keys())) {
|
||||
if (key.startsWith(prefix)) this.journalCache.delete(key);
|
||||
}
|
||||
}
|
||||
this.liveMetaCache.delete(runId);
|
||||
this.runIdToLiveDir.delete(runId);
|
||||
this.emit('run_removed', { runId });
|
||||
}
|
||||
@@ -346,14 +482,58 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-synthesize an in-flight run from its transcript dir when its newest member
|
||||
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
|
||||
* Re-synthesize an in-flight run from its transcript dir. Cheap pass first: stat
|
||||
* the dir members and skip entirely when the newest mtime is unchanged, so an idle
|
||||
* poll never reads a single (large) transcript. Only on a real change do we parse —
|
||||
* and even then per-transcript stats come from an mtime-keyed cache, so only the
|
||||
* transcripts that actually grew are re-read.
|
||||
*/
|
||||
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
|
||||
const info = await this.parseLiveDir(live);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(live.dirPath);
|
||||
} catch {
|
||||
return; // vanished between discover and read
|
||||
}
|
||||
|
||||
// Cheap pass: newest member mtime + the agent-file set (no transcript reads yet).
|
||||
let newestMtime = 0;
|
||||
let journalPath: string | null = null;
|
||||
let journalMtime = 0;
|
||||
const agentFiles = new Map<string, LiveAgentFile>();
|
||||
for (const name of entries) {
|
||||
if (name !== LIVE_JOURNAL_FILE && !name.startsWith(LIVE_AGENT_PREFIX)) continue;
|
||||
const full = join(live.dirPath, name);
|
||||
let m = 0;
|
||||
try {
|
||||
m = (await stat(full)).mtimeMs;
|
||||
} catch {
|
||||
continue; // entry vanished — ignore
|
||||
}
|
||||
if (m > newestMtime) newestMtime = m;
|
||||
if (name === LIVE_JOURNAL_FILE) {
|
||||
journalPath = full;
|
||||
journalMtime = m;
|
||||
continue;
|
||||
}
|
||||
// agent-<stem>.jsonl (transcript) or agent-<stem>.meta.json (queued-slot marker)
|
||||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
||||
if (!stem) continue;
|
||||
const slot = agentFiles.get(stem) || { agentId: stem };
|
||||
if (name.endsWith(LIVE_TRANSCRIPT_SUFFIX)) {
|
||||
slot.transcriptPath = full;
|
||||
slot.transcriptMtime = m;
|
||||
}
|
||||
agentFiles.set(stem, slot);
|
||||
}
|
||||
if (agentFiles.size === 0) return; // nothing to show yet
|
||||
|
||||
// Skip the expensive parse when nothing changed since the last synthesis.
|
||||
if (this.liveDirMtimes.get(live.dirPath) === newestMtime) return;
|
||||
this.liveDirMtimes.set(live.dirPath, newestMtime);
|
||||
|
||||
const info = await this.parseLiveDir(live, agentFiles, journalPath, journalMtime, newestMtime);
|
||||
if (!info) return;
|
||||
if (this.liveDirMtimes.get(live.dirPath) === info.lastActivityAt) return;
|
||||
this.liveDirMtimes.set(live.dirPath, info.lastActivityAt);
|
||||
|
||||
const existed = this.runs.has(info.runId);
|
||||
this.runs.set(info.runId, info);
|
||||
@@ -362,56 +542,87 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal ACTIVE WorkflowRunInfo from `subagents/workflows/wf_<id>/`.
|
||||
* The transcript tree carries no phases/tokens — those arrive with the
|
||||
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
|
||||
* card→transcript click still works), each marked done/running from journal
|
||||
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
|
||||
* Build an ACTIVE WorkflowRunInfo from a live transcript dir, ENRICHED so the
|
||||
* floating window / panel show real data mid-run rather than empty slots:
|
||||
* - per-agent tokens + tool-calls + model + last tool, parsed from each
|
||||
* `agent-<id>.jsonl` (mtime-cached — only changed transcripts are re-read);
|
||||
* - per-agent state: 'done' once the journal logs a `result` (→ green badge),
|
||||
* 'progress' once it logs `started` or a transcript exists (→ yellow), else 'start';
|
||||
* - run name/summary/phases from the live `workflows/scripts/<name>-<runId>.js`
|
||||
* (the completion wf_<id>.json, which carries these, only lands at the end);
|
||||
* - run totals = sums of the per-agent stats.
|
||||
*/
|
||||
private async parseLiveDir(live: DiscoveredLiveRun): Promise<WorkflowRunInfo | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(live.dirPath);
|
||||
} catch {
|
||||
return null; // vanished between discover and read
|
||||
}
|
||||
private async parseLiveDir(
|
||||
live: DiscoveredLiveRun,
|
||||
agentFiles: Map<string, LiveAgentFile>,
|
||||
journalPath: string | null,
|
||||
journalMtime: number,
|
||||
newestMtime: number
|
||||
): Promise<WorkflowRunInfo | null> {
|
||||
const { startedOrder, doneIds } = journalPath
|
||||
? await this.readJournal(journalPath, journalMtime)
|
||||
: { startedOrder: [] as string[], doneIds: new Set<string>() };
|
||||
const startIndex = new Map<string, number>();
|
||||
startedOrder.forEach((id, i) => startIndex.set(id, i));
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
let newestMtime = 0;
|
||||
for (const name of entries) {
|
||||
if (name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
||||
if (stem) agentIds.add(stem);
|
||||
}
|
||||
if (name === LIVE_JOURNAL_FILE || name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
try {
|
||||
const m = (await stat(join(live.dirPath, name))).mtimeMs;
|
||||
if (m > newestMtime) newestMtime = m;
|
||||
} catch {
|
||||
// entry vanished — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agentIds.size === 0) return null; // nothing to show yet
|
||||
// Order agents by journal launch order; not-yet-started ones trail (sorted by id).
|
||||
const ids = Array.from(agentFiles.keys()).sort((a, b) => {
|
||||
const ia = startIndex.has(a) ? (startIndex.get(a) as number) : Number.MAX_SAFE_INTEGER;
|
||||
const ib = startIndex.has(b) ? (startIndex.get(b) as number) : Number.MAX_SAFE_INTEGER;
|
||||
if (ia !== ib) return ia - ib;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
});
|
||||
|
||||
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
|
||||
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
|
||||
.sort()
|
||||
.map((id, i) => ({
|
||||
let totalTokens = 0;
|
||||
let totalToolCalls = 0;
|
||||
let defaultModel = '';
|
||||
const agents: WorkflowAgentInfo[] = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = ids[i];
|
||||
const file = agentFiles.get(id) as LiveAgentFile;
|
||||
const stats = file.transcriptPath
|
||||
? await this.agentTranscriptStats(file.transcriptPath, file.transcriptMtime || 0)
|
||||
: null;
|
||||
const state = doneIds.has(id) ? 'done' : startIndex.has(id) || file.transcriptPath ? 'progress' : 'start';
|
||||
if (stats) {
|
||||
totalTokens += stats.tokens;
|
||||
totalToolCalls += stats.toolCalls;
|
||||
if (stats.model && !defaultModel) defaultModel = stats.model;
|
||||
}
|
||||
agents.push({
|
||||
index: i + 1,
|
||||
label: `agent ${i + 1}`,
|
||||
phaseIndex: 1,
|
||||
phaseTitle: '',
|
||||
model: '',
|
||||
state: doneIds.has(id) ? 'done' : 'progress',
|
||||
model: stats?.model || '',
|
||||
state,
|
||||
agentId: id,
|
||||
}));
|
||||
tokens: stats ? stats.tokens : undefined,
|
||||
toolCalls: stats ? stats.toolCalls : undefined,
|
||||
lastToolName: stats?.lastToolName,
|
||||
promptPreview: stats?.promptPreview,
|
||||
});
|
||||
}
|
||||
|
||||
// Script meta is immutable for the life of a run, so derive it at most once.
|
||||
// Re-derive only while we still lack a name (the script can land a poll or two
|
||||
// after the first transcripts, which seed the run before the script exists).
|
||||
let meta = this.liveMetaCache.get(live.runId);
|
||||
if (!meta || !meta.workflowName) {
|
||||
meta = await this.deriveLiveWorkflowMeta(live);
|
||||
this.liveMetaCache.set(live.runId, meta);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: live.runId,
|
||||
workflowName: meta.workflowName,
|
||||
summary: meta.summary,
|
||||
status: 'running',
|
||||
agentCount: agents.length,
|
||||
phases: [],
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
defaultModel: defaultModel || undefined,
|
||||
phases: meta.phases,
|
||||
agents,
|
||||
sessionUuid: live.sessionUuid,
|
||||
projectHash: live.projectHash,
|
||||
@@ -419,25 +630,145 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/** Agent ids that already emitted a `result` event in the run journal. */
|
||||
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
|
||||
const done = new Set<string>();
|
||||
/** Parse one agent transcript for token/tool stats; cached on the file's mtime. */
|
||||
private async agentTranscriptStats(path: string, mtimeMs: number): Promise<AgentTranscriptStats | null> {
|
||||
// peek (not get) on the hit-check so a cache HIT doesn't churn the LRU — every
|
||||
// active agent is looked up each poll, and get() would delete+reinsert each time.
|
||||
const cached = this.agentStatCache.peek(path);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.stats;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path, 'utf-8');
|
||||
} catch {
|
||||
return null; // vanished mid-poll
|
||||
}
|
||||
let tokens = 0;
|
||||
let toolCalls = 0;
|
||||
let model = '';
|
||||
let lastToolName: string | undefined;
|
||||
let promptPreview: string | undefined;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: { type?: string; message?: unknown };
|
||||
try {
|
||||
entry = JSON.parse(line) as { type?: string; message?: unknown };
|
||||
} catch {
|
||||
continue; // tolerate a partially-written trailing line
|
||||
}
|
||||
const msg = entry.message as
|
||||
| {
|
||||
model?: string;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
content?: Array<{ type?: string; name?: string; text?: string }> | string;
|
||||
}
|
||||
| undefined;
|
||||
if (!promptPreview && entry.type === 'user' && msg) {
|
||||
promptPreview = truncate(extractMessageText(msg.content), PROMPT_PREVIEW_MAX);
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') continue;
|
||||
if (typeof msg.model === 'string' && msg.model) model = msg.model;
|
||||
const u = msg.usage;
|
||||
if (u) {
|
||||
const total =
|
||||
(u.input_tokens || 0) +
|
||||
(u.output_tokens || 0) +
|
||||
(u.cache_read_input_tokens || 0) +
|
||||
(u.cache_creation_input_tokens || 0);
|
||||
// Anthropic usage already reflects cumulative context, so the agent's running
|
||||
// token total ≈ its LATEST usage-bearing message — last non-zero wins.
|
||||
if (total > 0) tokens = total;
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block && block.type === 'tool_use' && typeof block.name === 'string') {
|
||||
toolCalls++;
|
||||
lastToolName = block.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const stats: AgentTranscriptStats = { tokens, toolCalls, model, lastToolName, promptPreview };
|
||||
this.agentStatCache.set(path, { mtimeMs, stats });
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the run journal: agent ids in `started` order plus the set that already
|
||||
* logged a terminal `result`. (`started` lines appear in launch order.)
|
||||
*/
|
||||
private async readJournal(
|
||||
journalPath: string,
|
||||
mtimeMs: number
|
||||
): Promise<{ startedOrder: string[]; doneIds: Set<string> }> {
|
||||
const cached = this.journalCache.get(journalPath);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.parsed;
|
||||
const startedOrder: string[] = [];
|
||||
const seenStarted = new Set<string>();
|
||||
const doneIds = new Set<string>();
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(journalPath, 'utf-8');
|
||||
} catch {
|
||||
return done; // journal not written yet — all agents still in progress
|
||||
return { startedOrder, doneIds }; // journal not written yet
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line) as { type?: string; agentId?: string };
|
||||
if (ev && ev.type === 'result' && typeof ev.agentId === 'string') done.add(ev.agentId);
|
||||
if (!ev || typeof ev.agentId !== 'string') continue;
|
||||
if (ev.type === 'started' && !seenStarted.has(ev.agentId)) {
|
||||
seenStarted.add(ev.agentId);
|
||||
startedOrder.push(ev.agentId);
|
||||
} else if (ev.type === 'result') {
|
||||
doneIds.add(ev.agentId);
|
||||
}
|
||||
} catch {
|
||||
// tolerate a partially-written trailing line
|
||||
}
|
||||
}
|
||||
return done;
|
||||
const parsed = { startedOrder, doneIds };
|
||||
this.journalCache.set(journalPath, { mtimeMs, parsed });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive name/summary/phases for a live run from its persisted script file
|
||||
* `…/workflows/scripts/<name>-<runId>.js`. The filename yields a reliable name;
|
||||
* `meta.description`/`meta.phases` are best-effort regex extractions (the script is
|
||||
* JS we must not eval), so any parse miss simply leaves those optional fields absent.
|
||||
*/
|
||||
private async deriveLiveWorkflowMeta(live: DiscoveredLiveRun): Promise<LiveWorkflowMeta> {
|
||||
const empty: LiveWorkflowMeta = { phases: [] };
|
||||
const scriptsDir = join(this.projectsDir, live.projectHash, live.sessionUuid, WORKFLOWS_SUBDIR, SCRIPTS_SUBDIR);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(scriptsDir);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
const suffix = `-${live.runId}.js`;
|
||||
const fileName = names.find((n) => n.endsWith(suffix)) || names.find((n) => n.includes(live.runId));
|
||||
if (!fileName) return empty;
|
||||
const nameFromFile = fileName.endsWith(suffix) ? fileName.slice(0, -suffix.length) : fileName.replace(/\.js$/, '');
|
||||
let body = '';
|
||||
try {
|
||||
body = (await readFile(join(scriptsDir, fileName), 'utf-8')).slice(0, SCRIPT_READ_MAX_BYTES);
|
||||
} catch {
|
||||
return { workflowName: nameFromFile || undefined, phases: [] };
|
||||
}
|
||||
// Scope name/summary/phases parsing to the `meta` literal so later script
|
||||
// declarations (schemas, prose) can't be mistaken for it.
|
||||
const metaBlock = extractMetaBlock(body) || body;
|
||||
return {
|
||||
workflowName: nameFromFile || extractMetaString(metaBlock, 'name') || undefined,
|
||||
summary: extractMetaString(metaBlock, 'description') || undefined,
|
||||
phases: extractMetaPhases(metaBlock),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,16 @@ export class MockSession extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
|
||||
* exercising the reliable-delivery path behave like production. */
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
this._appliedInputSeq.set(clientId, seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get the last written data */
|
||||
get lastWrite(): string | undefined {
|
||||
return this.writeBuffer[this.writeBuffer.length - 1];
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview Exactly-once input delivery — Session.shouldApplyInput dedup.
|
||||
*
|
||||
* Guards the server half of the reliable-input-delivery feature: the web client
|
||||
* tags each input frame with a stable clientId + a monotonic per-session seq and
|
||||
* redelivers anything it hasn't seen ACKed (a half-open socket silently drops
|
||||
* frames on a flaky link). shouldApplyInput must apply each (clientId, seq)
|
||||
* exactly once so a redelivery can never type the prompt twice — while still
|
||||
* applying untagged input (curl/legacy) unconditionally at the call sites.
|
||||
*
|
||||
* See docs/reliable-input-delivery.md.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
function makeSession(): Session {
|
||||
// workingDir is the only required field; no PTY is spawned until start(),
|
||||
// and TmuxManager no-ops under VITEST — so this is a cheap, side-effect-free
|
||||
// instance for exercising the pure dedup bookkeeping.
|
||||
return new Session({ workingDir: '/tmp' });
|
||||
}
|
||||
|
||||
describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
|
||||
it('applies a fresh (clientId, seq) exactly once', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('clientA', 1)).toBe(true);
|
||||
// Same seq redelivered (lost ACK) — must NOT apply again.
|
||||
expect(s.shouldApplyInput('clientA', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies strictly increasing seqs and rejects stale ones', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 2)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 3)).toBe(true);
|
||||
// Out-of-order / replayed lower seqs are duplicates.
|
||||
expect(s.shouldApplyInput('c', 2)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(false);
|
||||
// The next genuinely-new seq still applies.
|
||||
expect(s.shouldApplyInput('c', 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks each client independently', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('a', 5)).toBe(true);
|
||||
// A different client at seq 1 is not shadowed by client a's higher seq.
|
||||
expect(s.shouldApplyInput('b', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('b', 1)).toBe(false);
|
||||
expect(s.shouldApplyInput('a', 6)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates a seq gap (skips never collapse a new seq to a duplicate)', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||
// Client jumped seq (e.g. resumed after a reload that kept the counter).
|
||||
expect(s.shouldApplyInput('c', 100)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 100)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 50)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 101)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps recent clients dedup-correct past the eviction bound', () => {
|
||||
const s = makeSession();
|
||||
// Far exceed MAX_INPUT_DEDUP_CLIENTS (256) with one-shot clients, then prove
|
||||
// a freshly-active client is still deduped correctly (MRU eviction).
|
||||
for (let i = 0; i < 400; i++) {
|
||||
expect(s.shouldApplyInput(`oneshot-${i}`, 1)).toBe(true);
|
||||
}
|
||||
expect(s.shouldApplyInput('recent', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('recent', 1)).toBe(false);
|
||||
expect(s.shouldApplyInput('recent', 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -355,6 +355,43 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a tagged (clientId, seq) input exactly once on redelivery', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||
session.writeBuffer.length = 0;
|
||||
|
||||
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
|
||||
|
||||
// First delivery of seq 1 — applied (200, written once).
|
||||
const first = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
// Redelivery of the SAME seq (client never saw the ACK) — still 200, but
|
||||
// must NOT write again.
|
||||
const dup = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||
expect(dup.statusCode).toBe(200);
|
||||
|
||||
// A genuinely new seq — applied.
|
||||
const next = await post({ input: '\r', seq: 2, clientId: 'cid-1' });
|
||||
expect(next.statusCode).toBe(200);
|
||||
|
||||
expect(session.writeBuffer).toEqual(['prompt', '\r']);
|
||||
});
|
||||
|
||||
it('always applies untagged input (curl/legacy, no dedup)', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||
session.writeBuffer.length = 0;
|
||||
|
||||
const post = () => harness.app.inject({ method: 'POST', url, payload: { input: 'x' } });
|
||||
await post();
|
||||
await post();
|
||||
// No seq/clientId ⇒ no dedup ⇒ both writes land.
|
||||
expect(session.writeBuffer).toEqual(['x', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/resize ==========
|
||||
@@ -369,7 +406,7 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: undefined });
|
||||
});
|
||||
|
||||
it('passes viewport type through for resize arbitration', async () => {
|
||||
@@ -381,7 +418,19 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: undefined });
|
||||
});
|
||||
|
||||
it('passes force resize through for redraw requests', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 120, rows: 40, force: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
|
||||
});
|
||||
|
||||
it('rejects cols exceeding max (500)', async () => {
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40);
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -300,7 +300,22 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes force resize through for redraw requests', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40, f: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -342,7 +357,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1);
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -357,7 +372,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200);
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
|
||||
@@ -113,6 +113,15 @@ describe('Session resize arbitration', () => {
|
||||
expect(resize).toHaveBeenCalledWith(100, 30);
|
||||
});
|
||||
|
||||
it('applies forced resizes even when the dimensions did not change', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 120, 40);
|
||||
|
||||
session.resize(120, 40, { force: true });
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
|
||||
describe('idle-desktop override (whoever is active wins)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadTerminalUiHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
let now = 1_000;
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_fn: () => void) => 1,
|
||||
Blob: function Blob() {},
|
||||
URL: {
|
||||
createObjectURL: () => 'blob:yield',
|
||||
revokeObjectURL: () => {},
|
||||
},
|
||||
Worker: function Worker(this: any) {
|
||||
this.postMessage = () => {};
|
||||
},
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => true,
|
||||
},
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
|
||||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
return {
|
||||
app,
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElementHarness() {
|
||||
const listeners = new Map<string, (ev: any) => void>();
|
||||
return {
|
||||
element: {
|
||||
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
|
||||
listeners.set(type, listener);
|
||||
}),
|
||||
},
|
||||
dispatch(type: string, event: any) {
|
||||
listeners.get(type)?.(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('terminal touch tap mouse guard', () => {
|
||||
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
|
||||
const event = {
|
||||
isTrusted: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows the app synthetic mouse event through the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
|
||||
const event = {
|
||||
isTrusted: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows trusted mouse events after the tap window expires', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
setNow(2_000);
|
||||
|
||||
const event = {
|
||||
isTrusted: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -317,3 +317,99 @@ describe('WorkflowRunWatcher — in-flight (live) runs', () => {
|
||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Live ENRICHMENT: while a run is in-flight the watcher now parses each agent
|
||||
* transcript for real tokens/tool-calls/model, maps state→colour from the journal,
|
||||
* and derives the run name/summary/phases from the persisted script — so the
|
||||
* floating window/panel show real data mid-run instead of "0 tok / agent N".
|
||||
*/
|
||||
describe('WorkflowRunWatcher — live enrichment (tokens/state/name)', () => {
|
||||
const RUN = 'wf_enrich01-abc';
|
||||
let projectsDir: string;
|
||||
let watcher: WorkflowRunWatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-enrich-'));
|
||||
const sessionDir = join(projectsDir, PROJECT_HASH, SESSION_UUID);
|
||||
const liveDir = join(sessionDir, 'subagents', 'workflows', RUN);
|
||||
await mkdir(liveDir, { recursive: true });
|
||||
const scriptsDir = join(sessionDir, 'workflows', 'scripts');
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
|
||||
// Agent aaa: a user prompt + two assistant turns with usage + two tool_use blocks.
|
||||
await writeFile(
|
||||
join(liveDir, 'agent-aaa.jsonl'),
|
||||
[
|
||||
'{"type":"user","message":{"role":"user","content":"Audit the docs"}}',
|
||||
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":1000,"output_tokens":40},"content":[{"type":"tool_use","name":"Bash"}]}}',
|
||||
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":2000,"cache_read_input_tokens":500,"output_tokens":80},"content":[{"type":"tool_use","name":"Edit"},{"type":"text","text":"done"}]}}',
|
||||
].join('\n') + '\n',
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(join(liveDir, 'agent-aaa.meta.json'), JSON.stringify({ agentType: 'workflow-subagent' }), 'utf-8');
|
||||
// Agent bbb: started, no result yet, minimal transcript (no usage).
|
||||
await writeFile(join(liveDir, 'agent-bbb.jsonl'), '{"type":"assistant","message":{"content":[]}}\n', 'utf-8');
|
||||
// bbb starts BEFORE aaa, so journal launch order ['bbb','aaa'] differs from the
|
||||
// old alphabetical sort ['aaa','bbb'] — the ordering assertion below is adversarial.
|
||||
await writeFile(
|
||||
join(liveDir, 'journal.jsonl'),
|
||||
'{"type":"started","agentId":"bbb"}\n{"type":"started","agentId":"aaa"}\n{"type":"result","agentId":"aaa","result":{}}\n',
|
||||
'utf-8'
|
||||
);
|
||||
// Persisted script — name from filename, summary/phases from the meta literal.
|
||||
await writeFile(
|
||||
join(scriptsDir, `my-cool-workflow-${RUN}.js`),
|
||||
"export const meta = {\n name: 'my-cool-workflow',\n description: 'Audit and update the docs',\n phases: [ { title: 'Plan', detail: 'plan it' }, { title: 'Do', detail: 'do it' } ],\n}\n",
|
||||
'utf-8'
|
||||
);
|
||||
watcher = new WorkflowRunWatcher(projectsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
watcher.stop();
|
||||
await rm(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function firstRun(): Promise<WorkflowRunInfo> {
|
||||
return new Promise<WorkflowRunInfo>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('timed out')), 5000);
|
||||
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
|
||||
clearTimeout(t);
|
||||
resolve(info);
|
||||
});
|
||||
watcher.start();
|
||||
});
|
||||
}
|
||||
|
||||
it('derives workflowName/summary/phases from the live script file', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.workflowName).toBe('my-cool-workflow');
|
||||
expect(info.summary).toBe('Audit and update the docs');
|
||||
expect(info.phases.map((p) => p.title)).toEqual(['Plan', 'Do']);
|
||||
});
|
||||
|
||||
it('parses per-agent tokens (last usage-bearing message) + tool-call counts from the transcript', async () => {
|
||||
const info = await firstRun();
|
||||
const aaa = info.agents.find((a) => a.agentId === 'aaa')!;
|
||||
expect(aaa.tokens).toBe(2580); // last turn: 2000 in + 500 cache + 80 out
|
||||
expect(aaa.toolCalls).toBe(2); // Bash + Edit
|
||||
expect(aaa.model).toBe('claude-opus-4-8');
|
||||
expect(aaa.promptPreview).toBe('Audit the docs');
|
||||
});
|
||||
|
||||
it('maps state to done (→green) / progress (→yellow) from the journal', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.agents.find((a) => a.agentId === 'aaa')!.state).toBe('done');
|
||||
expect(info.agents.find((a) => a.agentId === 'bbb')!.state).toBe('progress');
|
||||
});
|
||||
|
||||
it('orders agents by journal launch order (NOT alphabetical) and sums run-level totals', async () => {
|
||||
const info = await firstRun();
|
||||
// bbb started first in the journal though it sorts after aaa — launch order wins.
|
||||
expect(info.agents.map((a) => a.agentId)).toEqual(['bbb', 'aaa']);
|
||||
expect(info.agents[0].label).toBe('agent 1'); // = bbb, the first-launched
|
||||
expect(info.totalToolCalls).toBe(2);
|
||||
expect(info.totalTokens).toBe(2580);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user