Files
Codeman/src/web/public/ultracode-windows.js
T
Claudia[bot] c01edcbbb8 feat(web): optional collapsible left session sidebar
The header tab strip stops working past roughly a dozen sessions: it wraps
into two or three rows, eats vertical space and still cannot be scanned.
This adds a vertical session list in a left <aside> as an ALTERNATIVE
layout — a filter box, a live count, and a 44px collapsed rail that keeps
the ambient signal (status dot, task badge) visible.

The strip is not removed. Settings -> Display -> Tab Bar -> Session List
Layout switches between them and the default stays 'header', so existing
users see no change until they opt in.

Structure: one #sessionTabs element, two mount points. applySessionListLayout()
re-parents the SAME node between #sessionTabsHost and #sessionSidebarList,
which is why there is no second renderer and no duplicated wiring — app.$()
caches getElementById results and never invalidates them, so a moved node
keeps every existing consumer (settings-ui, webview-tabs, the generated
gesture bundle, the mobile tests) working untouched.

Notable integration points:
- Below 1024px the sidebar is an off-canvas drawer overlaying the terminal;
  closed it gets inert + aria-hidden so it cannot be tabbed into, and touch
  swipes over it no longer switch sessions.
- Subagent and ultracode windows anchor to the right edge of a sidebar row
  instead of its bottom, connector curves follow.
- Alt+B toggles; the chord is gated out of the PTY so xterm cannot also
  write ESC b into a live session.
- Collapse state lives in its own localStorage key (the settings blob is
  rebuilt from DOM controls on every save) and falls back to in-memory
  intent where storage throws.

Verified: frontend syntax + public asset checks, tsc, eslint, 26 new jsdom
tests, and a headless-Chromium harness (scripts/verify-session-sidebar.mts)
that renders a synthetic 25-session fleet in both layouts at 1600/1000/393px
and asserts mount point, widths, inert/aria state and row count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:42:21 +02:00

849 lines
38 KiB
JavaScript

