@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.
- package/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- 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, '<') + '</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,
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -->
|