Compare commits

...
Author SHA1 Message Date
Codeman maintainer 5d406c9705 chore: version packages 2026-06-19 15:31:57 +02:00
Codeman maintainer a8782b364f fix(security): harden remaining inline onclick handlers against XSS double-context
Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's ' reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.

Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.

Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
  into a single-quoted JS string (selectSession / killMuxSession). Now
  JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
  orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).

The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.

Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:26:42 +02:00
Ark0N d8da1bd3ff Merge pull request #132 from aakhter/cod-127-xss-ultracode-handlers
Harden ultracode inline onclick handlers against XSS
2026-06-19 15:10:28 +02:00
Aamer Akhter 06871eb7e3 Harden ultracode inline onclick handlers against XSS
The ultracode run/agent cards and minimized-tab badges built inline onclick
handlers by interpolating escapeHtml(value) inside single-quoted JavaScript
strings within an HTML attribute:

    onclick="app.openUltracodeAgentWindow('${escapeHtml(agentId)}', ...)"

escapeHtml maps ' -> &#39;, but the browser HTML-decodes the attribute value
before the handler source is parsed, so &#39; becomes a literal ' again and a
quote in a run/agent/session id breaks out of the string literal into
executable JS. escapeHtml alone is insufficient for the JS-string-within-HTML-
attribute double context.

Switch each handler to 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 an inert string argument. This
matches the encoding already used by other handlers in these files.

