@hanzlaa/rcode 4.5.0 → 4.6.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.
@@ -1,101 +1,31 @@
1
1
  /**
2
- * OrchestrationView — Preact port of renderOrchestration() + _orchCard().
2
+ * OrchestrationView — 2-column Diwan layout.
3
+ *
4
+ * Left rail (300px): view header, Agents card (live rows for whatever the
5
+ * orchestrator is currently running, fed by /api/sessions — replaces the
6
+ * old static command-list "Runner picker"), Pipeline card (live sessions,
7
+ * or a history-status summary when nothing is live).
8
+ * Right column: the docked xterm.js terminal (XtermPanel docked=true) with
9
+ * Run History beneath it.
3
10
  *
4
11
  * Reads activeSessions from the store (kept fresh by startSessionsPoll in
5
- * orchestrator.js at 4 s intervals). Terminal button sets store.terminal so
6
- * XtermPanel opens. Stop calls orchestrator.stopSession.
12
+ * orchestrator.js at 4 s intervals). Terminal focus sets store.terminal so
13
+ * the docked XtermPanel opens/reattaches. Stop calls orchestrator.stopSession.
7
14
  *
8
15
  * No separate poll timer — if a tighter cadence is needed while this view is
9
16
  * open, a local useEffect interval can be added. For now the 4 s global poll
10
17
  * is sufficient.
11
18
  */
12
19
 
13
- import { html, useState, useEffect } from '../preact.js';
20
+ import { html, useState } from '../preact.js';
14
21
  import { useStore } from '../store.js';
15
- import { stopSession, openTermPanel, ALLOWED_COMMANDS, isSessionRunning, mergeSessionsAndHistory } from '../orchestrator.js';
22
+ import { stopSession, openTermPanel, ALLOWED_COMMANDS, mergeSessionsAndHistory } from '../orchestrator.js';
16
23
  import { openRunnerPicker } from '../components/RunnerPicker.js';
17
24
  import { RejectDialog } from '../components/RejectDialog.js';
25
+ import { XtermPanel } from '../components/XtermPanel.js';
18
26
  import { orchElapsed, humanDate } from '../util.js';
19
27
  import { Icon } from '../icons-client.js';
20
28
 
21
- // ── Session card ──────────────────────────────────────────────────────────────
22
-
23
- // Tooltip text per session status — shown on the colored status dot.
24
- const DOT_TITLES = {
25
- running: 'Running — output streaming',
26
- blocked: 'Blocked — waiting for your input',
27
- done: 'Done — exited cleanly',
28
- exited: 'Exited',
29
- stopped: 'Stopped',
30
- error: 'Error',
31
- };
32
-
33
- function OrchCard({ session: s }) {
34
- const blocked = s.status === 'blocked';
35
- // 'blocked' is a live PTY (server-side classification of running) — keep
36
- // the Stop button available for it.
37
- const running = s.status === 'running' || blocked;
38
- const waiting = blocked || !!s.waiting;
39
- const [showReject, setShowReject] = useState(false);
40
- const cardCls = 'orch-card orch-' + s.status + (waiting ? ' orch-waiting' : '');
41
- const badge = blocked
42
- ? html`<${Icon} name="alert-triangle" size=${12}/> blocked — needs input`
43
- : waiting ? html`<${Icon} name="hourglass" size=${12}/> waiting for input` : s.status;
44
- const dotCls = 'term-status-dot ' + (blocked ? 'blocked' : waiting ? 'waiting' : s.status);
45
- const dotTitle = DOT_TITLES[s.status] || s.status;
46
-
47
- function handleTerminal(e) {
48
- e.stopPropagation();
49
- openTermPanel(s.storyId, s.storyId);
50
- }
51
-
52
- function handleStop(e) {
53
- e.stopPropagation();
54
- stopSession(s.storyId);
55
- }
56
-
57
- return html`
58
- <div class=${cardCls}>
59
- <div class="orch-card-head">
60
- <span class=${dotCls} title=${dotTitle}></span>
61
- <span class="orch-card-id">${s.storyId}</span>
62
- ${s.runner ? html`
63
- <span class="runner-badge" title=${'Launched with ' + s.runner + (s.model ? ' (' + s.model + ')' : '')}>
64
- ${s.runner}${s.model ? ' · ' + s.model : ''}
65
- </span>
66
- ` : null}
67
- <span class="orch-card-badge">${badge}</span>
68
- </div>
69
- <div class="orch-card-cmd">${s.cmd || ''}</div>
70
- <div class="orch-card-meta">
71
- <${Icon} name="clock" size=${12}/> ${orchElapsed(s.startTime)}
72
- ${' · '}<${Icon} name="edit-3" size=${12}/> ${s.filesChanged || 0} file${s.filesChanged === 1 ? '' : 's'}
73
- ${' · '}<${Icon} name="eye" size=${12}/> ${s.clients || 0}
74
- ${s.pid ? html` · pid ${s.pid}` : null}
75
- </div>
76
- ${s.rejection ? html`
77
- <div class="orch-card-rejection">
78
- Rejected: ${s.rejection.reason}
79
- </div>
80
- ` : null}
81
- <div class="orch-card-actions">
82
- <button class="term-run-btn outline" onClick=${handleTerminal}>
83
- <${Icon} name="monitor" size=${14}/> Terminal
84
- </button>
85
- ${running ? html`
86
- <button class="term-run-btn danger" onClick=${handleStop}>■ Stop</button>
87
- ` : null}
88
- ${waiting ? html`
89
- <button class="term-run-btn danger" onClick=${e => { e.stopPropagation(); setShowReject(true); }}>
90
- <${Icon} name="alert-triangle" size=${14}/> Reject
91
- </button>
92
- ` : null}
93
- </div>
94
- ${showReject ? html`<${RejectDialog} session=${s} onClose=${() => setShowReject(false)}/>` : null}
95
- </div>
96
- `;
97
- }
98
-
99
29
  // ── Sorted session list ───────────────────────────────────────────────────────
