mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-27 19:39:33 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d406c9705 | ||
|
|
a8782b364f | ||
|
|
d8da1bd3ff | ||
|
|
06871eb7e3 | ||
|
|
5d59c1764d | ||
|
|
cfcd9d288b | ||
|
|
9c22114b5a | ||
|
|
98b2124d7e |
No files matched your search
@@ -1,5 +1,55 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
## 1.1.10
|
||||||
|
|
||||||
### Patch Changes
|
### 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.
|
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.10 (must match `package.json`)
|
**Version**: 1.1.15 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -218,11 +218,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### 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
|
### 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`).
|
**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`).
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.10",
|
"version": "1.1.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.10",
|
"version": "1.1.15",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.10",
|
"version": "1.1.15",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -2773,12 +2773,16 @@ class CodemanApp {
|
|||||||
const minimizedCount = minimizedAgents?.size || 0;
|
const minimizedCount = minimizedAgents?.size || 0;
|
||||||
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
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
|
// Show folder name if session has a custom name AND tall tabs setting is enabled
|
||||||
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
||||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
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>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
@@ -2792,9 +2796,10 @@ class CodemanApp {
|
|||||||
</span>
|
</span>
|
||||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||||
${subagentBadge}
|
${subagentBadge}
|
||||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
${ultracodeBadge}
|
||||||
<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-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" 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>`);
|
</div>`);
|
||||||
_tabIdx++;
|
_tabIdx++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ const KeyboardAccessoryBar = {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
<button class="accessory-btn" data-action="esc" title="Escape">Esc</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">
|
<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">
|
<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"/>
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ class NotificationManager {
|
|||||||
const readClass = n.read ? '' : ' unread';
|
const readClass = n.read ? '' : ' unread';
|
||||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
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>` : '';
|
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">
|
<div class="notif-item-header">
|
||||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||||
|
|||||||
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let actions = '';
|
let actions = '';
|
||||||
if (orchState === 'executing' || orchState === 'failed') {
|
if (orchState === 'executing' || orchState === 'failed') {
|
||||||
if (phase.status === 'pending') {
|
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') {
|
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>';
|
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||||
html.push(`
|
html.push(`
|
||||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||||
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
|
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||||
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
|
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||||
title="Double-click to open tracking window">
|
title="Double-click to open tracking window">
|
||||||
<div class="subagent-header">
|
<div class="subagent-header">
|
||||||
${agentIcon}
|
${agentIcon}
|
||||||
@@ -762,8 +762,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
${teammateBadge}
|
${teammateBadge}
|
||||||
${modelBadge}
|
${modelBadge}
|
||||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
<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>` : ''}
|
${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(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
<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 ? '✕' : '⧉'}
|
${hasWindow ? '✕' : '⧉'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +810,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||||
<span class="name">${escapeHtml(a.tool)}</span>
|
<span class="name">${escapeHtml(a.tool)}</span>
|
||||||
<span class="detail">${escapeHtml(toolDetail.primary)}</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>` : ''}
|
${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>`;
|
</div>`;
|
||||||
} else if (a.type === 'tool_result') {
|
} 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>
|
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||||||
${modelBadge}
|
${modelBadge}
|
||||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
<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
|
View Full Transcript
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1195,7 +1195,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
parentDiv.dataset.parentSession = parentSessionId;
|
parentDiv.dataset.parentSession = parentSessionId;
|
||||||
parentDiv.innerHTML = `
|
parentDiv.innerHTML = `
|
||||||
<span class="parent-label">from</span>
|
<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);
|
header.insertAdjacentElement('afterend', parentDiv);
|
||||||
}
|
}
|
||||||
@@ -1687,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status running">terminal</span>
|
<span class="status running">terminal</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-window-actions">
|
<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>
|
</div>
|
||||||
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
<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();
|
const fileName = path.split('/').pop();
|
||||||
html.push(`
|
html.push(`
|
||||||
<span class="project-insight-filepath"
|
<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>
|
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -3099,7 +3099,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status streaming">streaming</span>
|
<span class="status streaming">streaming</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-viewer-window-actions">
|
<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>
|
</div>
|
||||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
<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>
|
<span class="size-badge">${sizeKB} KB</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-popup-actions">
|
<div class="image-popup-actions">
|
||||||
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
|
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
|
||||||
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
|
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-popup-body">
|
<div class="image-popup-body">
|
||||||
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
||||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -3505,9 +3505,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sid = escapeHtml(muxSession.sessionId);
|
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
|
||||||
html += `
|
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>
|
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||||
<div class="process-info">
|
<div class="process-info">
|
||||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||||
@@ -3520,7 +3520,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="process-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -3563,7 +3563,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="process-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1385,11 +1385,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-manage-actions">
|
<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>
|
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>
|
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>
|
title="Delete case">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1484,14 +1484,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const isSelected = c.name === currentCase;
|
const isSelected = c.name === currentCase;
|
||||||
html += `
|
html += `
|
||||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
<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">
|
<span class="mobile-case-item-icon">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</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">
|
<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"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
+93
-16
@@ -5573,6 +5573,45 @@ kbd {
|
|||||||
letter-spacing: 0.03em;
|
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 - compact, hover-triggered */
|
||||||
.subagent-dropdown {
|
.subagent-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -8473,14 +8512,14 @@ kbd {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ultracode-status.completed {
|
.ultracode-status.completed {
|
||||||
background: var(--success);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
.ultracode-status.active {
|
.ultracode-status.active {
|
||||||
background: var(--warning);
|
background: var(--yellow);
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
.ultracode-status.failed {
|
.ultracode-status.failed {
|
||||||
background: var(--error, #b3261e);
|
background: var(--red, #b3261e);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8531,6 +8570,16 @@ kbd {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
margin-bottom: 0.35rem;
|
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 {
|
.ultracode-agent-card--clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -8539,6 +8588,14 @@ kbd {
|
|||||||
.ultracode-agent-card--clickable:hover {
|
.ultracode-agent-card--clickable:hover {
|
||||||
background: var(--bg-card);
|
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 {
|
.ultracode-agent-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -8564,10 +8621,10 @@ kbd {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.ultracode-agent-state.completed {
|
.ultracode-agent-state.completed {
|
||||||
background: var(--success);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
.ultracode-agent-state.active {
|
.ultracode-agent-state.active {
|
||||||
background: var(--warning);
|
background: var(--yellow);
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
.ultracode-agent-state.idle {
|
.ultracode-agent-state.idle {
|
||||||
@@ -8629,13 +8686,6 @@ kbd {
|
|||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.ultracode-window.collapsed {
|
|
||||||
height: auto !important;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
.ultracode-window.collapsed .ultracode-window-body {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.ultracode-window-header {
|
.ultracode-window-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -8707,14 +8757,15 @@ kbd {
|
|||||||
margin-bottom: 0.4rem;
|
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 {
|
.connection-line.ultracode-connection {
|
||||||
stroke: #a855f7;
|
stroke: var(--accent);
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
stroke-dasharray: 6 3;
|
stroke-dasharray: 6 3;
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
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 5px rgba(var(--accent-rgb), 0.85))
|
||||||
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
|
drop-shadow(0 0 10px rgba(var(--accent-rgb), 0.5));
|
||||||
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
|
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.connection-line.ultracode-connection:hover {
|
.connection-line.ultracode-connection:hover {
|
||||||
@@ -8725,6 +8776,32 @@ kbd {
|
|||||||
50% { opacity: 1; }
|
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
|
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
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
|
default via the marker class below; the server strips it at render when the
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||||||
const statusClass = agent?.status || 'idle';
|
const statusClass = agent?.status || 'idle';
|
||||||
agentItems.push(`
|
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-status ${statusClass}"></span>
|
||||||
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</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>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
||||||
this._appendUltracodeConnectionLines(svg, rects);
|
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
|
parentSessionId && parentSessionName
|
||||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||||
<span class="parent-label">from</span>
|
<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>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status ${agent.status}">${agent.status}</span>
|
<span class="status ${agent.status}">${agent.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-window-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${parentHeader}
|
${parentHeader}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
|
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
|
||||||
this._fetchWorkflowRunDetail(runId);
|
this._fetchWorkflowRunDetail(runId);
|
||||||
this.renderUltracodeAgentsPanel();
|
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) {
|
selectWorkflowPhase(phaseIndex) {
|
||||||
this._ensureWorkflowState();
|
this._ensureWorkflowState();
|
||||||
@@ -105,6 +109,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async _fetchWorkflowRunDetail(runId) {
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
|
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
|
||||||
const env = await res.json();
|
const env = await res.json();
|
||||||
@@ -117,15 +126,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* transient — next update retries */
|
/* 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
|
// 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.
|
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
// Returns { formatted: string[], entryCount } or null when nothing is available
|
||||||
async openWorkflowAgentTranscript(agentId) {
|
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
|
||||||
if (!agentId) return;
|
// 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;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
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 ok = data && data.success && data.data;
|
||||||
const formatted = ok ? data.data.formatted : null;
|
const formatted = ok ? data.data.formatted : null;
|
||||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||||
if (!formatted || !entryCount) {
|
if (!formatted || !entryCount) return null;
|
||||||
alert(
|
return { formatted, entryCount };
|
||||||
'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();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----- Render (debounced) -----
|
// ----- Render (debounced) -----
|
||||||
@@ -203,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||||
}
|
}
|
||||||
return (
|
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>` +
|
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||||
@@ -251,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header =
|
const header =
|
||||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
`<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('');
|
.join('');
|
||||||
detail.innerHTML = html;
|
detail.innerHTML = html;
|
||||||
},
|
},
|
||||||
|
|
||||||
_workflowAgentCardHtml(a) {
|
_workflowAgentCardHtml(a, runId) {
|
||||||
const state = String(a.state || 'start');
|
const state = String(a.state || 'start');
|
||||||
const stateCls = this._workflowAgentStateClass(state);
|
const stateCls = this._workflowAgentStateClass(state);
|
||||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : 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
|
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
|
||||||
// no agentId yet, so they stay non-clickable.
|
// no agentId yet, so they stay non-clickable.
|
||||||
const clickable = !!a.agentId;
|
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
|
const cardAttrs = clickable
|
||||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
|
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
|
||||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
` title="View transcript" onclick="app.openUltracodeAgentWindow(${escapeHtml(JSON.stringify(a.agentId))},${escapeHtml(JSON.stringify(runId || ''))})"`
|
||||||
: ` class="ultracode-agent-card"`;
|
: ` class="ultracode-agent-card${cardStateCls}"`;
|
||||||
return (
|
return (
|
||||||
`<div${cardAttrs}>` +
|
`<div${cardAttrs}>` +
|
||||||
`<div class="ultracode-agent-top">` +
|
`<div class="ultracode-agent-top">` +
|
||||||
|
|||||||
@@ -33,9 +33,12 @@
|
|||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
||||||
_ensureUltracodeWindowState() {
|
_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.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
||||||
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
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;
|
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -77,12 +80,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_syncUltracodeFloatingWindow(run, opts) {
|
_syncUltracodeFloatingWindow(run, opts) {
|
||||||
this._ensureUltracodeWindowState();
|
this._ensureUltracodeWindowState();
|
||||||
if (!run || !run.runId) return;
|
if (!run || !run.runId) return;
|
||||||
if (!this._ultracodeFloatingEnabled()) return;
|
|
||||||
const runId = run.runId;
|
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
|
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
|
||||||
|
|
||||||
const active = this._isWorkflowRunActive(run);
|
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) {
|
if (active) {
|
||||||
// Run is alive — cancel any pending auto-close.
|
// 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. */
|
/** Build and mount a floating window for a run, positioned near its parent tab. */
|
||||||
createUltracodeWindow(run) {
|
createUltracodeWindow(run) {
|
||||||
this._ensureUltracodeWindowState();
|
this._ensureUltracodeWindowState();
|
||||||
@@ -140,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="uw-status"></span>
|
<span class="uw-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ultracode-window-actions">
|
<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>
|
<button class="uw-close" type="button" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggleUltracodeWindowCollapse(runId);
|
this.minimizeUltracodeWindowToTab(runId);
|
||||||
});
|
});
|
||||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -184,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
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.renderUltracodeWindowContent(runId);
|
||||||
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Collapse/expand the window to header-only (line stays connected). */
|
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
|
||||||
toggleUltracodeWindowCollapse(runId) {
|
|
||||||
|
/** 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);
|
const data = this.ultracodeWindows.get(runId);
|
||||||
if (!data) return;
|
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();
|
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). */
|
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
||||||
removeAllUltracodeWindows() {
|
removeAllUltracodeWindows() {
|
||||||
this._ensureUltracodeWindowState();
|
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) {
|
for (const [, data] of this.ultracodeWindows) {
|
||||||
this._teardownUltracodeDrag(data.dragListeners);
|
this._teardownUltracodeDrag(data.dragListeners);
|
||||||
if (data.element) data.element.remove();
|
if (data.element) data.element.remove();
|
||||||
}
|
}
|
||||||
this.ultracodeWindows.clear();
|
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);
|
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
||||||
this.ultracodeWindowCloseTimers.clear();
|
this.ultracodeWindowCloseTimers.clear();
|
||||||
|
this.minimizedUltracodeRuns.clear();
|
||||||
|
this.minimizedUltracodeAgents.clear();
|
||||||
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
||||||
if (had) this.updateConnectionLines();
|
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. */
|
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
||||||
@@ -328,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header =
|
const header =
|
||||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
`<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('');
|
.join('');
|
||||||
return head + grid;
|
return head + grid;
|
||||||
@@ -381,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
svg.appendChild(line);
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+380
-49
@@ -53,16 +53,99 @@ const RUN_FILE_PREFIX = 'wf_';
|
|||||||
const RUN_FILE_SUFFIX = '.json';
|
const RUN_FILE_SUFFIX = '.json';
|
||||||
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
||||||
const LIVE_AGENT_PREFIX = 'agent-';
|
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. */
|
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
|
||||||
const PROMPT_PREVIEW_MAX = 200;
|
const PROMPT_PREVIEW_MAX = 200;
|
||||||
const RESULT_PREVIEW_MAX = 240;
|
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 {
|
function truncate(value: string | undefined, max: number): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
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. */
|
/** Drop the heavy `agents[]` for list/snapshot use. */
|
||||||
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
|
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
|
||||||
const { agents: _agents, ...summary } = info;
|
const { agents: _agents, ...summary } = info;
|
||||||
@@ -85,6 +168,34 @@ interface DiscoveredLiveRun {
|
|||||||
runId: string;
|
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). */
|
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
|
||||||
interface RawProgressEntry {
|
interface RawProgressEntry {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -123,6 +234,17 @@ export class WorkflowRunWatcher extends EventEmitter {
|
|||||||
private liveDirMtimes = new Map<string, number>();
|
private liveDirMtimes = new Map<string, number>();
|
||||||
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
||||||
private runIdToLiveDir = new Map<string, string>();
|
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/). */
|
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
|
||||||
private dirWatchers = new Map<string, ChokidarWatcher>();
|
private dirWatchers = new Map<string, ChokidarWatcher>();
|
||||||
|
|
||||||
@@ -160,6 +282,9 @@ export class WorkflowRunWatcher extends EventEmitter {
|
|||||||
this.runIdToPath.clear();
|
this.runIdToPath.clear();
|
||||||
this.liveDirMtimes.clear();
|
this.liveDirMtimes.clear();
|
||||||
this.runIdToLiveDir.clear();
|
this.runIdToLiveDir.clear();
|
||||||
|
this.agentStatCache.clear();
|
||||||
|
this.liveMetaCache.clear();
|
||||||
|
this.journalCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All cached runs (no recency filter), most-recently-active first. */
|
/** 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);
|
if (path) this.fileMtimes.delete(path);
|
||||||
this.runIdToPath.delete(runId);
|
this.runIdToPath.delete(runId);
|
||||||
const liveDir = this.runIdToLiveDir.get(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.runIdToLiveDir.delete(runId);
|
||||||
this.emit('run_removed', { 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
|
* Re-synthesize an in-flight run from its transcript dir. Cheap pass first: stat
|
||||||
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
|
* 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> {
|
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 (!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);
|
const existed = this.runs.has(info.runId);
|
||||||
this.runs.set(info.runId, info);
|
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>/`.
|
* Build an ACTIVE WorkflowRunInfo from a live transcript dir, ENRICHED so the
|
||||||
* The transcript tree carries no phases/tokens — those arrive with the
|
* floating window / panel show real data mid-run rather than empty slots:
|
||||||
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
|
* - per-agent tokens + tool-calls + model + last tool, parsed from each
|
||||||
* card→transcript click still works), each marked done/running from journal
|
* `agent-<id>.jsonl` (mtime-cached — only changed transcripts are re-read);
|
||||||
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
|
* - 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> {
|
private async parseLiveDir(
|
||||||
let entries: string[];
|
live: DiscoveredLiveRun,
|
||||||
try {
|
agentFiles: Map<string, LiveAgentFile>,
|
||||||
entries = await readdir(live.dirPath);
|
journalPath: string | null,
|
||||||
} catch {
|
journalMtime: number,
|
||||||
return null; // vanished between discover and read
|
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>();
|
// Order agents by journal launch order; not-yet-started ones trail (sorted by id).
|
||||||
let newestMtime = 0;
|
const ids = Array.from(agentFiles.keys()).sort((a, b) => {
|
||||||
for (const name of entries) {
|
const ia = startIndex.has(a) ? (startIndex.get(a) as number) : Number.MAX_SAFE_INTEGER;
|
||||||
if (name.startsWith(LIVE_AGENT_PREFIX)) {
|
const ib = startIndex.has(b) ? (startIndex.get(b) as number) : Number.MAX_SAFE_INTEGER;
|
||||||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
if (ia !== ib) return ia - ib;
|
||||||
if (stem) agentIds.add(stem);
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
}
|
});
|
||||||
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
|
|
||||||
|
|
||||||
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
|
let totalTokens = 0;
|
||||||
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
|
let totalToolCalls = 0;
|
||||||
.sort()
|
let defaultModel = '';
|
||||||
.map((id, i) => ({
|
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,
|
index: i + 1,
|
||||||
label: `agent ${i + 1}`,
|
label: `agent ${i + 1}`,
|
||||||
phaseIndex: 1,
|
phaseIndex: 1,
|
||||||
phaseTitle: '',
|
phaseTitle: '',
|
||||||
model: '',
|
model: stats?.model || '',
|
||||||
state: doneIds.has(id) ? 'done' : 'progress',
|
state,
|
||||||
agentId: id,
|
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 {
|
return {
|
||||||
runId: live.runId,
|
runId: live.runId,
|
||||||
|
workflowName: meta.workflowName,
|
||||||
|
summary: meta.summary,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
agentCount: agents.length,
|
agentCount: agents.length,
|
||||||
phases: [],
|
totalTokens,
|
||||||
|
totalToolCalls,
|
||||||
|
defaultModel: defaultModel || undefined,
|
||||||
|
phases: meta.phases,
|
||||||
agents,
|
agents,
|
||||||
sessionUuid: live.sessionUuid,
|
sessionUuid: live.sessionUuid,
|
||||||
projectHash: live.projectHash,
|
projectHash: live.projectHash,
|
||||||
@@ -419,25 +630,145 @@ export class WorkflowRunWatcher extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent ids that already emitted a `result` event in the run journal. */
|
/** Parse one agent transcript for token/tool stats; cached on the file's mtime. */
|
||||||
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
|
private async agentTranscriptStats(path: string, mtimeMs: number): Promise<AgentTranscriptStats | null> {
|
||||||
const done = new Set<string>();
|
// 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;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await readFile(journalPath, 'utf-8');
|
text = await readFile(journalPath, 'utf-8');
|
||||||
} catch {
|
} 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')) {
|
for (const line of text.split('\n')) {
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
try {
|
try {
|
||||||
const ev = JSON.parse(line) as { type?: string; agentId?: string };
|
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 {
|
} catch {
|
||||||
// tolerate a partially-written trailing line
|
// 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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -317,3 +317,99 @@ describe('WorkflowRunWatcher — in-flight (live) runs', () => {
|
|||||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
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