Affected:
- ultracode-panel.js: selectWorkflowRun, openUltracodeAgentWindow
- ultracode-windows.js: restore/dismiss for minimized run and agent tabs
2026-06-19 08:55:24 -04:00
Codeman maintainer 5d59c1764d feat(ultracode): in-page agent transcript windows + minimize-to-tab (1.1.14)
Clicking an agent card opens its live transcript as an in-page connected
floating window instead of a detached browser popup. The "−" button on both
run and agent windows now minimizes into the originating session tab as a
restorable ULTRA badge (🧬 runs, 📄 transcripts). Removes the old
collapse-to-header behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:26:33 +02:00
13 changed files with 599 additions and 81 deletions
+24
View File
@@ -1,5 +1,29 @@
# 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 `&#39;` 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
+3 -3
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.1.13 (must match `package.json`)
**Version**: 1.1.15 (must match `package.json`)
## Project Overview
@@ -218,11 +218,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~127 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~147 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.13",
"version": "1.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.13",
"version": "1.1.15",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.13",
"version": "1.1.15",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+9 -4
View File
@@ -2773,12 +2773,16 @@ class CodemanApp {
const minimizedCount = minimizedAgents?.size || 0;
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
// Ultracode runs + agent transcripts minimized to this tab (ultracode-windows.js
// renders one merged ULTRA badge; returns '' when nothing is minimized).
const ultracodeBadge = this.renderUltracodeTabBadge ? this.renderUltracodeTabBadge(id) : '';
// Show folder name if session has a custom name AND tall tabs setting is enabled
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -2792,9 +2796,10 @@ class CodemanApp {
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
${ultracodeBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</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">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
}
+1 -1
View File
@@ -273,7 +273,7 @@ class NotificationManager {
const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : '';
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
<div class="notif-item-header">
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
+2 -2
View File
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
let actions = '';
if (orchState === 'executing' || orchState === 'failed') {
if (phase.status === 'pending') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
}
if (phase.status === 'failed') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
}
}
+17 -17
View File
@@ -753,8 +753,8 @@ Object.assign(CodemanApp.prototype, {
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
html.push(`
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
title="Double-click to open tracking window">
<div class="subagent-header">
${agentIcon}
@@ -762,8 +762,8 @@ Object.assign(CodemanApp.prototype, {
${teammateBadge}
${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
${hasWindow ? '✕' : '⧉'}
</button>
</div>
@@ -810,7 +810,7 @@ Object.assign(CodemanApp.prototype, {
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${escapeHtml(a.tool)}</span>
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'tool_result') {
@@ -859,7 +859,7 @@ Object.assign(CodemanApp.prototype, {
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
View Full Transcript
</button>
</div>
@@ -1195,7 +1195,7 @@ Object.assign(CodemanApp.prototype, {
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
`;
header.insertAdjacentElement('afterend', parentDiv);
}
@@ -1687,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
@@ -2200,7 +2200,7 @@ Object.assign(CodemanApp.prototype, {
const fileName = path.split('/').pop();
html.push(`
<span class="project-insight-filepath"
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
`);
}
@@ -3099,7 +3099,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status streaming">streaming</span>
</div>
<div class="log-viewer-window-actions">
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
</div>
</div>
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
@@ -3275,14 +3275,14 @@ Object.assign(CodemanApp.prototype, {
<span class="size-badge">${sizeKB} KB</span>
</div>
<div class="image-popup-actions">
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
</div>
</div>
<div class="image-popup-body">
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
</div>
`;
@@ -3505,9 +3505,9 @@ Object.assign(CodemanApp.prototype, {
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
}
const sid = escapeHtml(muxSession.sessionId);
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
html += `
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
<div class="process-info">
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
@@ -3520,7 +3520,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
</div>
</div>
`;
@@ -3563,7 +3563,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
</div>
</div>
`;
+5 -5
View File
@@ -1385,11 +1385,11 @@ Object.assign(CodemanApp.prototype, {
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</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' : ''}>&#x25BC;</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">&#x2715;</button>
</div>
</div>
@@ -1484,14 +1484,14 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
+65 -7
View File
@@ -5573,6 +5573,45 @@ kbd {
letter-spacing: 0.03em;
}
/* Ultracode run minimized-to-tab badge — same chip shape as the subagent badge but a
distinct purple (matches the ultracode window accent), and a SEPARATE class so the
incremental tab-update path (which keys on .tab-subagent-badge) never touches it.
Reuses the shared .subagent-dropdown for its restore/dismiss list. */
.session-tab .tab-ultracode-badge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 16px;
padding: 0 5px;
border-radius: 8px;
font-size: 0.6rem;
cursor: pointer;
background: #a855f7;
color: white;
margin-left: 4px;
transition: transform 0.1s, background 0.15s;
}
@media (hover: hover) {
.session-tab .tab-ultracode-badge:hover {
background: #9333ea;
transform: scale(1.05);
}
}
.session-tab .tab-ultracode-badge .subagent-label {
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.03em;
}
/* Per-item type glyph in the ULTRA dropdown (🧬 run vs 📄 transcript). */
.subagent-dropdown-item .ultracode-dd-icon {
font-size: 0.7rem;
line-height: 1;
flex-shrink: 0;
margin-right: 1px;
}
/* Subagent Dropdown - compact, hover-triggered */
.subagent-dropdown {
display: none;
@@ -8647,13 +8686,6 @@ kbd {
transform: scale(0.92);
opacity: 0;
}
.ultracode-window.collapsed {
height: auto !important;
resize: none;
}
.ultracode-window.collapsed .ultracode-window-body {
display: none;
}
.ultracode-window-header {
display: flex;
align-items: center;
@@ -8744,6 +8776,32 @@ kbd {
50% { opacity: 1; }
}
/* Agent-transcript window: spawned from an agent card (in a run window or the dock
panel), tied to its parent run window by its own connector line. Reuses the
.ultracode-window chrome; wider with a monospace transcript body. */
.ultracode-agent-window {
width: 470px;
height: 420px;
border-color: #38bdf8;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(56, 189, 248, 0.18);
}
.ultracode-agent-window .ultracode-window-body .uw-transcript {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 0.68rem;
line-height: 1.45;
color: var(--text);
}
/* Distinguish the parent→agent line from the tab→run line: solid cyan, no dashes.
Must follow .ultracode-connection (equal specificity → source order wins). */
.connection-line.ultracode-agent-connection {
stroke: #38bdf8;
stroke-dasharray: none;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)) drop-shadow(0 0 5px rgba(56, 189, 248, 0.85));
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
default via the marker class below; the server strips it at render when the
+8 -4
View File
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
const statusClass = agent?.status || 'idle';
agentItems.push(`
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
<span class="subagent-dropdown-status ${statusClass}"></span>
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">&times;</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</span>
</div>
`);
}
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
if (typeof this._appendUltracodeConnectionLines === 'function') {
this._appendUltracodeConnectionLines(svg, rects);
}
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
this._appendUltracodeAgentConnectionLines(svg, rects);
}
},
// ═══════════════════════════════════════════════════════════════
@@ -695,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
</div>`
: '';
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status ${agent.status}">${agent.status}</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
</div>
</div>
${parentHeader}
+13 -23
View File
@@ -131,12 +131,15 @@ Object.assign(CodemanApp.prototype, {
}
},
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
// Phase 4: fetch an agent's live transcript by agentId. The workflow agent's
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
async openWorkflowAgentTranscript(agentId) {
if (!agentId) return;
// Returns { formatted: string[], entryCount } or null when nothing is available
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
// in-page floating window lives in ultracode-windows.js (openUltracodeAgentWindow) —
// we no longer spawn a detached browser popup.
async _fetchWorkflowAgentTranscript(agentId) {
if (!agentId) return null;
let data = null;
try {
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
@@ -147,21 +150,8 @@ Object.assign(CodemanApp.prototype, {
const ok = data && data.success && data.data;
const formatted = ok ? data.data.formatted : null;
const entryCount = ok ? data.data.entryCount || 0 : 0;
if (!formatted || !entryCount) {
alert(
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
);
return;
}
const win = window.open('', '_blank', 'width=860,height=640');
if (!win) return; // popup blocked
win.document.write(
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
);
win.document.close();
if (!formatted || !entryCount) return null;
return { formatted, entryCount };
},
// ----- Render (debounced) -----
@@ -214,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
}
return (
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun(${escapeHtml(JSON.stringify(r.runId))})">` +
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
@@ -262,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
return header + group.map((a) => this._workflowAgentCardHtml(a, runId)).join('');
})
.join('');
detail.innerHTML = html;
},
_workflowAgentCardHtml(a) {
_workflowAgentCardHtml(a, runId) {
const state = String(a.state || 'start');
const stateCls = this._workflowAgentStateClass(state);
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
@@ -289,7 +279,7 @@ Object.assign(CodemanApp.prototype, {
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
const cardAttrs = clickable
? ` 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${cardStateCls}"`;
return (
`<div${cardAttrs}>` +
+449 -12
View File
@@ -33,9 +33,12 @@
Object.assign(CodemanApp.prototype, {
/** Lazily seed the floating-window state maps (constructor also seeds them). */
_ensureUltracodeWindowState() {
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
if (!this.ultracodeAgentWindows) this.ultracodeAgentWindows = new Map(); // agentId -> { element, runId, dragListeners }
if (!this.minimizedUltracodeRuns) this.minimizedUltracodeRuns = new Map(); // sessionId -> Set<runId> minimized to a tab
if (!this.minimizedUltracodeAgents) this.minimizedUltracodeAgents = new Map(); // sessionId -> Map<agentId,{runId,label}>
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
},
@@ -86,6 +89,21 @@ Object.assign(CodemanApp.prototype, {
const active = this._isWorkflowRunActive(run);
// Minimized to a tab — keep it there (don't re-pop a window). Clear the tab badge a
// short while after the run finishes, mirroring the floating window's finish grace.
if (this._isUltracodeRunMinimized(runId)) {
if (!active && !this.ultracodeWindowCloseTimers.has(runId)) {
const timer = setTimeout(() => {
this.ultracodeWindowCloseTimers.delete(runId);
this._removeMinimizedUltracodeRun(runId);
this.renderSessionTabs();
this.updateConnectionLines();
}, 8000);
this.ultracodeWindowCloseTimers.set(runId, timer);
}
return;
}
if (active) {
// Run is alive — cancel any pending auto-close.
const pending = this.ultracodeWindowCloseTimers.get(runId);
@@ -134,11 +152,11 @@ Object.assign(CodemanApp.prototype, {
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, expand if collapsed, refresh.
// Already open — bring to front and refresh.
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
if (existing.collapsed) this.toggleUltracodeWindowCollapse(runId);
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId);
this.updateConnectionLines();
@@ -167,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
<span class="uw-status"></span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Collapse">─</button>
<button class="uw-min" type="button" title="Minimize to tab">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
@@ -198,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleUltracodeWindowCollapse(runId);
this.minimizeUltracodeWindowToTab(runId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
@@ -211,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
}
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
this.updateConnectionLines();
},
/** Collapse/expand the window to header-only (line stays connected). */
toggleUltracodeWindowCollapse(runId) {
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
/** Is this run currently minimized to a tab (so auto-pop should leave it alone)? */
_isUltracodeRunMinimized(runId) {
if (!this.minimizedUltracodeRuns) return false;
for (const set of this.minimizedUltracodeRuns.values()) {
if (set.has(runId)) return true;
}
return false;
},
/** Drop a run from the minimized-to-tab tracking (all sessions, or a specific one). */
_removeMinimizedUltracodeRun(runId, sessionId) {
if (!this.minimizedUltracodeRuns) return;
if (sessionId) {
const set = this.minimizedUltracodeRuns.get(sessionId);
if (set) {
set.delete(runId);
if (!set.size) this.minimizedUltracodeRuns.delete(sessionId);
}
return;
}
for (const [sid, set] of this.minimizedUltracodeRuns) {
if (set.delete(runId) && !set.size) this.minimizedUltracodeRuns.delete(sid);
}
},
/**
* Minimize the floating run window into its originating session tab: record it as
* minimized (so a badge renders on the tab), genie-animate the window toward that
* tab, then remove the floating element. Restorable from the tab badge dropdown.
*/
minimizeUltracodeWindowToTab(runId) {
this._ensureUltracodeWindowState();
const data = this.ultracodeWindows.get(runId);
if (!data) return;
data.collapsed = !data.collapsed;
data.element.classList.toggle('collapsed', data.collapsed);
let parentSessionId = data.parentSessionId;
if (!parentSessionId) {
const summary = this.workflowRuns && this.workflowRuns.get(runId);
parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
}
// No tab to fly into → fall back to a plain close so the window isn't orphaned.
if (!parentSessionId) {
this.closeUltracodeWindow(runId, true);
return;
}
// Cancel any pending finish auto-close — the badge owns the run's lifecycle now.
const pending = this.ultracodeWindowCloseTimers.get(runId);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
if (!this.minimizedUltracodeRuns.has(parentSessionId)) this.minimizedUltracodeRuns.set(parentSessionId, new Set());
this.minimizedUltracodeRuns.get(parentSessionId).add(runId);
const element = data.element;
const dragListeners = data.dragListeners;
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
this._teardownUltracodeDrag(dragListeners);
if (element) element.remove();
this.ultracodeWindows.delete(runId);
// Full rebuild so the tab badge renders (the incremental path only knows subagent badges).
this._fullRenderSessionTabs();
this.updateConnectionLines();
});
},
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
if (!tab || !element) {
done();
return;
}
const w = element.getBoundingClientRect();
const t = tab.getBoundingClientRect();
const dx = t.left + t.width / 2 - (w.left + w.width / 2);
const dy = t.top + t.height / 2 - (w.top + w.height / 2);
element.style.transformOrigin = 'center center';
element.style.transition = 'transform 0.26s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.26s ease';
element.style.pointerEvents = 'none';
requestAnimationFrame(() => {
element.style.transform = `translate(${dx}px, ${dy}px) scale(0.06)`;
element.style.opacity = '0';
});
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
done();
};
element.addEventListener('transitionend', finish, { once: true });
setTimeout(finish, 320); // fallback in case transitionend doesn't fire
},
/** Tab badge (with restore/dismiss dropdown) for runs minimized to this session's tab. */
renderUltracodeTabBadge(sessionId) {
this._ensureUltracodeWindowState();
const runSet = this.minimizedUltracodeRuns.get(sessionId);
const agentMap = this.minimizedUltracodeAgents.get(sessionId);
const total = (runSet ? runSet.size : 0) + (agentMap ? agentMap.size : 0);
if (total === 0) return '';
const trunc = (s) => (s.length > 25 ? s.slice(0, 25) + '…' : s);
const items = [];
// Minimized run windows (🧬) first…
if (runSet) {
for (const runId of runSet) {
const run = this.workflowRuns && this.workflowRuns.get(runId);
const name = run ? run.workflowName || run.summary || runId : runId;
const statusCls = this._workflowStatusClass(run ? String(run.status || '') : '');
items.push(
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeRunFromTab(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore run">` +
`<span class="subagent-dropdown-status ${statusCls}"></span>` +
`<span class="ultracode-dd-icon">🧬</span>` +
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeRun(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</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">&times;</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();
},
@@ -260,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">&times;</button>
</div>
</div>
<div class="ultracode-window-body">
<div class="subagent-empty">Loading transcript…</div>
</div>
`;
// Position: offset from the parent run window if it's open, else cascade.
const parentWin = runId ? this.ultracodeWindows.get(runId) : null;
if (parentWin && parentWin.element) {
const r = parentWin.element.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(r.left + 40, window.innerWidth - 472))}px`;
win.style.top = `${Math.max(8, Math.min(r.top + 40, window.innerHeight - 160))}px`;
} else {
const n = this.ultracodeAgentWindows.size;
win.style.left = `${Math.min(140 + n * 28, Math.max(8, window.innerWidth - 472))}px`;
win.style.top = `${110 + n * 28}px`;
}
document.body.appendChild(win);
requestAnimationFrame(() => win.classList.remove('spawning'));
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.minimizeUltracodeAgentWindowToTab(agentId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
this.closeUltracodeAgentWindow(agentId);
});
this.ultracodeAgentWindows.set(agentId, { element: win, runId, dragListeners });
this.updateConnectionLines();
return win;
},
/** Resolve a human label for an agent from the run's fetched detail.agents[]. */
_ultracodeAgentLabel(agentId, runId) {
const detail = this.workflowRunDetails && runId ? this.workflowRunDetails.get(runId) : null;
const agents = detail && Array.isArray(detail.agents) ? detail.agents : null;
if (agents) {
const found = agents.find((a) => a.agentId === agentId);
if (found && found.label) return found.label;
}
return null;
},
/** Fill an agent window's body with the fetched transcript (or a friendly empty state). */
renderUltracodeAgentWindowContent(agentId, data) {
const info = this.ultracodeAgentWindows.get(agentId);
if (!info || !info.element) return;
const body = info.element.querySelector('.ultracode-window-body');
if (!body) return;
if (!data || !data.formatted || !data.entryCount) {
body.innerHTML =
'<div class="subagent-empty">No transcript available yet — the agent may be queued, aged out of tracking, or subagent tracking is disabled.</div>';
return;
}
const text = escapeHtml(data.formatted.join('\n'));
body.innerHTML = `<div class="uw-summary">${data.entryCount} entries</div><pre class="uw-transcript">${text}</pre>`;
},
/** Close one floating agent-transcript window. */
closeUltracodeAgentWindow(agentId) {
this._ensureUltracodeWindowState();
const info = this.ultracodeAgentWindows.get(agentId);
if (!info) return;
this._teardownUltracodeDrag(info.dragListeners);
if (info.element) info.element.remove();
this.ultracodeAgentWindows.delete(agentId);
this.updateConnectionLines();
},
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
removeAllUltracodeWindows() {
this._ensureUltracodeWindowState();
const had = this.ultracodeWindows.size > 0;
const hadMinimized = this.minimizedUltracodeRuns.size > 0 || this.minimizedUltracodeAgents.size > 0;
const had = this.ultracodeWindows.size > 0 || this.ultracodeAgentWindows.size > 0;
for (const [, data] of this.ultracodeWindows) {
this._teardownUltracodeDrag(data.dragListeners);
if (data.element) data.element.remove();
}
this.ultracodeWindows.clear();
for (const [, info] of this.ultracodeAgentWindows) {
this._teardownUltracodeDrag(info.dragListeners);
if (info.element) info.element.remove();
}
this.ultracodeAgentWindows.clear();
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
this.ultracodeWindowCloseTimers.clear();
this.minimizedUltracodeRuns.clear();
this.minimizedUltracodeAgents.clear();
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
if (had) this.updateConnectionLines();
// Drop any now-stale tab badges.
if (hadMinimized) this.renderSessionTabs();
},
/** When the feature is toggled on, pop windows for any currently-active runs. */
@@ -355,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
})
.join('');
return head + grid;
@@ -408,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
svg.appendChild(line);
}
},
/**
* Append agent-window → parent connector lines into the shared SVG. Parent is the
* agent's run floating window when open, else the run's session tab. Called right
* after `_appendUltracodeConnectionLines` so it shares the same batched pass + the
* tab-rect cache.
*/
_appendUltracodeAgentConnectionLines(svg, rects) {
this._ensureUltracodeWindowState();
if (!svg || !this.ultracodeAgentWindows.size) return;
if (!rects) rects = new Map();
for (const [agentId, info] of this.ultracodeAgentWindows) {
if (!info.element) continue;
const winRect = info.element.getBoundingClientRect();
// Anchor: parent run window bottom-center if open, else the run's tab.
let px, py;
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
if (runWin && runWin.element) {
const pr = runWin.element.getBoundingClientRect();
px = pr.left + pr.width / 2;
py = pr.bottom;
} else {
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
if (!tabRect) continue;
px = tabRect.left + tabRect.width / 2;
py = tabRect.bottom;
}
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (py + y2) / 2;
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
line.setAttribute('data-agent-id', agentId);
svg.appendChild(line);
}
},
});