100
30
 
101
31
  function sortSessions(sessions) {
@@ -115,76 +45,215 @@ function sortSessions(sessions) {
115
45
  });
116
46
  }
117
47
 
118
- // ── Command runner ────────────────────────────────────────────────────────────
48
+ // ── Agents card (left rail) ───────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Two-letter mono abbreviation derived from a storyId (e.g. "cmd-rcode-status"
52
+ * → "RS", "sprint-33.1" → "S3", "phase-33" → "PH"). Strips the synthetic
53
+ * "cmd-" prefix used by the command runner so command sessions abbreviate on
54
+ * their actual command name, not the literal word "cmd".
55
+ */
56
+ function agentAbbr(storyId) {
57
+ const id = String(storyId || '').replace(/^cmd-/, '');
58
+ const parts = id.split(/[-.]/).filter(Boolean);
59
+ if (parts.length > 1) return (parts[0][0] + parts[1][0]).toUpperCase();
60
+ return id.slice(0, 2).toUpperCase();
61
+ }
62
+
63
+ // How long an ended session keeps showing in the Agents card after it exits,
64
+ // so the transition from "running" to "gone" isn't instant. After this the
65
+ // row falls out of the list; Pipeline's history summary + Run History still
66
+ // have the record.
67
+ const RECENTLY_EXITED_MS = 60000;
119
68
 
120
69
  /**
121
- * CommandRunner dropdown + Run button for launching allowlisted rcode commands.
122
- * State is local (useState) no store changes needed; runCommandFromUI handles
123
- * all session and terminal state via runCommandFromUI runSession.
70
+ * Live status for an Agents-card row. classifyStatus() on the server already
71
+ * folds a stalled 'running' PTY into 'blocked'; the 'waiting' flag is a
72
+ * separate, shorter-idle-threshold signal that can be true while status is
73
+ * still 'running'. Both read as the same "needs a look" state here, matching
74
+ * PipelineCard's grouping.
124
75
  */
