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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-06-15 16:33:54 +02:00
parent e6989bdb40
commit d16bf34e34
14 changed files with 584 additions and 12 deletions
+10
View File
@@ -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
+8 -6
View File
@@ -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/<projHash>/<sessionUuid>/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).
+2 -2
View File
@@ -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": [
+1 -1
View File
@@ -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",
+5
View File
@@ -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<agentId, { element, position }>
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
+8
View File
@@ -1106,6 +1106,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)">
<span class="settings-item-label">Ultracode Floating Windows</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsUltracodeFloatingWindows">
<span class="slider"></span>
</label>
</div>
<!-- Subagent Options Section -->
<div class="settings-section-header">Subagent Options</div>
@@ -1963,6 +1970,7 @@
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
<script defer src="ultracode-windows.js"></script>
<script defer src="image-input.js"></script>
</body>
</html>
+13
View File
@@ -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');
+123
View File
@@ -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
+9
View File
@@ -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;
+10
View File
@@ -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 */
+382
View File
@@ -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 = `
<div class="ultracode-window-header">
<div class="ultracode-window-title" title="${escapeHtml(titleText)}">
<span class="icon">🧬</span>
<span class="uw-name">${escapeHtml(titleText)}</span>
<span class="uw-status"></span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Collapse">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
<div class="ultracode-window-body" id="ultracode-window-body-${runId}">
<div class="subagent-empty">Loading agents…</div>
</div>
`;
// 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 = `<div class="uw-summary">${this._fmtNum(run.totalTokens)} tok · ${run.totalToolCalls ?? 0} tools · ${agentCount} agents</div>`;
if (!agents) {
// Summary-only (detail not fetched yet): show phase chips as a teaser.
if (phases.length) {
const chips = phases
.map(
(p) =>
`<span class="ultracode-phase-chip" title="${escapeHtml(p.detail || '')}">${escapeHtml(p.title || '')}</span>`
)
.join('');
return (
head + `<div class="ultracode-phase-list">${chips}</div><div class="subagent-empty">Loading agents…</div>`
);
}
return head + '<div class="subagent-empty">Loading agents…</div>';
}
if (!agents.length) return head + '<div class="subagent-empty">No agents yet</div>';
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 =
`<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('');
})
.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);
}
},
});
+8 -2
View File
@@ -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', () => {
+2
View File
@@ -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(),
+3 -1
View File
@@ -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<boolean> {
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);