From d16bf34e34a831ed92649c6b585f32dfda980bf7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 15 Jun 2026 16:33:54 +0200 Subject: [PATCH] feat(ultracode): floating run windows with tab connector lines + dedicated toggle Auto-popping draggable window per active ultracode/Workflow run, connected by a glowing line to its originating session tab (resolved via claudeSessionId === sessionUuid). Mirrors the live agent grid; auto-closes after a run finishes; dismissals are remembered. Additional to the existing docked panel. New "Ultracode Floating Windows" setting (default OFF), independent of the "Ultracode Agents" panel toggle; either toggle starts the workflow-run watcher. Also bumps version to 1.1.3 and brings CLAUDE.md up to date for the ultracode subsystem. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 + CLAUDE.md | 14 +- package-lock.json | 4 +- package.json | 2 +- src/web/public/app.js | 5 + src/web/public/index.html | 8 + src/web/public/settings-ui.js | 13 + src/web/public/styles.css | 123 +++++++++ src/web/public/subagent-windows.js | 9 + src/web/public/ultracode-panel.js | 10 + src/web/public/ultracode-windows.js | 382 ++++++++++++++++++++++++++++ src/web/routes/system-routes.ts | 10 +- src/web/schemas.ts | 2 + src/web/server.ts | 4 +- 14 files changed, 584 insertions(+), 12 deletions(-) create mode 100644 src/web/public/ultracode-windows.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2d209a..cd94e483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # aicodeman +## 1.1.3 + +### Patch Changes + +- Ultracode floating run windows + a dedicated toggle to control them. + - **New: floating ultracode run windows.** When enabled, each active ultracode / Workflow run pops a small draggable window (like the file browser) connected by a glowing line to its originating session tab — the same connector-line idiom as subagent windows. The tab is resolved by matching the run's `sessionUuid` to a session's `claudeSessionId`. The window mirrors the live agent grid (phases, per-agent model / tokens burned / tool calls / state), auto-closes a few seconds after its run finishes, and remembers windows you explicitly dismiss so they don't re-pop. These windows are **additional to** the existing docked "Ultracode Agents" master-detail panel, which is unchanged. + - **New setting "Ultracode Floating Windows"** (App Settings → Display), **default OFF**, independent of the "Ultracode Agents" panel toggle. Either toggle now starts the server-side workflow-run watcher (at boot and on live settings change), so the floating windows work even with the docked panel off. + - Internals: new frontend module `ultracode-windows.js` (load order 15.5); ultracode connector lines are appended into the shared `#connectionLines` SVG within the existing batched read/write reflow pass in `subagent-windows.js`; new `ultracodeFloatingWindows` app-settings key in `schemas.ts`; watcher gating in `server.ts` + `system-routes.ts` now ORs both ultracode toggles. + - Docs: `CLAUDE.md` brought up to date for the 1.1.2 ultracode/workflow-run subsystem (Agents / Frontend / Types / Config inventories, JS load order, a Key Patterns entry) and the new floating-windows feature. + ## 1.1.2 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index fb87bc87..b269f477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.2 (must match `package.json`) +**Version**: 1.1.3 (must match `package.json`) ## Project Overview @@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first | -| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | | +| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns | | **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | | | **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | | @@ -132,14 +132,14 @@ 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) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | | -| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.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) | +| **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`. **Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control). -**Config**: `src/config/` — 12 files, no barrel (`index.ts`) exists; import from the specific file. +**Config**: `src/config/` — 14 files, no barrel (`index.ts`) exists; import from the specific file. **Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority). @@ -174,11 +174,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. +**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects///workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is only started when the setting is on (`server.ts` gates on `showUltracodeAgents`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on the same `showUltracodeAgents` setting), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. + **Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`). ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7). diff --git a/package-lock.json b/package-lock.json index cf96bb08..ca46b78b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.1.2", + "version": "1.1.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 4de7c4d5..d741ab4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.1.2", + "version": "1.1.3", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 76d6befb..a1e530b6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -350,6 +350,11 @@ class CodemanApp { this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane) this.activeWorkflowRunId = null; this.activeWorkflowPhaseIndex = null; + // Ultracode floating run windows (additional to the dock panel — ultracode-windows.js) + this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed } + this.ultracodeWindowsClosed = new Set(); // runIds the user explicitly dismissed (don't re-pop) + this.ultracodeWindowCloseTimers = new Map(); // runId -> auto-close timeout + this.ultracodeWindowZIndex = 1000; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - minimized to tab diff --git a/src/web/public/index.html b/src/web/public/index.html index c41aea48..dc9ac53e 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1106,6 +1106,13 @@ +
+ Ultracode Floating Windows + +
Subagent Options
@@ -1963,6 +1970,7 @@ + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2dd58764..a356e1c7 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -314,6 +314,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; + document.getElementById('appSettingsUltracodeFloatingWindows').checked = + settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) @@ -1370,6 +1372,7 @@ Object.assign(CodemanApp.prototype, { showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, showSubagents: document.getElementById('appSettingsShowSubagents').checked, showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked, + ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, @@ -1685,6 +1688,7 @@ Object.assign(CodemanApp.prototype, { showFileBrowser: false, showSubagents: false, showUltracodeAgents: false, + ultracodeFloatingWindows: false, showMultiMonitorButton: false, showPlanUsageLimits: false, showAttachmentsButton: false, @@ -1899,6 +1903,15 @@ Object.assign(CodemanApp.prototype, { ultracodePanel.classList.add('hidden'); } } + // Floating ultracode run windows have their OWN opt-in (default OFF), independent of the + // docked panel above: pop active runs when enabled, tear them all down when disabled + // (additional layer — ultracode-windows.js). + const ultracodeFloatingWindows = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; + if (ultracodeFloatingWindows) { + if (typeof this.syncAllUltracodeFloatingWindows === 'function') this.syncAllUltracodeFloatingWindows(); + } else if (typeof this.removeAllUltracodeWindows === 'function') { + this.removeAllUltracodeWindows(); + } // File browser panel visibility const fileBrowserPanel = document.getElementById('fileBrowserPanel'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ad66aeb5..8bfc9286 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8556,6 +8556,129 @@ kbd { white-space: nowrap; } +/* ========== Ultracode floating run windows (additional to the dock panel) ========== */ +/* A draggable window that auto-pops while a Workflow/ultracode run is active and is + tied by a glowing connector line to the originating session tab (ultracode-windows.js). */ +.ultracode-window { + position: fixed; + width: 380px; + height: 320px; + min-width: 260px; + min-height: 140px; + max-width: 92vw; + max-height: 80vh; + background: var(--bg-card); + border: 1px solid #a855f7; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(168, 85, 247, 0.15); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + resize: both; + contain: layout paint; + transition: transform 0.18s ease, opacity 0.18s ease; +} +.ultracode-window.spawning { + 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; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + background: var(--bg-input); + border-bottom: 1px solid var(--border); + cursor: move; +} +.ultracode-window-title { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} +.ultracode-window-title .icon { + font-size: 0.95rem; + flex-shrink: 0; +} +.ultracode-window-title .uw-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ultracode-window-title .uw-status { + flex-shrink: 0; + margin-left: 0; +} +.ultracode-window-actions { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} +.ultracode-window-actions button { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.15rem 0.4rem; + font-size: 0.9rem; + line-height: 1; +} +.ultracode-window-actions button:hover { + color: var(--text); +} +.ultracode-window-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0.5rem; + background: var(--bg); + min-height: 0; +} +.ultracode-window-body::-webkit-scrollbar { + width: 6px; +} +.ultracode-window-body::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +.ultracode-window-body .uw-summary { + font-size: 0.65rem; + color: var(--text-dim); + font-family: var(--font-mono); + margin-bottom: 0.4rem; +} + +/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */ +.connection-line.ultracode-connection { + stroke: #a855f7; + 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)); + animation: ultracode-conn-pulse 1.4s ease-in-out infinite; +} +.connection-line.ultracode-connection:hover { + stroke-width: 3.5; +} +@keyframes ultracode-conn-pulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + /* 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 diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 0f7c7b76..3d21b292 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -455,6 +455,12 @@ Object.assign(CodemanApp.prototype, { svg.appendChild(line); } } + + // Ultracode floating run windows → parent tab (additional layer, ultracode-windows.js). + // Drawn into the same SVG and same batched read/write pass; the tab-rect cache is shared. + if (typeof this._appendUltracodeConnectionLines === 'function') { + this._appendUltracodeConnectionLines(svg, rects); + } }, // ═══════════════════════════════════════════════════════════════ @@ -975,6 +981,9 @@ Object.assign(CodemanApp.prototype, { } this.imagePopups.clear(); + // Clean up ultracode floating run windows (re-seeded from data.workflowRuns on reconnect) + if (typeof this.removeAllUltracodeWindows === 'function') this.removeAllUltracodeWindows(); + // Clear orphaned plan generation state this.activePlanOrchestratorId = null; this._planProgressHandler = null; diff --git a/src/web/public/ultracode-panel.js b/src/web/public/ultracode-panel.js index fb97078e..c3f20d21 100644 --- a/src/web/public/ultracode-panel.js +++ b/src/web/public/ultracode-panel.js @@ -31,6 +31,10 @@ Object.assign(CodemanApp.prototype, { this._ensureWorkflowState(); this.workflowRuns.clear(); (summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s)); + // Restore floating windows for runs that are still active & recent (additional layer). + if (typeof this._syncUltracodeFloatingWindow === 'function') { + (summaries || []).forEach((s) => this._syncUltracodeFloatingWindow(s, { fromSeed: true })); + } this.renderUltracodeAgentsPanel(); }, @@ -47,6 +51,8 @@ Object.assign(CodemanApp.prototype, { this.workflowRuns.delete(data.runId); this.workflowRunDetails.delete(data.runId); if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null; + // Retire the floating run window too (additional layer — ultracode-windows.js). + if (typeof this.closeUltracodeWindow === 'function') this.closeUltracodeWindow(data.runId, false); this.renderUltracodeAgentsPanel(); }, @@ -58,6 +64,8 @@ Object.assign(CodemanApp.prototype, { if (this.activeWorkflowRunId === summary.runId) { this._fetchWorkflowRunDetail(summary.runId); } + // Auto-pop / refresh the floating run window for active runs (additional layer). + if (typeof this._syncUltracodeFloatingWindow === 'function') this._syncUltracodeFloatingWindow(summary); this.renderUltracodeAgentsPanel(); }, @@ -97,6 +105,8 @@ Object.assign(CodemanApp.prototype, { if (run) { this.workflowRunDetails.set(runId, run); if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail(); + // Refresh the floating window (if one is open for this run) with the fetched agents[]. + if (this.ultracodeWindows && this.ultracodeWindows.has(runId)) this.renderUltracodeWindowContent(runId); } } catch { /* transient — next update retries */ diff --git a/src/web/public/ultracode-windows.js b/src/web/public/ultracode-windows.js new file mode 100644 index 00000000..64eda6b1 --- /dev/null +++ b/src/web/public/ultracode-windows.js @@ -0,0 +1,382 @@ +/** + * @fileoverview Ultracode floating run windows — auto-popping draggable windows + * with a connector line to the originating session tab. + * + * This is the "floating thing" companion to the docked master-detail panel in + * `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL). + * When the `showUltracodeAgents` setting is on, a small floating window pops up + * for each ACTIVE ultracode/Workflow run (status not completed/killed/failed), + * mirroring the live agent grid, and is connected by a glowing line to the + * Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same + * line idiom subagent windows use. The window auto-closes a few seconds after + * its run finishes; an explicitly-closed run is remembered and never re-pops. + * + * Reuses, rather than duplicates: + * - `makeWindowDraggable` + the shared `#connectionLines` SVG (subagent-windows.js) + * - `_workflowAgentCardHtml`, `_fmtNum`, `_workflowStatusClass`, `_fetchWorkflowRunDetail`, + * and the `workflowRuns` / `workflowRunDetails` maps (ultracode-panel.js) + * + * The connector-line draw is appended to the shared SVG from inside + * `_updateConnectionLinesImmediate` (subagent-windows.js calls + * `_appendUltracodeConnectionLines` at the end of its render pass), so both the + * subagent and ultracode lines live in one batched read→write reflow pass. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency subagent-windows.js (makeWindowDraggable, updateConnectionLines, #connectionLines) + * @dependency ultracode-panel.js (workflowRuns/workflowRunDetails, _workflowAgentCardHtml, _fmtNum) + * @loadorder 15.5 (after subagent-windows.js — needs makeWindowDraggable at runtime) + */ +/* global CodemanApp, escapeHtml */ + +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.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed + if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id + if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000; + }, + + /** Floating windows have their own opt-in (default OFF), independent of the dock panel. */ + _ultracodeFloatingEnabled() { + const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {}; + return !!(settings && settings.ultracodeFloatingWindows); + }, + + /** A run is "working" until it reaches a terminal status. Mid-run status is absent. */ + _isWorkflowRunActive(run) { + const s = String((run && run.status) || ''); + return !(s === 'completed' || s === 'killed' || s === 'failed'); + }, + + /** + * Resolve which Codeman tab a run belongs to: the session whose + * `claudeSessionId` equals the run's `sessionUuid` (the path segment the watcher + * captured). Falls back to the active session so the line still lands somewhere. + */ + _resolveUltracodeParentSession(run) { + const uuid = run && run.sessionUuid; + if (uuid && this.sessions) { + for (const [sessionId, session] of this.sessions) { + if (session && session.claudeSessionId === uuid) return sessionId; + } + } + if (this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) { + return this.activeSessionId; + } + return null; + }, + + /** + * Auto-pop driver — called for every run discovered/updated and on reconnect seed. + * Creates a floating window for active runs, refreshes existing ones, and schedules + * an auto-close once a run finishes. + */ + _syncUltracodeFloatingWindow(run, opts) { + this._ensureUltracodeWindowState(); + if (!run || !run.runId) return; + if (!this._ultracodeFloatingEnabled()) return; + const runId = run.runId; + if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal + + const active = this._isWorkflowRunActive(run); + const existing = this.ultracodeWindows.get(runId); + + if (active) { + // Run is alive — cancel any pending auto-close. + const pending = this.ultracodeWindowCloseTimers.get(runId); + if (pending) { + clearTimeout(pending); + this.ultracodeWindowCloseTimers.delete(runId); + } + if (existing) { + this.renderUltracodeWindowContent(runId); + this._fetchWorkflowRunDetail(runId); // refresh agents[]; re-renders window on land + } else { + // On a reconnect snapshot, only restore windows for genuinely recent runs so + // a backlog of stale undefined-status runs doesn't carpet the screen. + if (opts && opts.fromSeed) { + const FLOAT_SEED_MAX_AGE_MS = 5 * 60 * 1000; + const age = Date.now() - (run.lastActivityAt || 0); + if (!(run.lastActivityAt && age < FLOAT_SEED_MAX_AGE_MS)) return; + } + this.createUltracodeWindow(run); + } + } else if (existing) { + // Finished — refresh to the final state (status + final agent states), show it + // briefly, then retire the floating window. + this._fetchWorkflowRunDetail(runId); + this.renderUltracodeWindowContent(runId); + if (!this.ultracodeWindowCloseTimers.has(runId)) { + const FLOAT_FINISH_GRACE_MS = 8000; + const timer = setTimeout(() => { + this.ultracodeWindowCloseTimers.delete(runId); + this.closeUltracodeWindow(runId, false); + }, FLOAT_FINISH_GRACE_MS); + this.ultracodeWindowCloseTimers.set(runId, timer); + } + } + }, + + /** Build and mount a floating window for a run, positioned near its parent tab. */ + createUltracodeWindow(run) { + this._ensureUltracodeWindowState(); + const runId = run.runId; + if (this.ultracodeWindows.has(runId)) return; + const parentSessionId = this._resolveUltracodeParentSession(run); + const titleText = run.workflowName || run.summary || runId; + + const win = document.createElement('div'); + win.className = 'ultracode-window spawning'; + win.id = `ultracode-window-${runId}`; + win.style.zIndex = ++this.ultracodeWindowZIndex; + win.innerHTML = ` +
+
+ 🧬 + ${escapeHtml(titleText)} + +
+
+ + +
+
+
+
Loading agents…
+
+ `; + + // Position: spawn from the parent tab if we can find it, else cascade. + const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null; + if (parentTab) { + const r = parentTab.getBoundingClientRect(); + const left = Math.max(8, Math.min(r.left, window.innerWidth - 392)); + win.style.left = `${left}px`; + win.style.top = `${r.bottom + 14}px`; + } else { + const n = this.ultracodeWindows.size; + win.style.left = `${24 + n * 26}px`; + win.style.top = `${96 + n * 26}px`; + } + + document.body.appendChild(win); + // Drop the spawn class on the next frame so the transition runs. + 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.toggleUltracodeWindowCollapse(runId); + }); + win.querySelector('.uw-close').addEventListener('click', (e) => { + e.stopPropagation(); + this.closeUltracodeWindow(runId, true); + }); + const nameEl = win.querySelector('.uw-name'); + if (parentSessionId) { + nameEl.style.cursor = 'pointer'; + nameEl.title = 'Go to session'; + nameEl.addEventListener('click', () => this.selectSession(parentSessionId)); + } + + this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false }); + + 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) { + const data = this.ultracodeWindows.get(runId); + if (!data) return; + data.collapsed = !data.collapsed; + data.element.classList.toggle('collapsed', data.collapsed); + this.updateConnectionLines(); + }, + + /** Remove a floating window. `userInitiated` records a dismissal so it won't re-pop. */ + closeUltracodeWindow(runId, userInitiated) { + this._ensureUltracodeWindowState(); + const pending = this.ultracodeWindowCloseTimers.get(runId); + if (pending) { + clearTimeout(pending); + this.ultracodeWindowCloseTimers.delete(runId); + } + const data = this.ultracodeWindows.get(runId); + if (userInitiated) this.ultracodeWindowsClosed.add(runId); + if (!data) return; + this._teardownUltracodeDrag(data.dragListeners); + data.element.remove(); + this.ultracodeWindows.delete(runId); + this.updateConnectionLines(); + }, + + /** Detach the document-level drag listeners returned by makeWindowDraggable. */ + _teardownUltracodeDrag(dl) { + if (!dl) return; + document.removeEventListener('mousemove', dl.move); + document.removeEventListener('mouseup', dl.up); + if (dl.touchMove) { + document.removeEventListener('touchmove', dl.touchMove); + document.removeEventListener('touchend', dl.up); + document.removeEventListener('touchcancel', dl.up); + } + if (dl.handle) { + dl.handle.removeEventListener('mousedown', dl.handleMouseDown); + dl.handle.removeEventListener('touchstart', dl.handleTouchStart); + } + }, + + /** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */ + removeAllUltracodeWindows() { + this._ensureUltracodeWindowState(); + const had = this.ultracodeWindows.size > 0; + for (const [, data] of this.ultracodeWindows) { + this._teardownUltracodeDrag(data.dragListeners); + if (data.element) data.element.remove(); + } + this.ultracodeWindows.clear(); + for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t); + this.ultracodeWindowCloseTimers.clear(); + // Redraw so the now-orphaned connector lines are cleared from the shared SVG. + if (had) this.updateConnectionLines(); + }, + + /** When the feature is toggled on, pop windows for any currently-active runs. */ + syncAllUltracodeFloatingWindows() { + this._ensureUltracodeWindowState(); + if (!this._ultracodeFloatingEnabled()) { + this.removeAllUltracodeWindows(); + return; + } + if (!this.workflowRuns) return; + for (const run of this.workflowRuns.values()) { + this._syncUltracodeFloatingWindow(run, { fromSeed: true }); + } + }, + + /** Refresh a floating window's header + body from the latest summary/detail. */ + renderUltracodeWindowContent(runId) { + const data = this.ultracodeWindows.get(runId); + if (!data) return; + const summary = this.workflowRuns && this.workflowRuns.get(runId); + const detail = this.workflowRunDetails && this.workflowRunDetails.get(runId); + // Summary is the freshest run-level info (every SSE tick); detail supplies agents[] + // but is fetched less often. Merge so a completed summary isn't masked by stale detail. + const run = summary && detail ? { ...detail, ...summary, agents: detail.agents } : detail || summary; + if (!run) return; + + const nameEl = data.element.querySelector('.uw-name'); + if (nameEl) nameEl.textContent = run.workflowName || run.summary || runId; + + const statusEl = data.element.querySelector('.uw-status'); + if (statusEl) { + const finished = !this._isWorkflowRunActive(run); + const label = run.status ? String(run.status) : finished ? '—' : 'running'; + const clsKey = run.status ? run.status : finished ? '' : 'running'; + statusEl.textContent = label; + statusEl.className = 'uw-status ultracode-status ' + this._workflowStatusClass(clsKey); + } + + const body = data.element.querySelector('.ultracode-window-body'); + if (body) body.innerHTML = this._ultracodeWindowBodyHtml(run); + }, + + /** Compact body: a stats line + agent cards grouped by phase (reuses panel helpers). */ + _ultracodeWindowBodyHtml(run) { + const phases = Array.isArray(run.phases) ? run.phases : []; + const agents = Array.isArray(run.agents) ? run.agents : null; + const agentCount = run.agentCount ?? (agents ? agents.length : 0); + const head = `
${this._fmtNum(run.totalTokens)} tok · ${run.totalToolCalls ?? 0} tools · ${agentCount} agents
`; + + if (!agents) { + // Summary-only (detail not fetched yet): show phase chips as a teaser. + if (phases.length) { + const chips = phases + .map( + (p) => + `${escapeHtml(p.title || '')}` + ) + .join(''); + return ( + head + `
${chips}
Loading agents…
` + ); + } + return head + '
Loading agents…
'; + } + if (!agents.length) return head + '
No agents yet
'; + + const groups = new Map(); + agents.forEach((a) => { + const key = a.phaseIndex || 0; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(a); + }); + const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b); + const grid = orderedKeys + .map((key) => { + const group = groups.get(key); + const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`; + const tok = group.reduce((s, a) => s + (a.tokens || 0), 0); + const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0); + const header = + `
${escapeHtml(title)}` + + `${this._fmtNum(tok)} tok · ${tools} tools
`; + return header + group.map((a) => this._workflowAgentCardHtml(a)).join(''); + }) + .join(''); + return head + grid; + }, + + /** + * Append ultracode-window → parent-tab connector lines into the shared SVG. + * Invoked at the tail of `_updateConnectionLinesImmediate` (subagent-windows.js), + * so it shares that pass's batched read/write discipline. `rects` is the tab-rect + * cache already populated for subagent lines — reuse it, fill any gaps. + */ + _appendUltracodeConnectionLines(svg, rects) { + this._ensureUltracodeWindowState(); + if (!svg || !this.ultracodeWindows.size) return; + if (!rects) rects = new Map(); + + // PHASE 1: layout reads (resolve parents, batch getBoundingClientRect). + const winList = []; + for (const [runId, data] of this.ultracodeWindows) { + if (!data.element) continue; + if (!data.parentSessionId) { + const summary = this.workflowRuns && this.workflowRuns.get(runId); + if (summary) data.parentSessionId = this._resolveUltracodeParentSession(summary); + } + const parentSessionId = data.parentSessionId; + 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()); + } + winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() }); + } + + // PHASE 2: writes (curve from tab bottom-center to window top-center). + for (const { runId, parentSessionId, winRect } of winList) { + const tabRect = rects.get('tab:' + parentSessionId); + if (!tabRect) continue; + const x1 = tabRect.left + tabRect.width / 2; + const y1 = tabRect.bottom; + const x2 = winRect.left + winRect.width / 2; + const y2 = winRect.top; + const midY = (y1 + y2) / 2; + const path = `M ${x1} ${y1} C ${x1} ${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'); + line.setAttribute('data-run-id', runId); + line.setAttribute('data-parent-tab', parentSessionId); + svg.appendChild(line); + } + }, +}); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index b15c8b56..5c3f54df 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -541,8 +541,14 @@ export function registerSystemRoutes( // Handle subagent tracking toggle dynamically toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); - // Handle ultracode/workflow run watcher toggle dynamically (default OFF) - toggleService((settings.showUltracodeAgents as boolean) ?? false, workflowRunWatcher, 'Workflow run watcher'); + // Handle ultracode/workflow run watcher toggle dynamically (default OFF). + // Either the docked panel OR the floating windows keep the watcher running. + toggleService( + ((settings.showUltracodeAgents as boolean) ?? false) || + ((settings.ultracodeFloatingWindows as boolean) ?? false), + workflowRunWatcher, + 'Workflow run watcher' + ); // Handle image watcher toggle dynamically toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 2b4ceaa3..ffe049f4 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -350,6 +350,8 @@ export const SettingsUpdateSchema = z subagentActiveTabOnly: z.boolean().optional(), /** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */ showUltracodeAgents: z.boolean().optional(), + /** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */ + ultracodeFloatingWindows: z.boolean().optional(), imageWatcherEnabled: z.boolean().optional(), tunnelEnabled: z.boolean().optional(), tabTwoRows: z.boolean().optional(), diff --git a/src/web/server.ts b/src/web/server.ts index 4d60470b..a156ec11 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2026,13 +2026,15 @@ export class WebServer extends EventEmitter { /** * Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in). + * The watcher feeds BOTH the docked Ultracode Agents panel (`showUltracodeAgents`) and the + * floating run windows (`ultracodeFloatingWindows`), so either toggle starts it. */ private async isWorkflowAgentTrackingEnabled(): Promise { const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); - return settings.showUltracodeAgents ?? false; + return (settings.showUltracodeAgents ?? false) || (settings.ultracodeFloatingWindows ?? false); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read showUltracodeAgents setting:', err);