125
- function CommandRunner() {
126
- // Subscribe to store updates so isSessionRunning() + orchOnline re-evaluate on each poll.
127
- const { orchOnline } = useStore();
76
+ function agentStatusMeta(s) {
77
+ if (s.status === 'blocked' || s.waiting) return { label: 'Blocked', cls: 'blocked' };
78
+ if (s.status === 'running') return { label: 'Running', cls: 'running' };
79
+ return { label: s.status.charAt(0).toUpperCase() + s.status.slice(1), cls: 'exited' };
80
+ }
81
+
82
+ /** Elapsed / idle / files-changed glance line from the fields /api/sessions already reports. */
83
+ function agentGlance(s) {
84
+ const bits = [orchElapsed(s.startTime) + ' elapsed'];
85
+ if (typeof s.idleSeconds === 'number') bits.push('idle ' + s.idleSeconds + 's');
86
+ if (typeof s.filesChanged === 'number') bits.push(s.filesChanged + ' file' + (s.filesChanged === 1 ? '' : 's') + ' changed');
87
+ return bits.join(' · ');
88
+ }
89
+
90
+ /**
91
+ * AgentsCard — replaces the old static "Runner picker" command list. Shows
92
+ * the agents the orchestrator is CURRENTLY RUNNING (live sessions from
93
+ * /api/sessions), each row driven entirely by fields the API already
94
+ * returns: storyId, cmd, status/waiting, startTime, idleSeconds,
95
+ * filesChanged. Clicking a row attaches the docked terminal to that session
96
+ * (same wiring as PipelineCard / #969's fix-orch-live-agents predecessor
97
+ * commit). Command launching (previously this card's only job) moves to the
98
+ * compact footer control — RunnerPicker.js and the confirm-dialog flow are
99
+ * unchanged.
100
+ */
101
+ function AgentsCard() {
102
+ const { activeSessions, orchOnline, terminal } = useStore();
128
103
  const [selected, setSelected] = useState(ALLOWED_COMMANDS[0]?.cmd || '');
129
- const [busy, setBusy] = useState(false);
130
-
131
- const slug = selected ? selected.replace(/^\//, '').replace(/\//g, '-') : '';
132
- const sessionId = slug ? 'cmd-' + slug : '';
133
- const isRunning = sessionId ? isSessionRunning(sessionId) : false;
134
- const orchDown = orchOnline === false;
135
- const disabled = busy || isRunning || orchDown;
136
-
137
- // Reset busy 2 s after a Run click — the terminal panel is now open and the
138
- // session is streaming. Managed via useEffect so the timer is cancelled if
139
- // CommandRunner unmounts before it fires.
140
- useEffect(() => {
141
- if (!busy) return;
142
- const t = setTimeout(() => setBusy(false), 2000);
143
- return () => clearTimeout(t);
144
- }, [busy]);
104
+ const orchDown = orchOnline === false;
105
+ const attachedStoryId = terminal && terminal.open ? terminal.storyId : '';
106
+
107
+ const now = Date.now();
108
+ const agentRows = sortSessions((activeSessions || []).filter(s => {
109
+ if (s.status === 'running' || s.status === 'blocked') return true;
110
+ const last = Date.parse(s.lastOutputAt || s.startTime || '') || 0;
111
+ return (now - last) < RECENTLY_EXITED_MS;
112
+ }));
145
113
 
146
114
  function handleRun(e) {
147
- if (!selected || disabled) return;
148
- setBusy(true);
115
+ if (!selected || orchDown) return;
149
116
  openRunnerPicker(e.currentTarget, { kind: 'command', cmd: selected, title: selected });
150
117
  }
151
118
 
152
119
  return html`
153
- <div class="cmd-runner">
154
- <div class="cmd-runner-title">
155
- <${Icon} name="terminal" size=${14}/> Command Runner
120
+ <div class="orch-runner-card">
121
+ <div class="orch-card-label">Agents</div>
122
+ <div class="orch-runner-list">
123
+ ${agentRows.length === 0 ? html`
124
+ <div class="orch-runner-empty">
125
+ <div class="empty">No agents running</div>
126
+ <div class="orch-runner-hint">Use "Run a command" below or launch a story from the Kanban board.</div>
127
+ </div>
128
+ ` : agentRows.map(s => {
129
+ const meta = agentStatusMeta(s);
130
+ const isAttached = s.storyId === attachedStoryId;
131
+ return html`
132
+ <div key=${s.storyId}
133
+ class=${'orch-runner-row' + (isAttached ? ' attached' : '')}
134
+ onClick=${() => openTermPanel(s.storyId, s.storyId)}>
135
+ <span class="orch-runner-abbr">${agentAbbr(s.storyId)}</span>
136
+ <div class="orch-runner-info">
137
+ <div class="orch-runner-name">${s.storyId}</div>
138
+ <div class="orch-runner-role">${s.cmd}</div>
139
+ <div class="orch-runner-glance">${agentGlance(s)}</div>
140
+ </div>
141
+ <span class=${'orch-agent-status ' + meta.cls}>${meta.label}</span>
142
+ </div>
143
+ `;
144
+ })}
156
145
  </div>
157
- <div class="cmd-runner-row">
158
- <select class="cmd-runner-select"
159
- value=${selected}
160
- onChange=${e => setSelected(e.target.value)}>
161
- ${ALLOWED_COMMANDS.map(({ cmd, label }) => html`
162
- <option key=${cmd} value=${cmd}>${label}</option>
163
- `)}
164
- </select>
165
- <button class=${'cmd-runner-btn' + (disabled ? ' cmd-runner-btn--busy' : '')}
166
- onClick=${handleRun}
167
- disabled=${disabled}>
168
- ${isRunning
169
- ? html`<${Icon} name="hourglass" size=${14}/> Running…`
170
- : busy
171
- ? html`<${Icon} name="hourglass" size=${14}/> Starting…`
172
- : html`<${Icon} name="play" size=${14}/> Run`}
146
+ <div class="orch-runner-card-footer">
147
+ <label class="orch-run-cmd-field">
148
+ <span class="orch-runner-hint">Run a command</span>
149
+ <select class="runner-picker-select" value=${selected}
150
+ onChange=${e => setSelected(e.target.value)}>
151
+ ${ALLOWED_COMMANDS.map(({ cmd, label }) => html`
152
+ <option key=${cmd} value=${cmd}>${(label || '').split('—')[0].trim()}</option>
153
+ `)}
154
+ </select>
155
+ </label>
156
+ <button class="term-run-btn" disabled=${orchDown} onClick=${handleRun}>
157
+ <${Icon} name="play" size=${14}/> Run
173
158
  </button>
174
- </div>
175
- <div class="cmd-runner-hint">
176
- ${orchDown
177
- ? html`Orchestrator is unreachable — commands cannot run until it is back.`
178
- : isRunning
179
- ? html`Command is running — output is streaming to the terminal panel.`
180
- : busy
181
- ? html`Starting — the terminal panel will open shortly.`
182
- : html`Select a command and press Run. Output streams live to the terminal panel.`}
159
+ ${orchDown ? html`
160
+ <div class="orch-runner-hint">Orchestrator unreachable — Run is disabled.</div>
161
+ ` : null}
183
162
  </div>
184
163
  </div>
185
164
  `;
186
165
  }
187
166
 
167
+ // ── Pipeline card (left rail) ─────────────────────────────────────────────────
168
+
169
+ const PIPELINE_GLYPH = {
170
+ running: '◐', blocked: '!', waiting: '…',
171
+ done: '✓', exited: '✕', stopped: '■', error: '✕',
172
+ };
173
+
174
+ function PipelineRow({ glyphCls, glyph, label, count, onClick, actions, active }) {
175
+ return html`
176
+ <div class=${'orch-pipeline-row' + (active ? ' active' : '')} onClick=${onClick}>
177
+ <span class=${'orch-pipeline-glyph ' + glyphCls}>${glyph}</span>
178
+ <span class="orch-pipeline-label-text">${label}</span>
179
+ <span class="orch-pipeline-count">${count}</span>
180
+ ${actions ? html`<span class="orch-pipeline-actions">${actions}</span>` : null}
181
+ </div>
182
+ `;
183
+ }
184
+
185
+ /**
186
+ * PipelineCard — live sessions (clickable to focus in the docked terminal,
187
+ * with inline Stop / Reject affordances) when any exist; otherwise falls
188
+ * back to a STATUS_ORDER history-count summary so the card stays useful.
189
+ */
190
+ function PipelineCard() {
191
+ const { activeSessions, history, terminal } = useStore();
192
+ const [rejectFor, setRejectFor] = useState(null);
193
+ const attachedStoryId = terminal && terminal.open ? terminal.storyId : '';
194
+ const live = sortSessions((activeSessions || []).filter(
195
+ s => s.status === 'running' || s.status === 'blocked'
196
+ ));
197
+
198
+ let body;
199
+ if (live.length > 0) {
200
+ body = live.map(s => {
201
+ const blocked = s.status === 'blocked';
202
+ const waiting = blocked || !!s.waiting;
203
+ const glyphCls = waiting ? 'blocked' : 'running';
204
+ const actions = html`
205
+ ${waiting ? html`
206
+ <button class="orch-pipeline-action-btn danger"
207
+ title="Reject"
208
+ onClick=${e => { e.stopPropagation(); setRejectFor(s); }}>
209
+ <${Icon} name="alert-triangle" size=${11}/>
210
+ </button>
211
+ ` : null}
212
+ <button class="orch-pipeline-action-btn danger"
213
+ title="Stop"
214
+ onClick=${e => { e.stopPropagation(); stopSession(s.storyId); }}>■</button>
215
+ `;
216
+ return html`
217
+ <${PipelineRow} key=${s.storyId}
218
+ glyphCls=${glyphCls}
219
+ glyph=${PIPELINE_GLYPH[blocked ? 'blocked' : 'running']}
220
+ label=${s.storyId}
221
+ count=${orchElapsed(s.startTime)}
222
+ active=${s.storyId === attachedStoryId}
223
+ onClick=${() => openTermPanel(s.storyId, s.storyId)}
224
+ actions=${actions}
225
+ />
226
+ `;
227
+ });
228
+ } else {
229
+ const merged = mergeSessionsAndHistory(activeSessions, history);
230
+ const ended = merged.filter(r => r.status !== 'running' && r.status !== 'blocked');
231
+ body = STATUS_ORDER.map(status => {
232
+ const count = ended.filter(r => r.status === status).length;
233
+ if (count === 0) return null;
234
+ return html`
235
+ <${PipelineRow} key=${status}
236
+ glyphCls=${status}
237
+ glyph=${PIPELINE_GLYPH[status] || '·'}
238
+ label=${status}
239
+ count=${count}
240
+ />
241
+ `;
242
+ });
243
+ if (ended.length === 0) {
244
+ body = html`<div class="empty">No runs yet.</div>`;
245
+ }
246
+ }
247
+
248
+ return html`
249
+ <div class="orch-pipeline-card">
250
+ <div class="orch-pipeline-label">Pipeline</div>
251
+ ${body}
252
+ ${rejectFor ? html`<${RejectDialog} session=${rejectFor} onClose=${() => setRejectFor(null)}/>` : null}
253
+ </div>
254
+ `;
255
+ }
256
+
188
257
  // ── Run history panel ─────────────────────────────────────────────────────────
189
258
 
190
259
  function durationLabel(ms) {
@@ -194,9 +263,20 @@ function durationLabel(ms) {
194
263
  return Math.floor(ms / 3600000) + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
195
264
  }
196
265
 
197
- function HistoryRow({ run }) {
266
+ /**
267
+ * HistoryRow — ended runs are read-only UNLESS the server still holds the
268
+ * session in memory (run.source === 'live', from mergeSessionsAndHistory —
269
+ * true until "Clean sessions" removes it). While that's the case the PTY's
270
+ * scrollback is still attachable over WS, so the row is clickable and
271
+ * reattaches the docked terminal to replay it; otherwise there is nothing to
272
+ * attach to and the row stays static (no persisted log replay exists).
273
+ */
274
+ function HistoryRow({ run, active }) {
275
+ const attachable = run.source === 'live';
198
276
  return html`
199
- <div class="hist-row" key=${run.storyId}>
277
+ <div class=${'hist-row' + (attachable ? ' clickable' : '') + (active ? ' active' : '')}
278
+ key=${run.storyId}
279
+ onClick=${attachable ? () => openTermPanel(run.storyId, run.storyId) : undefined}>
200
280
  <span class=${'term-status-dot ' + run.status}></span>
201
281
  <span class="hist-row-id">${run.storyId}</span>
202
282
  <span class="hist-row-cmd">${run.cmd}</span>
@@ -209,7 +289,8 @@ function HistoryRow({ run }) {
209
289
  const STATUS_ORDER = ['done', 'exited', 'stopped', 'error'];
210
290
 
211
291
  function HistoryPanel() {
212
- const { activeSessions, history } = useStore();
292
+ const { activeSessions, history, terminal } = useStore();
293
+ const attachedStoryId = terminal && terminal.open ? terminal.storyId : '';
213
294
  const merged = mergeSessionsAndHistory(activeSessions, history);
214
295
  // 'blocked' is a live session (waiting for input), not an ended run.
215
296
  const ended = merged.filter(r => r.status !== 'running' && r.status !== 'blocked');
@@ -257,7 +338,7 @@ function HistoryPanel() {
257
338
  ${[...dateMap.entries()].map(([dateKey, runs]) => html`
258
339
  <div key=${dateKey}>
259
340
  <div class="hist-date">${dateKey}</div>
260
- ${runs.map(run => html`<${HistoryRow} key=${run.storyId} run=${run}/>`)}
341
+ ${runs.map(run => html`<${HistoryRow} key=${run.storyId} run=${run} active=${run.storyId === attachedStoryId}/>`)}
261
342
  </div>
262
343
  `)}
263
344
  </div>
@@ -271,42 +352,39 @@ function HistoryPanel() {
271
352
 
272
353
  export function OrchestrationView() {
273
354
  const { activeSessions, orchOnline } = useStore();
274
- const sessions = sortSessions(activeSessions || []);
355
+ const liveCount = (activeSessions || []).filter(
356
+ s => s.status === 'running' || s.status === 'blocked'
357
+ ).length;
275
358
  const orchDown = orchOnline === false;
276
359
 
277
360
  return html`
278
- <div class="view active" id="view-orchestration">
279
- <div class="view-title section-icon"><${Icon} name="activity" size=${18}/> Orchestration</div>
280
- <div class="orch-subtitle">
281
- Live agent sessions — run, watch, communicate, stop.
282
- </div>
283
-
284
- ${orchDown ? html`
285
- <div class="orch-down-banner" role="alert">
286
- ⚠ Orchestrator unreachable (port 7718) — Run buttons are disabled.
287
- Restart the dashboard, or set ORCH_PORT if the port is in use.
361
+ <div class="view active orch-layout" id="view-orchestration">
362
+ <div class="orch-left-rail">
363
+ <div>
364
+ <h1 class="orch-h1">Orchestration</h1>
365
+ <div class="orch-header-sub">
366
+ ${liveCount} agent${liveCount === 1 ? '' : 's'} live ·
367
+ ${orchDown ? ' runner unreachable' : ' runner connected'}
368
+ </div>
288
369
  </div>
289
- ` : null}
290
-
291
- <${CommandRunner}/>
292
370
 
293
- ${sessions.length === 0 ? html`
294
- <div class="empty">
295
- ${orchDown ? 'Session status unavailable while the orchestrator is down.' : 'No active execution.'}
296
- <div class="empty-action">
297
- Use the Command Runner above, or run <code>/rcode-execute</code> to
298
- start a phase or sprint.
371
+ ${orchDown ? html`
372
+ <div class="orch-down-banner" role="alert">
373
+ Orchestrator unreachable Run buttons are disabled.
374
+ Restart the dashboard, or set ORCH_PORT if the port is in use.
299
375
  </div>
300
- </div>
301
- ` : html`
302
- <div class="orch-grid">
303
- ${sessions.map(s => html`
304
- <${OrchCard} key=${s.storyId} session=${s} />
305
- `)}
306
- </div>
307
- `}
376
+ ` : null}
308
377
 
309
- <${HistoryPanel}/>
378
+ <${AgentsCard}/>
379
+ <${PipelineCard}/>
380
+ </div>
381
+
382
+ <div class="orch-right-col">
383
+ <${XtermPanel} docked=${true} />
384
+ <div class="orch-hist-dock">
385
+ <${HistoryPanel}/>
386
+ </div>
387
+ </div>
310
388
  </div>
311
389
  `;
312
390
  }