/**
* @fileoverview Ultracode floating run windows — auto-popping draggable windows
* with a connector line to the originating session tab.
*
* This is the "floating thing" companion to the docked master-detail panel in
* `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL).
* When the `ultracodeFloatingWindows` setting is on (a DEDICATED toggle, separate
* from the dock panel's `showUltracodeAgents` — see `_ultracodeFloatingEnabled`),
* a small floating window pops up
* for each ACTIVE ultracode/Workflow run (status not completed/killed/failed),
* mirroring the live agent grid, and is connected by a glowing line to the
* Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same
* line idiom subagent windows use. The window auto-closes a few seconds after
* its run finishes; an explicitly-closed run is remembered and never re-pops.
*
* Reuses, rather than duplicates:
* - `makeWindowDraggable` + the shared `#connectionLines` SVG (subagent-windows.js)
* - `_workflowAgentCardHtml`, `_fmtNum`, `_workflowStatusClass`, `_fetchWorkflowRunDetail`,
* and the `workflowRuns` / `workflowRunDetails` maps (ultracode-panel.js)
*
* The connector-line draw is appended to the shared SVG from inside
* `_updateConnectionLinesImmediate` (subagent-windows.js calls
* `_appendUltracodeConnectionLines` at the end of its render pass), so both the
* subagent and ultracode lines live in one batched read→write reflow pass.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (makeWindowDraggable, updateConnectionLines, #connectionLines)
* @dependency ultracode-panel.js (workflowRuns/workflowRunDetails, _workflowAgentCardHtml, _fmtNum)
* @loadorder 15.5 (after subagent-windows.js — needs makeWindowDraggable at runtime)
*/
/* global CodemanApp, escapeHtml */
Object.assign(CodemanApp.prototype, {
/** Lazily seed the floating-window state maps (constructor also seeds them). */
_ensureUltracodeWindowState() {
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
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;
},
/** Floating windows have their own opt-in (default OFF), independent of the dock panel. */
_ultracodeFloatingEnabled() {
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
return !!(settings && settings.ultracodeFloatingWindows);
},
/** A run is "working" until it reaches a terminal status. Mid-run status is absent. */
_isWorkflowRunActive(run) {
const s = String((run && run.status) || '');
return !(s === 'completed' || s === 'killed' || s === 'failed');
},
/**
* Resolve which Codeman tab a run belongs to: the session whose
* `claudeSessionId` equals the run's `sessionUuid` (the path segment the watcher
* captured). Falls back to the active session so the line still lands somewhere.
*/
_resolveUltracodeParentSession(run) {
const uuid = run && run.sessionUuid;
if (uuid && this.sessions) {
for (const [sessionId, session] of this.sessions) {
if (session && session.claudeSessionId === uuid) return sessionId;
}
}
if (this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
return this.activeSessionId;
}
return null;
},
/**
* Auto-pop driver — called for every run discovered/updated and on reconnect seed.
* Creates a floating window for active runs, refreshes existing ones, and schedules
* an auto-close once a run finishes.
*/
_syncUltracodeFloatingWindow(run, opts) {
this._ensureUltracodeWindowState();
if (!run || !run.runId) return;
const runId = run.runId;
const existing = this.ultracodeWindows.get(runId);
// Auto-pop is gated on the floating-windows toggle, but an ALREADY-open window
// (e.g. one opened by clicking the run in the dock) keeps refreshing regardless.
if (!existing && !this._ultracodeFloatingEnabled()) return;
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
const active = this._isWorkflowRunActive(run);
// 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);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
if (existing) {
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // refresh agents[]; re-renders window on land
} else {
// On a reconnect snapshot, only restore windows for genuinely recent runs so
// a backlog of stale undefined-status runs doesn't carpet the screen.
if (opts && opts.fromSeed) {
const FLOAT_SEED_MAX_AGE_MS = 5 * 60 * 1000;
const age = Date.now() - (run.lastActivityAt || 0);
if (!(run.lastActivityAt && age < FLOAT_SEED_MAX_AGE_MS)) return;
}
this.createUltracodeWindow(run);
}
} else if (existing) {
// Finished — refresh to the final state (status + final agent states), show it
// briefly, then retire the floating window.
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeWindowContent(runId);
if (!this.ultracodeWindowCloseTimers.has(runId)) {
const FLOAT_FINISH_GRACE_MS = 8000;
const timer = setTimeout(() => {
this.ultracodeWindowCloseTimers.delete(runId);
this.closeUltracodeWindow(runId, false);
}, FLOAT_FINISH_GRACE_MS);
this.ultracodeWindowCloseTimers.set(runId, timer);
}
}
},
/**
* Explicitly open (or focus) the floating window for a run — the click-through
* from the dock panel's run list. Unlike auto-pop this ignores the floating-windows
* toggle and clears any prior dismissal (it's a direct user action), then draws the
* connector line from the run's session tab.
*/
openUltracodeWindowForRun(runId) {
this._ensureUltracodeWindowState();
if (!runId) return;
const run = this.workflowRuns && this.workflowRuns.get(runId);
if (!run) return;
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
this._removeMinimizedUltracodeRun(runId); // …and a past minimize-to-tab
const existing = this.ultracodeWindows.get(runId);
if (existing) {
// Already open — bring to front and refresh.
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId);
this.updateConnectionLines();
} else {
this.createUltracodeWindow(run);
}
},
/** Build and mount a floating window for a run, positioned near its parent tab. */
createUltracodeWindow(run) {
this._ensureUltracodeWindowState();
const runId = run.runId;
if (this.ultracodeWindows.has(runId)) return;
const parentSessionId = this._resolveUltracodeParentSession(run);
const titleText = run.workflowName || run.summary || runId;
const win = document.createElement('div');
win.className = 'ultracode-window spawning';
win.id = `ultracode-window-${runId}`;
win.style.zIndex = ++this.ultracodeWindowZIndex;
win.innerHTML = `
<div class="ultracode-window-header">
<div class="ultracode-window-title" title="${escapeHtml(titleText)}">
<span class="icon">🧬</span>
<span class="uw-name">${escapeHtml(titleText)}</span>
<span class="uw-status"></span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Minimize to tab">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
<div class="ultracode-window-body" id="ultracode-window-body-${runId}">
<div class="subagent-empty">Loading agents…</div>
</div>
`;
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
if (parentTab) {
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
// sidebar. The viewport clamp is unchanged.
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
const left = Math.max(8, Math.min(anchor.spawnLeft, window.innerWidth - 392));
win.style.left = `${left}px`;
win.style.top = `${anchor.spawnTop + (anchor.vertical ? 14 : 0)}px`;
} else {
const n = this.ultracodeWindows.size;
win.style.left = `${24 + n * 26}px`;
win.style.top = `${96 + n * 26}px`;
}
document.body.appendChild(win);
// Drop the spawn class on the next frame so the transition runs.
requestAnimationFrame(() => win.classList.remove('spawning'));
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
// leave the small scale-in transition above as the whole animation).
this.applyWindowEntrance?.(win);
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.minimizeUltracodeWindowToTab(runId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
this.closeUltracodeWindow(runId, true);
});
const nameEl = win.querySelector('.uw-name');
if (parentSessionId) {
nameEl.style.cursor = 'pointer';
nameEl.title = 'Go to session';
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
}
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
this.updateConnectionLines();
},
// ── 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;
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();
},
/** Remove a floating window. `userInitiated` records a dismissal so it won't re-pop. */
closeUltracodeWindow(runId, userInitiated) {
this._ensureUltracodeWindowState();
const pending = this.ultracodeWindowCloseTimers.get(runId);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
const data = this.ultracodeWindows.get(runId);
if (userInitiated) this.ultracodeWindowsClosed.add(runId);
if (!data) return;
this._teardownUltracodeDrag(data.dragListeners);
data.element.remove();
this.ultracodeWindows.delete(runId);
this.updateConnectionLines();
},
/** Detach the document-level drag listeners returned by makeWindowDraggable. */
_teardownUltracodeDrag(dl) {
if (!dl) return;
document.removeEventListener('mousemove', dl.move);
document.removeEventListener('mouseup', dl.up);
if (dl.touchMove) {
document.removeEventListener('touchmove', dl.touchMove);
document.removeEventListener('touchend', dl.up);
document.removeEventListener('touchcancel', dl.up);
}
if (dl.handle) {
dl.handle.removeEventListener('mousedown', dl.handleMouseDown);
dl.handle.removeEventListener('touchstart', dl.handleTouchStart);
}
},
// ── 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'));
this.applyWindowEntrance?.(win);
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 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. */
syncAllUltracodeFloatingWindows() {
this._ensureUltracodeWindowState();
if (!this._ultracodeFloatingEnabled()) {
this.removeAllUltracodeWindows();
return;
}
if (!this.workflowRuns) return;
for (const run of this.workflowRuns.values()) {
this._syncUltracodeFloatingWindow(run, { fromSeed: true });
}
},
/** Refresh a floating window's header + body from the latest summary/detail. */
renderUltracodeWindowContent(runId) {
const data = this.ultracodeWindows.get(runId);
if (!data) return;
const summary = this.workflowRuns && this.workflowRuns.get(runId);
const detail = this.workflowRunDetails && this.workflowRunDetails.get(runId);
// Summary is the freshest run-level info (every SSE tick); detail supplies agents[]
// but is fetched less often. Merge so a completed summary isn't masked by stale detail.
const run = summary && detail ? { ...detail, ...summary, agents: detail.agents } : detail || summary;
if (!run) return;
const nameEl = data.element.querySelector('.uw-name');
if (nameEl) nameEl.textContent = run.workflowName || run.summary || runId;
const statusEl = data.element.querySelector('.uw-status');
if (statusEl) {
const finished = !this._isWorkflowRunActive(run);
const label = run.status ? String(run.status) : finished ? '—' : 'running';
const clsKey = run.status ? run.status : finished ? '' : 'running';
statusEl.textContent = label;
statusEl.className = 'uw-status ultracode-status ' + this._workflowStatusClass(clsKey);
}
const body = data.element.querySelector('.ultracode-window-body');
if (body) body.innerHTML = this._ultracodeWindowBodyHtml(run);
},
/** Compact body: a stats line + agent cards grouped by phase (reuses panel helpers). */
_ultracodeWindowBodyHtml(run) {
const phases = Array.isArray(run.phases) ? run.phases : [];
const agents = Array.isArray(run.agents) ? run.agents : null;
const agentCount = run.agentCount ?? (agents ? agents.length : 0);
const head = `<div class="uw-summary">${this._fmtNum(run.totalTokens)} tok · ${run.totalToolCalls ?? 0} tools · ${agentCount} agents</div>`;
if (!agents) {
// Summary-only (detail not fetched yet): show phase chips as a teaser.
if (phases.length) {
const chips = phases
.map(
(p) =>
`<span class="ultracode-phase-chip" title="${escapeHtml(p.detail || '')}">${escapeHtml(p.title || '')}</span>`
)
.join('');
return (
head + `<div class="ultracode-phase-list">${chips}</div><div class="subagent-empty">Loading agents…</div>`
);
}
return head + '<div class="subagent-empty">Loading agents…</div>';
}
if (!agents.length) return head + '<div class="subagent-empty">No agents yet</div>';
const groups = new Map();
agents.forEach((a) => {
const key = a.phaseIndex || 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(a);
});
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
const grid = orderedKeys
.map((key) => {
const group = groups.get(key);
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
})
.join('');
return head + grid;
},
/**
* Append ultracode-window → parent-tab connector lines into the shared SVG.
* Invoked at the tail of `_updateConnectionLinesImmediate` (subagent-windows.js),
* so it shares that pass's batched read/write discipline. `rects` is the tab-rect
* cache already populated for subagent lines — reuse it, fill any gaps.
*/
_appendUltracodeConnectionLines(svg, rects) {
this._ensureUltracodeWindowState();
if (!svg || !this.ultracodeWindows.size) return;
if (!rects) rects = new Map();
// PHASE 1: layout reads (resolve parents, batch getBoundingClientRect).
const winList = [];
for (const [runId, data] of this.ultracodeWindows) {
if (!data.element) continue;
if (!data.parentSessionId) {
const summary = this.workflowRuns && this.workflowRuns.get(runId);
if (summary) data.parentSessionId = this._resolveUltracodeParentSession(summary);
}
const parentSessionId = data.parentSessionId;
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
}
// PHASE 2: writes (curve from the tab anchor to the window — bottom-center to
// top-center in header layout, right-edge to left-edge in sidebar layout).
for (const { runId, parentSessionId, winRect } of winList) {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) continue;
const path = this._tabConnectorPath(this._tabAnchor(tabRect), winRect);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection');
line.setAttribute('data-run-id', runId);
line.setAttribute('data-parent-tab', parentSessionId);
svg.appendChild(line);
}
},
/**
* 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.
// A window anchor is always vertical; a tab anchor follows the session-list
// layout (_tabAnchor), so the curve leaves a sidebar row sideways.
let anchor;
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
if (runWin && runWin.element) {
const pr = runWin.element.getBoundingClientRect();
anchor = { x: pr.left + pr.width / 2, y: pr.bottom, vertical: true };
} 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;
anchor = this._tabAnchor(tabRect);
}
const path = this._tabConnectorPath(anchor, winRect);
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);
}
},
});