@hanzlaa/rcode 4.1.2 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
@@ -0,0 +1,133 @@
1
+ /**
2
+ * CommandPalette — Cmd+K / Ctrl+K searchable command overlay.
3
+ *
4
+ * Reads the allowlisted commands directly from ALLOWED_COMMANDS (orchestrator.js)
5
+ * and executes selections through runCommandFromUI — no second command list.
6
+ *
7
+ * Props:
8
+ * open {boolean} — whether the palette is visible
9
+ * onClose {function} — called when the palette should close (Escape, backdrop click)
10
+ *
11
+ * Added in sprint 36.1 — DSH-4 command palette.
12
+ */
13
+
14
+ import { html, useState, useEffect, useRef, useMemo } from '../preact.js';
15
+ import { ALLOWED_COMMANDS, runCommandFromUI } from '../orchestrator.js';
16
+ import { Icon } from '../icons-client.js';
17
+
18
+ /**
19
+ * Build an ordered group list and a flat navigation list from a filtered
20
+ * commands array. Group order matches first-seen category order.
21
+ *
22
+ * @param {Array<{cmd,label,category}>} items
23
+ * @returns {{ groups: Array<{category, items}>, flat: Array<{cmd,label,category}> }}
24
+ */
25
+ function groupCommands(items) {
26
+ const seen = [];
27
+ const map = {};
28
+ for (const item of items) {
29
+ if (!map[item.category]) {
30
+ map[item.category] = [];
31
+ seen.push(item.category);
32
+ }
33
+ map[item.category].push(item);
34
+ }
35
+ const groups = seen.map(cat => ({ category: cat, items: map[cat] }));
36
+ const flat = groups.flatMap(g => g.items);
37
+ return { groups, flat };
38
+ }
39
+
40
+ export function CommandPalette({ open, onClose }) {
41
+ const [query, setQuery] = useState('');
42
+ const [activeIdx, setActiveIdx] = useState(0);
43
+ const inputRef = useRef(null);
44
+
45
+ // Focus and reset when opened.
46
+ useEffect(() => {
47
+ if (open) {
48
+ setQuery('');
49
+ setActiveIdx(0);
50
+ // Defer by one tick so the element is in the DOM and visible.
51
+ requestAnimationFrame(() => {
52
+ if (inputRef.current) inputRef.current.focus();
53
+ });
54
+ }
55
+ }, [open]);
56
+
57
+ // Filter commands by query substring (label or cmd).
58
+ const results = useMemo(() => {
59
+ const q = query.trim().toLowerCase();
60
+ if (!q) return ALLOWED_COMMANDS;
61
+ return ALLOWED_COMMANDS.filter(
62
+ ({ cmd, label }) =>
63
+ cmd.toLowerCase().includes(q) || label.toLowerCase().includes(q)
64
+ );
65
+ }, [query]);
66
+
67
+ const { groups, flat } = useMemo(() => groupCommands(results), [results]);
68
+
69
+ function choose(cmd) {
70
+ runCommandFromUI(cmd);
71
+ onClose();
72
+ }
73
+
74
+ function handleKeyDown(e) {
75
+ if (e.key === 'ArrowDown') {
76
+ e.preventDefault();
77
+ setActiveIdx(i => Math.min(i + 1, flat.length - 1));
78
+ } else if (e.key === 'ArrowUp') {
79
+ e.preventDefault();
80
+ setActiveIdx(i => Math.max(i - 1, 0));
81
+ } else if (e.key === 'Enter') {
82
+ if (flat[activeIdx]) choose(flat[activeIdx].cmd);
83
+ } else if (e.key === 'Escape') {
84
+ onClose();
85
+ }
86
+ }
87
+
88
+ if (!open) return null;
89
+
90
+ // Running flat index counter across groups so activeIdx maps correctly.
91
+ let flatIdx = 0;
92
+
93
+ return html`
94
+ <div class="cmd-palette-overlay" onClick=${onClose}>
95
+ <div class="cmd-palette" onClick=${e => e.stopPropagation()} onKeyDown=${handleKeyDown}>
96
+
97
+ <div class="cmd-palette-search">
98
+ <${Icon} name="search" size=${16} cls="cmd-palette-search-icon" />
99
+ <input
100
+ class="cmd-palette-input"
101
+ ref=${inputRef}
102
+ value=${query}
103
+ onInput=${e => { setQuery(e.target.value); setActiveIdx(0); }}
104
+ placeholder="Search commands…"
105
+ />
106
+ </div>
107
+
108
+ <div class="cmd-palette-list">
109
+ ${flat.length === 0
110
+ ? html`<div class="cmd-palette-empty">No commands match</div>`
111
+ : groups.map(({ category, items }) => html`
112
+ <div class="cmd-palette-group" key=${category}>${category}</div>
113
+ ${items.map(item => {
114
+ const idx = flatIdx++;
115
+ return html`
116
+ <button
117
+ class=${'cmd-palette-item' + (idx === activeIdx ? ' active' : '')}
118
+ key=${item.cmd}
119
+ onClick=${() => choose(item.cmd)}
120
+ >
121
+ <span>${item.label}</span>
122
+ <span class="cmd-palette-cmd">${item.cmd}</span>
123
+ </button>
124
+ `;
125
+ })}
126
+ `)
127
+ }
128
+ </div>
129
+
130
+ </div>
131
+ </div>
132
+ `;
133
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * FileReader — shared markdown reader used by FilesView and MemoryView.
3
+ *
4
+ * Renders as a right-side slide-over (backdrop + panel) so it works on top
5
+ * of any list layout. Fetches /api/file?path=... itself whenever `path`
6
+ * changes, so callers only manage which file is open. Markdown renders via
7
+ * the global `marked` CDN lib with the same sanitizer the legacy Files view
8
+ * used; falls back to escaped <pre> when marked is unavailable.
9
+ *
10
+ * Props:
11
+ * path — project-relative file path to fetch (required; null hides)
12
+ * title — display name shown in the header (falls back to basename)
13
+ * onClose — called when the user dismisses the reader (backdrop, ×, Esc)
14
+ */
15
+
16
+ import { html, useState, useEffect, useCallback } from '../preact.js';
17
+ import { showToast } from './shared.js';
18
+
19
+ // ---- Markdown helpers (moved from FilesView so both views share one copy) ----
20
+ function stripFrontmatter(md) {
21
+ if (!md.startsWith('---')) return md;
22
+ const end = md.indexOf('\n---', 3);
23
+ return end === -1 ? md : md.slice(end + 4).trimStart();
24
+ }
25
+
26
+ // Minimal HTML sanitizer for rendered markdown. No DOMPurify dependency on the
27
+ // client, so we strip the dangerous primitives via regex after marked emits
28
+ // HTML: script/iframe/object/embed tags, inline event handlers, and
29
+ // javascript:/data: URLs in href/src. Markdown content comes from the project
30
+ // dir (semi-trusted) but may include attacker-controlled text checked into a
31
+ // repo, so we cannot trust raw HTML passthrough.
32
+ function sanitizeHtml(raw) {
33
+ return String(raw)
34
+ .replace(/<\s*(script|iframe|object|embed|link|meta|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
35
+ .replace(/<\s*(script|iframe|object|embed|link|meta|style)\b[^>]*\/?>/gi, '')
36
+ .replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
37
+ .replace(/(href|src|xlink:href)\s*=\s*(["'])\s*(?:javascript|data|vbscript):[^"']*\2/gi, '$1=$2#blocked$2');
38
+ }
39
+
40
+ export function renderMd(md) {
41
+ const clean = stripFrontmatter(md);
42
+ if (typeof marked === 'undefined') {
43
+ return '<pre>' + clean.replace(/</g, '&lt;') + '</pre>';
44
+ }
45
+ return sanitizeHtml(marked.parse(clean));
46
+ }
47
+
48
+ export function FileReader({ path, title, onClose }) {
49
+ const [content, setContent] = useState({ html: null, loading: true, error: null });
50
+
51
+ useEffect(() => {
52
+ if (!path) return;
53
+ let cancelled = false;
54
+ setContent({ html: null, loading: true, error: null });
55
+ fetch('/api/file?path=' + encodeURIComponent(path))
56
+ .then(async resp => {
57
+ if (cancelled) return;
58
+ if (!resp.ok) {
59
+ const msg = resp.status === 404
60
+ ? 'File not found: ' + path
61
+ : 'Failed to load file (HTTP ' + resp.status + ').';
62
+ setContent({ html: null, loading: false, error: msg });
63
+ return;
64
+ }
65
+ const text = await resp.text();
66
+ if (!cancelled) setContent({ html: renderMd(text), loading: false, error: null });
67
+ })
68
+ .catch(() => {
69
+ if (!cancelled) setContent({ html: null, loading: false, error: 'Network error.' });
70
+ });
71
+ return () => { cancelled = true; };
72
+ }, [path]);
73
+
74
+ useEffect(() => {
75
+ function onKey(e) {
76
+ if (e.key === 'Escape' && onClose) onClose();
77
+ }
78
+ document.addEventListener('keydown', onKey);
79
+ return () => document.removeEventListener('keydown', onKey);
80
+ }, [onClose]);
81
+
82
+ const copyPath = useCallback(() => {
83
+ navigator.clipboard.writeText(path).then(() => {
84
+ showToast('Path copied!');
85
+ }).catch(() => {});
86
+ }, [path]);
87
+
88
+ if (!path) return null;
89
+ const name = title || path.split('/').pop();
90
+
91
+ return html`
92
+ <div class="reader-backdrop" onClick=${onClose}></div>
93
+ <div class="reader-panel" role="dialog" aria-label=${name}>
94
+ <div class="reader-header">
95
+ <div class="reader-heading">
96
+ <div class="reader-title">${name}</div>
97
+ <div class="reader-path">${path}</div>
98
+ </div>
99
+ <div class="reader-actions">
100
+ <button class="reader-copy" onClick=${copyPath}>Copy path</button>
101
+ <button class="reader-close" aria-label="Close reader" onClick=${onClose}>×</button>
102
+ </div>
103
+ </div>
104
+ <div class="reader-body">
105
+ ${content.loading && html`
106
+ <div class="skeleton reader-skel-line"></div>
107
+ <div class="skeleton reader-skel-block"></div>
108
+ `}
109
+ ${content.error && html`<div class="reader-error">${content.error}</div>`}
110
+ ${!content.loading && !content.error && content.html != null && html`
111
+ <div class="md-render" dangerouslySetInnerHTML=${{ __html: content.html }} />
112
+ `}
113
+ </div>
114
+ </div>
115
+ `;
116
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * FilterChips — interactive filter chip component.
3
+ *
4
+ * Renders three groups of toggle chips (status / milestone / date).
5
+ * Clicking a chip writes the updated filter set into location.hash via
6
+ * applyFilters() from filter-state.js. The App.js hashchange listener then
7
+ * re-renders the active view with the new filters prop.
8
+ *
9
+ * Props:
10
+ * filters — route filter object { status, milestone, date }
11
+ * statusOptions — Array<{ value, label }>
12
+ * milestoneOptions — Array<{ value, label }>
13
+ * dateOptions — Array<{ value, label }>
14
+ */
15
+
16
+ import { html } from '../preact.js';
17
+ import { applyFilters } from '../filter-state.js';
18
+
19
+ /** @returns {string} — current view path segment from location.hash */
20
+ function viewPath() {
21
+ return location.hash.slice(1).split('?')[0] || 'overview';
22
+ }
23
+
24
+ /**
25
+ * A single group of chips for one filter dimension.
26
+ *
27
+ * @param {{ label: string, dimension: string, options: Array<{value,label}>, active: string, filters: object }} props
28
+ */
29
+ function ChipGroup({ dimension, options, active, filters }) {
30
+ if (!options || options.length === 0) return null;
31
+
32
+ function handleClick(value) {
33
+ const next = Object.assign({}, filters, {
34
+ [dimension]: active === value ? '' : value,
35
+ });
36
+ location.hash = applyFilters(viewPath(), next);
37
+ }
38
+
39
+ return html`
40
+ <div class="filter-chip-group">
41
+ ${options.map(opt => {
42
+ const isActive = opt.value === active;
43
+ return html`
44
+ <button
45
+ key=${opt.value}
46
+ class=${'filter-chip' + (isActive ? ' active' : '')}
47
+ onClick=${() => handleClick(opt.value)}
48
+ >${opt.label}</button>
49
+ `;
50
+ })}
51
+ </div>
52
+ `;
53
+ }
54
+
55
+ /**
56
+ * FilterChips — interactive filter chip row with a clear button.
57
+ */
58
+ export function FilterChips({ filters, statusOptions, milestoneOptions, dateOptions }) {
59
+ const f = filters || { status: '', milestone: '', date: '' };
60
+
61
+ const hasActive = f.status !== '' || f.milestone !== '' || f.date !== '';
62
+
63
+ function handleClear() {
64
+ location.hash = applyFilters(viewPath(), { status: '', milestone: '', date: '' });
65
+ }
66
+
67
+ return html`
68
+ <div class="filter-chips">
69
+ <${ChipGroup}
70
+ dimension="status"
71
+ options=${statusOptions}
72
+ active=${f.status}
73
+ filters=${f}
74
+ />
75
+ <${ChipGroup}
76
+ dimension="milestone"
77
+ options=${milestoneOptions}
78
+ active=${f.milestone}
79
+ filters=${f}
80
+ />
81
+ <${ChipGroup}
82
+ dimension="date"
83
+ options=${dateOptions}
84
+ active=${f.date}
85
+ filters=${f}
86
+ />
87
+ <button
88
+ class="filter-chip-clear"
89
+ disabled=${!hasActive}
90
+ onClick=${hasActive ? handleClear : undefined}
91
+ >Clear</button>
92
+ </div>
93
+ `;
94
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * NotifyCenter — blocked-session notification UI.
3
+ *
4
+ * Two components, both driven by store state written by notify.js:
5
+ *
6
+ * BlockedToasts — persistent corner toasts, one per blocked-session alert
7
+ * (store.blockedAlerts). Clicking a toast opens that session's terminal
8
+ * panel; the ✕ dismisses without opening. Mounted once in App.js.
9
+ *
10
+ * BlockedBell — topbar bell with a count of currently-blocked sessions and
11
+ * a dropdown listing them (click an entry → open its terminal). Mounted
12
+ * in Topbar.js. Renders nothing special when no session is blocked.
13
+ *
14
+ * No inline style= — all styling via .nb-* classes in css.js.
15
+ */
16
+
17
+ import { html, useState, useEffect, useRef } from '../preact.js';
18
+ import { useStore } from '../store.js';
19
+ import { openTermPanel } from '../orchestrator.js';
20
+ import { dismissBlockedAlert } from '../notify.js';
21
+ import { Icon } from '../icons-client.js';
22
+
23
+ // ── Persistent blocked toasts ─────────────────────────────────────────────────
24
+
25
+ export function BlockedToasts() {
26
+ const blockedAlerts = useStore(s => s.blockedAlerts);
27
+ const alerts = blockedAlerts || [];
28
+ if (alerts.length === 0) return null;
29
+
30
+ function open(storyId) {
31
+ dismissBlockedAlert(storyId);
32
+ openTermPanel(storyId, storyId);
33
+ }
34
+
35
+ return html`
36
+ <div class="nb-toasts" role="status" aria-live="polite">
37
+ ${alerts.map(a => html`
38
+ <div key=${a.storyId} class="nb-toast"
39
+ role="button" tabindex="0"
40
+ title=${'Open terminal for ' + a.storyId}
41
+ onClick=${() => open(a.storyId)}
42
+ onKeyDown=${e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(a.storyId); } }}>
43
+ <span class="nb-toast-dot" aria-hidden="true"></span>
44
+ <span class="nb-toast-text">
45
+ Agent waiting for input — ${a.storyId}
46
+ ${a.cmd ? html`<span class="nb-toast-cmd">${a.cmd}</span>` : null}
47
+ </span>
48
+ <button class="nb-toast-dismiss" aria-label="Dismiss"
49
+ onClick=${e => { e.stopPropagation(); dismissBlockedAlert(a.storyId); }}>
50
+ <${Icon} name="x" size=${12}/>
51
+ </button>
52
+ </div>
53
+ `)}
54
+ </div>
55
+ `;
56
+ }
57
+
58
+ // ── Topbar bell ───────────────────────────────────────────────────────────────
59
+
60
+ export function BlockedBell() {
61
+ const activeSessions = useStore(s => s.activeSessions);
62
+ const [open, setOpen] = useState(false);
63
+ const rootRef = useRef(null);
64
+ const blocked = (activeSessions || []).filter(s => s.status === 'blocked');
65
+
66
+ // Close the dropdown on any outside click.
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ function onDocClick(e) {
70
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
71
+ }
72
+ document.addEventListener('mousedown', onDocClick);
73
+ return () => document.removeEventListener('mousedown', onDocClick);
74
+ }, [open]);
75
+
76
+ // Nothing blocked → drop a stale open dropdown.
77
+ useEffect(() => {
78
+ if (blocked.length === 0 && open) setOpen(false);
79
+ }, [blocked.length]);
80
+
81
+ function openSession(storyId) {
82
+ setOpen(false);
83
+ dismissBlockedAlert(storyId);
84
+ openTermPanel(storyId, storyId);
85
+ }
86
+
87
+ return html`
88
+ <div class="nb-bell-wrap" ref=${rootRef}>
89
+ <button
90
+ class=${'tb-btn tb-btn--icon nb-bell' + (blocked.length ? ' nb-bell--alert' : '')}
91
+ type="button"
92
+ title=${blocked.length
93
+ ? blocked.length + ' session' + (blocked.length === 1 ? '' : 's') + ' waiting for input'
94
+ : 'No sessions waiting for input'}
95
+ aria-label="Blocked session notifications"
96
+ aria-expanded=${open}
97
+ onClick=${() => { if (blocked.length) setOpen(o => !o); }}>
98
+ <${Icon} name="bell" size=${15}/>
99
+ ${blocked.length ? html`<span class="nb-bell-count">${blocked.length}</span>` : null}
100
+ </button>
101
+ ${open && blocked.length ? html`
102
+ <div class="nb-bell-dropdown" role="menu">
103
+ <div class="nb-bell-title">Waiting for input</div>
104
+ ${blocked.map(s => html`
105
+ <button key=${s.storyId} class="nb-bell-item" role="menuitem"
106
+ title=${'Open terminal for ' + s.storyId}
107
+ onClick=${() => openSession(s.storyId)}>
108
+ <span class="term-status-dot blocked" aria-hidden="true"></span>
109
+ <span class="nb-bell-item-id">${s.storyId}</span>
110
+ <span class="nb-bell-item-cmd">${s.cmd || ''}</span>
111
+ </button>
112
+ `)}
113
+ </div>
114
+ ` : null}
115
+ </div>
116
+ `;
117
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
17
17
  import { useStore, setState } from '../store.js';
18
- import { orchToken, stopSession, cleanSessions, ORCH_HTTP } from '../orchestrator.js';
18
+ import { orchToken, stopSession, cleanSessions, ORCH_WS } from '../orchestrator.js';
19
19
  import { showToast } from './shared.js';
20
20
  import { Icon } from '../icons-client.js';
21
21
 
@@ -25,7 +25,23 @@ function mkSession(title) {
25
25
  return { title: title || 'Session', lines: [], fileOps: [], status: 'starting' };
26
26
  }
27
27
 
28
- // ── SSE streams (module-scoped — one EventSource per storyId) ────────────────
28
+ /**
29
+ * Strip ANSI escape sequences (OSC, CSI, other ESC) and carriage returns so
30
+ * raw PTY output renders as readable plain-text log lines. The full-fidelity
31
+ * terminal lives in XtermPanel; this panel is a lightweight log view.
32
+ */
33
+ function stripAnsi(s) {
34
+ return String(s)
35
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
36
+ .replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
37
+ .replace(/\x1b[@-_]/g, '')
38
+ .replace(/\r/g, '');
39
+ }
40
+
41
+ // ── Live streams (module-scoped — one WebSocket per storyId) ─────────────────
42
+ // The orchestrator's data plane is the PTY WebSocket at /ws/<storyId>
43
+ // (wire frames: {t:'o',d} output, {t:'hist',d} scrollback, {t:'s',s} status).
44
+ // The previous SSE endpoint (/api/stream/<id>) no longer exists on the server.
29
45
  const _streams = {};
30
46
 
31
47
  function closeStream(storyId) {
@@ -35,9 +51,11 @@ function closeStream(storyId) {
35
51
  // ── Component ─────────────────────────────────────────────────────────────────
36
52
 
37
53
  export function OrchPanel() {
38
- const { orchPanel } = useStore();
54
+ const { orchPanel, activeSessions } = useStore();
39
55
  const open = !!(orchPanel && orchPanel.open);
40
56
  const reqStory = orchPanel && orchPanel.storyId;
57
+ // Sessions reported by the orchestrator API (4s poll → store.activeSessions).
58
+ const apiSessions = activeSessions || [];
41
59
 
42
60
  // sessionsMap: { [storyId]: { title, lines, fileOps, status } }
43
61
  const [sessionsMap, setSessionsMap] = useState({});
@@ -72,11 +90,15 @@ export function OrchPanel() {
72
90
 
73
91
  function connectStream(storyId) {
74
92
  const tok = orchToken();
75
- const es = new EventSource(
76
- ORCH_HTTP + '/api/stream/' + encodeURIComponent(storyId) +
77
- '?token=' + encodeURIComponent(tok || '')
93
+ if (!tok) {
94
+ showToast('No orchestrator token restart the dashboard');
95
+ return;
96
+ }
97
+ const ws = new WebSocket(
98
+ ORCH_WS + '/ws/' + encodeURIComponent(storyId) +
99
+ '?token=' + encodeURIComponent(tok)
78
100
  );
79
- _streams[storyId] = es;
101
+ _streams[storyId] = ws;
80
102
 
81
103
  function appendLine(storyId, line, cls) {
82
104
  setSessionsMap(prev => {
@@ -89,32 +111,24 @@ export function OrchPanel() {
89
111
  });
90
112
  }
91
113
 
114
+ // Append a multi-line output chunk: the first segment continues the last
115
+ // streamed line, each newline starts a fresh line element.
92
116
  function appendChunk(storyId, chunk) {
93
117
  setSessionsMap(prev => {
94
118
  const sess = prev[storyId];
95
119
  if (!sess) return prev;
96
- const lines = sess.lines;
120
+ const parts = chunk.split('\n');
121
+ const lines = [...sess.lines];
97
122
  const last = lines[lines.length - 1];
98
123
  if (last && last.cls === 'kt-stream') {
99
- const updated = [...lines];
100
- updated[updated.length - 1] = { ...last, text: last.text + chunk };
101
- return { ...prev, [storyId]: { ...sess, lines: updated } };
124
+ lines[lines.length - 1] = { ...last, text: last.text + parts[0] };
125
+ } else if (parts[0]) {
126
+ lines.push({ text: parts[0], cls: 'kt-stream' });
102
127
  }
103
- return {
104
- ...prev,
105
- [storyId]: { ...sess, lines: [...lines, { text: chunk, cls: 'kt-stream' }] },
106
- };
107
- });
108
- }
109
-
110
- function appendFileOp(storyId, fileOp) {
111
- setSessionsMap(prev => {
112
- const sess = prev[storyId];
113
- if (!sess) return prev;
114
- return {
115
- ...prev,
116
- [storyId]: { ...sess, fileOps: [...sess.fileOps, fileOp] },
117
- };
128
+ for (let i = 1; i < parts.length; i++) {
129
+ lines.push({ text: parts[i], cls: 'kt-stream' });
130
+ }
131
+ return { ...prev, [storyId]: { ...sess, lines } };
118
132
  });
119
133
  }
120
134
 
@@ -126,41 +140,39 @@ export function OrchPanel() {
126
140
  });
127
141
  }
128
142
 
129
- es.onmessage = e => {
130
- try {
131
- const d = JSON.parse(e.data);
132
- if (d.chunk) appendChunk(storyId, d.chunk);
133
- if (d.line) {
134
- let cls = 'kt-line';
135
- const l = d.line;
136
- if (l.startsWith('')) cls += ' tool';
137
- else if (l.startsWith('⚠')) cls += ' warn';
138
- else if (l.startsWith('')) cls += ' err';
139
- else if (l.startsWith('')) cls += ' done-line';
140
- else if (l.startsWith('') || l.startsWith('') || l.startsWith('■')) cls += ' meta';
141
- appendLine(storyId, l, cls);
142
- }
143
- if (d.fileOp) appendFileOp(storyId, d.fileOp);
144
- if (d.status) {
145
- setTabStatus(storyId, d.status);
146
- if (d.status === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
147
- if (d.status === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
148
- if (d.status !== 'running') { closeStream(storyId); }
149
- }
150
- } catch { /* ignore parse errors */ }
143
+ ws.onmessage = e => {
144
+ let d;
145
+ try { d = JSON.parse(e.data); } catch { return; }
146
+ if (!d) return;
147
+ if (d.t === 'o' || d.t === 'hist') {
148
+ const text = stripAnsi(d.d);
149
+ if (text) appendChunk(storyId, text);
150
+ } else if (d.t === 's') {
151
+ setTabStatus(storyId, d.s);
152
+ if (d.s === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
153
+ if (d.s === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
154
+ if (d.s !== 'running' && d.s !== 'starting') closeStream(storyId);
155
+ }
151
156
  };
152
- es.onerror = () => {
157
+ ws.onerror = () => {
153
158
  setTabStatus(storyId, 'error');
154
159
  closeStream(storyId);
155
160
  };
161
+ ws.onclose = () => {
162
+ if (_streams[storyId] === ws) delete _streams[storyId];
163
+ };
156
164
  }
157
165
 
158
166
  const handleClose = useCallback(() => {
159
167
  setState({ orchPanel: null });
160
168
  }, []);
161
169
 
170
+ // Open (or focus) a session tab and attach its live stream. Used both for
171
+ // locally-opened tabs and for sessions discovered via the orchestrator API.
162
172
  function handleTabClick(storyId) {
173
+ setSessionsMap(prev => prev[storyId] ? prev : { ...prev, [storyId]: mkSession(storyId) });
163
174
  setActiveTab(storyId);
175
+ if (!_streams[storyId]) connectStream(storyId);
164
176
  }
165
177
 
166
178
  function handleTabClose(e, storyId) {
@@ -197,9 +209,14 @@ export function OrchPanel() {
197
209
  }
198
210
 
199
211
  const tabs = Object.keys(sessionsMap);
212
+ // Sessions the orchestrator knows about that aren't open as tabs yet —
213
+ // rendered as clickable entries so the panel reflects the API, not just
214
+ // locally-opened tabs.
215
+ const apiOnly = apiSessions.filter(s => s.storyId && !sessionsMap[s.storyId]);
200
216
  const activeSess = activeTab ? sessionsMap[activeTab] : null;
201
217
  const hasStream = activeTab && !!_streams[activeTab];
202
- const runningCount = Object.keys(_streams).length;
218
+ // 'blocked' = live PTY waiting for input — still a live session.
219
+ const runningCount = apiSessions.filter(s => s.status === 'running' || s.status === 'blocked').length;
203
220
 
204
221
  const panelCls = 'orch-panel' + (open ? ' open' : '');
205
222
 
@@ -213,9 +230,9 @@ export function OrchPanel() {
213
230
  <button class="orch-panel-close" onClick=${handleClose} title="Close" aria-label="Close panel"><${Icon} name="x" size=${14}/></button>
214
231
  </div>
215
232
 
216
- <!-- Tab strip -->
233
+ <!-- Tab strip — open tabs first, then API-known sessions not yet opened -->
217
234
  <div class="orch-tabs">
218
- ${tabs.length === 0 ? html`
235
+ ${tabs.length === 0 && apiOnly.length === 0 ? html`
219
236
  <div class="orch-term-empty orch-empty-tab">
220
237
  No active sessions
221
238
  </div>
@@ -239,6 +256,17 @@ export function OrchPanel() {
239
256
  </button>
240
257
  `;
241
258
  })}
259
+ ${apiOnly.map(s => html`
260
+ <button
261
+ key=${s.storyId}
262
+ class="orch-tab"
263
+ onClick=${() => handleTabClick(s.storyId)}
264
+ title=${'Attach to ' + s.storyId}
265
+ >
266
+ <span class=${'tab-status-dot ' + (s.status || 'starting')}></span>
267
+ <span>${s.storyId.slice(0, 20)}</span>
268
+ </button>
269
+ `)}
242
270
  </div>
243
271
 
244
272
  <!-- Terminal body -->