@try-works/dsh-recursive-mode 0.2.3 → 0.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/lib/client/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client.js +813 -58
- package/lib/delegation.d.ts +168 -0
- package/lib/enforcement.d.ts +8 -0
- package/lib/goals-projection.d.ts +92 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +915 -68
- package/lib/live-route.d.ts +1 -1
- package/lib/recursive_audit_team.tool.d.ts +2 -0
- package/lib/runtime.d.ts +112 -3
- package/lib/teams-loop.d.ts +160 -0
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +45 -19
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +1 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/styles.ts +310 -0
- package/src/delegation.ts +337 -1
- package/src/enforcement.ts +16 -1
- package/src/goals-projection.ts +149 -0
- package/src/index.ts +41 -11
- package/src/live-route.ts +69 -2
- package/src/recursive_audit_team.tool.ts +141 -0
- package/src/runtime.ts +134 -6
- package/src/teams-loop.ts +259 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase run-doc viewer (0.2.4): renders one .recursive/run/<runId>/<file>
|
|
3
|
+
* markdown doc read LAZILY via the host GET /.recursive/api/doc route, inside
|
|
4
|
+
* the Inspector run-detail panel. Read-only: NO approve / changes / comment /
|
|
5
|
+
* quit actions, NO session-answer writes, NO recursive/* session events.
|
|
6
|
+
*
|
|
7
|
+
* ATTRIBUTION — ported from @guillaumemeyer/dsh-plan-approval (MIT),
|
|
8
|
+
* https://github.com/guillaumemeyer/dsh-plan-approval:
|
|
9
|
+
* - the markdown line parser (parseDoc, based on parsePlan), and
|
|
10
|
+
* - the vim-nav + / search key handling (onKey/move/goTop/goBottom/
|
|
11
|
+
* computeMatches/searchNext/searchPrev/openSearch/closeSearch).
|
|
12
|
+
* Reused under MIT. NO planReviewOf / overlay / session-question logic is
|
|
13
|
+
* carried over — this viewer reads run artifacts.
|
|
14
|
+
*/
|
|
15
|
+
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
+
import type { ReactNode } from 'react'
|
|
17
|
+
import { fetchPhaseDoc } from './host-api.ts'
|
|
18
|
+
import type { BoardTheme } from './theme.ts'
|
|
19
|
+
|
|
20
|
+
/* ============================ parser (pure) ============================ */
|
|
21
|
+
|
|
22
|
+
/** One rendered markdown line (parsePlan base + fenced code + tables). */
|
|
23
|
+
export interface DocLine {
|
|
24
|
+
kind: 'blank' | 'h1' | 'h2' | 'h3' | 'h4' | 'li' | 'plain' | 'code' | 'table';
|
|
25
|
+
text: string;
|
|
26
|
+
/** table: data rows (header separator row dropped); first row is the header. */
|
|
27
|
+
cells?: string[][];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inline segment parsed from line text (bold / code span / link). */
|
|
31
|
+
export interface InlineSegment {
|
|
32
|
+
type: 'text' | 'bold' | 'code' | 'link';
|
|
33
|
+
text: string;
|
|
34
|
+
href?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Markdown -> line tokens. Base is parsePlan (blank/h1-h4/li/plain, MIT),
|
|
39
|
+
* extended for fenced code blocks (one code line per block) and pipe tables
|
|
40
|
+
* (one table line per block, header separator row dropped).
|
|
41
|
+
*/
|
|
42
|
+
export function parseDoc(plan: string): DocLine[] {
|
|
43
|
+
const raw = String(plan == null ? '' : plan).split('\n');
|
|
44
|
+
const out: DocLine[] = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (i < raw.length) {
|
|
47
|
+
const line = raw[i];
|
|
48
|
+
// blank
|
|
49
|
+
if (/^\s*$/.test(line)) { out.push({ kind: 'blank', text: '' }); i += 1; continue; }
|
|
50
|
+
// fenced code block: group until the closing fence
|
|
51
|
+
const fence = /^```\s*([\w+-]*)\s*$/.exec(line);
|
|
52
|
+
if (fence) {
|
|
53
|
+
const code: string[] = [];
|
|
54
|
+
i += 1;
|
|
55
|
+
while (i < raw.length && !/^```\s*$/.test(raw[i])) { code.push(raw[i]); i += 1; }
|
|
56
|
+
i += 1; // consume closing fence (or end of input)
|
|
57
|
+
out.push({ kind: 'code', text: code.join('\n') });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// pipe table: group consecutive | rows, drop the |-| separator row
|
|
61
|
+
if (/^\s*\|/.test(line)) {
|
|
62
|
+
const rows: string[][] = [];
|
|
63
|
+
while (i < raw.length && /^\s*\|/.test(raw[i])) {
|
|
64
|
+
const parts = raw[i].split('|').slice(1, -1).map((c) => c.trim());
|
|
65
|
+
rows.push(parts);
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
const data = rows.filter((r) => !r.every((c) => /^:?-{3,}:?$/.test(c)));
|
|
69
|
+
out.push({ kind: 'table', text: rows.map((r) => r.join(' | ')).join('\n'), cells: data });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// headings
|
|
73
|
+
const h = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
|
74
|
+
if (h) {
|
|
75
|
+
let kind: DocLine['kind'];
|
|
76
|
+
switch (h[1].length) {
|
|
77
|
+
case 1: kind = 'h1'; break;
|
|
78
|
+
case 2: kind = 'h2'; break;
|
|
79
|
+
case 3: kind = 'h3'; break;
|
|
80
|
+
default: kind = 'h4';
|
|
81
|
+
}
|
|
82
|
+
out.push({ kind, text: h[2] });
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// list item
|
|
87
|
+
const li = /^\s*[-*]\s+(.+)$/.exec(line);
|
|
88
|
+
if (li) { out.push({ kind: 'li', text: li[1] }); i += 1; continue; }
|
|
89
|
+
out.push({ kind: 'plain', text: line });
|
|
90
|
+
i += 1;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inline scanner: **bold**, `code`, [text](url); unmatched markers stay plain
|
|
97
|
+
* text. Used to build the React children of a line text.
|
|
98
|
+
*/
|
|
99
|
+
export function parseInline(text: string): InlineSegment[] {
|
|
100
|
+
const out: InlineSegment[] = [];
|
|
101
|
+
const re = /(\*\*(.+?)\*\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/g;
|
|
102
|
+
let last = 0;
|
|
103
|
+
let m: RegExpExecArray | null;
|
|
104
|
+
while ((m = re.exec(text)) !== null) {
|
|
105
|
+
if (m.index > last) out.push({ type: 'text', text: text.slice(last, m.index) });
|
|
106
|
+
if (m[1] !== undefined) out.push({ type: 'bold', text: m[2] });
|
|
107
|
+
if (m[3] !== undefined) out.push({ type: 'code', text: m[4] });
|
|
108
|
+
if (m[5] !== undefined) out.push({ type: 'link', text: m[6], href: m[7] });
|
|
109
|
+
last = re.lastIndex;
|
|
110
|
+
}
|
|
111
|
+
if (last < text.length) out.push({ type: 'text', text: text.slice(last) });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ============================ component ============================ */
|
|
116
|
+
|
|
117
|
+
export interface DocViewerProps {
|
|
118
|
+
runId: string;
|
|
119
|
+
worktreeRoot: string;
|
|
120
|
+
fileName: string;
|
|
121
|
+
theme: BoardTheme;
|
|
122
|
+
onClose: () => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SEARCH_HINTS: Array<[string, string]> = [
|
|
126
|
+
['j/k', 'move'], ['gg/G', 'ends'], ['/', 'search'], ['n/N', 'next/prev'], ['y', 'copy'], ['Esc', 'close'],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/** Inline segments -> ReactNodes (bold/code/link markup). */
|
|
130
|
+
function inlineNodes(segments: InlineSegment[], baseKey: string): ReactNode[] {
|
|
131
|
+
return segments.map((seg, n) => {
|
|
132
|
+
const key = baseKey + '-seg-' + String(n);
|
|
133
|
+
if (seg.type === 'bold') return createElement('strong', { key }, seg.text);
|
|
134
|
+
if (seg.type === 'code') return createElement('code', { key, className: 'rec-doc-inline-code' }, seg.text);
|
|
135
|
+
if (seg.type === 'link') return createElement('a', { key, href: seg.href, target: '_blank', rel: 'noreferrer', className: 'rec-doc-inline-link' }, seg.text);
|
|
136
|
+
return createElement('span', { key }, seg.text);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Render one parsed line as a React element. Headings/bullets get parsePlan
|
|
142
|
+
* sizing; code/table get block layout; inline markup applies to plain-ish text.
|
|
143
|
+
*/
|
|
144
|
+
function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
|
|
145
|
+
const cls = 'rec-doc-line rec-doc-' + line.kind + (isCurrent ? ' rec-doc-line-current' : '');
|
|
146
|
+
if (line.kind === 'blank') return createElement('div', { key: i, 'data-line': String(i), className: cls }, null);
|
|
147
|
+
if (line.kind === 'code') return createElement('pre', { key: i, 'data-line': String(i), className: cls + ' rec-doc-pre' }, createElement('code', { className: 'rec-doc-code' }, line.text));
|
|
148
|
+
if (line.kind === 'table') {
|
|
149
|
+
const all = line.cells ?? [];
|
|
150
|
+
const header = all[0] ?? [];
|
|
151
|
+
const body = all.slice(1);
|
|
152
|
+
return createElement('div', { key: i, 'data-line': String(i), className: cls },
|
|
153
|
+
createElement('table', { className: 'rec-doc-table' },
|
|
154
|
+
createElement('thead', null, createElement('tr', null, header.map((c, n) => createElement('th', { key: 'th-' + String(n) }, c)))),
|
|
155
|
+
createElement('tbody', null, body.map((r, n) => createElement('tr', { key: 'tr-' + String(n) }, r.map((c, m) => createElement('td', { key: 'td-' + String(m) }, c))))),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const nodes = inlineNodes(parseInline(line.text), String(i));
|
|
160
|
+
if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '•'), createElement('span', { className: 'rec-doc-li-text' }, nodes));
|
|
161
|
+
return createElement('div', { key: i, 'data-line': String(i), className: cls }, nodes);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The per-phase doc viewer. Fetches the route on mount / fileName change.
|
|
166
|
+
* Vim nav + / search + n/N + Esc; y copies the doc. Esc closes search first,
|
|
167
|
+
* else the viewer. data-theme is passed down by the hoisting Inspector
|
|
168
|
+
* (0.1.10 invariant: useBoardTheme lives in the panel).
|
|
169
|
+
*/
|
|
170
|
+
export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: DocViewerProps): ReactNode {
|
|
171
|
+
const [text, setText] = useState<string | null>(null);
|
|
172
|
+
const [error, setError] = useState<string | null>(null);
|
|
173
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
174
|
+
const [cursor, setCursor] = useState(0);
|
|
175
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
176
|
+
const [query, setQuery] = useState('');
|
|
177
|
+
const [matches, setMatches] = useState<number[]>([]);
|
|
178
|
+
const [activeMatch, setActiveMatch] = useState(0);
|
|
179
|
+
const ggArmed = useRef(false);
|
|
180
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
181
|
+
const [copied, setCopied] = useState(false);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
let disposed = false;
|
|
185
|
+
setText(null); setError(null); setNotice(null); setCopied(false); setCursor(0);
|
|
186
|
+
fetchPhaseDoc({ root: worktreeRoot, runId, file: fileName })
|
|
187
|
+
.then((t) => { if (!disposed) { setText(t); setCopied(false); } })
|
|
188
|
+
.catch(() => { if (!disposed) setError('Failed to load doc'); });
|
|
189
|
+
return () => { disposed = true; };
|
|
190
|
+
}, [worktreeRoot, runId, fileName]);
|
|
191
|
+
|
|
192
|
+
const docLines = useMemo(() => (text === null ? [] : parseDoc(text)), [text]);
|
|
193
|
+
|
|
194
|
+
const move = (delta: number) => {
|
|
195
|
+
if (docLines.length === 0) return;
|
|
196
|
+
setCursor((c) => Math.max(0, Math.min(docLines.length - 1, c + delta)));
|
|
197
|
+
};
|
|
198
|
+
const goTop = () => setCursor(0);
|
|
199
|
+
const goBottom = () => setCursor(Math.max(0, docLines.length - 1));
|
|
200
|
+
const computeMatches = (q: string) => {
|
|
201
|
+
if (!q) return [];
|
|
202
|
+
const needle = q.toLowerCase();
|
|
203
|
+
const found: number[] = [];
|
|
204
|
+
for (let i = 0; i < docLines.length; i++) {
|
|
205
|
+
if (docLines[i].text.toLowerCase().indexOf(needle) >= 0) found.push(i);
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
};
|
|
209
|
+
const openSearch = () => { setSearchOpen(true); setError(null); setNotice(null); };
|
|
210
|
+
const closeSearch = () => { setSearchOpen(false); setQuery(''); setMatches([]); setActiveMatch(0); if (rootRef.current) rootRef.current.focus(); };
|
|
211
|
+
const onSearchChange = (e: { target: { value: string } }) => {
|
|
212
|
+
const q = e.target.value;
|
|
213
|
+
setQuery(q);
|
|
214
|
+
setMatches(computeMatches(q));
|
|
215
|
+
setActiveMatch(0);
|
|
216
|
+
};
|
|
217
|
+
const searchNext = () => {
|
|
218
|
+
if (matches.length === 0) return;
|
|
219
|
+
const next = (activeMatch + 1) % matches.length;
|
|
220
|
+
setActiveMatch(next);
|
|
221
|
+
setCursor(matches[next]);
|
|
222
|
+
};
|
|
223
|
+
const searchPrev = () => {
|
|
224
|
+
if (matches.length === 0) return;
|
|
225
|
+
const prev = (activeMatch - 1 + matches.length) % matches.length;
|
|
226
|
+
setActiveMatch(prev);
|
|
227
|
+
setCursor(matches[prev]);
|
|
228
|
+
};
|
|
229
|
+
const onSearchKey = (e: { key: string; shiftKey: boolean; preventDefault: () => void }) => {
|
|
230
|
+
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); searchPrev(); }
|
|
231
|
+
else if (e.key === 'Enter') { e.preventDefault(); searchNext(); }
|
|
232
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const copyDoc = () => {
|
|
236
|
+
// SAFETY: node/SSR has no clipboard object; the guarded shape matches lib.dom's
|
|
237
|
+
// Navigator.clipboard and falls back to an error status when absent.
|
|
238
|
+
const clip = (globalThis as { navigator?: { clipboard?: { writeText?: (t: string) => Promise<void> } } }).navigator?.clipboard;
|
|
239
|
+
if (clip === undefined || clip.writeText === undefined) { setError('Copy unavailable'); setNotice(null); return; }
|
|
240
|
+
if (clip && clip.writeText) {
|
|
241
|
+
const data = text ?? '';
|
|
242
|
+
clip.writeText(data).then(() => { setNotice('Doc copied'); setError(null); setCopied(true); }).catch(() => { setError('Copy failed'); setNotice(null); });
|
|
243
|
+
} else { setError('Copy unavailable'); setNotice(null); }
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const onKey = (e: { key: string; preventDefault: () => void; target?: { tagName?: string } | null }) => {
|
|
247
|
+
const tag = (e.target && e.target.tagName) || '';
|
|
248
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
249
|
+
const rawKey = e.key || '';
|
|
250
|
+
const key = rawKey.toLowerCase();
|
|
251
|
+
if (rawKey === 'G') { e.preventDefault(); ggArmed.current = false; goBottom(); return; }
|
|
252
|
+
if (rawKey === 'N') { e.preventDefault(); ggArmed.current = false; searchPrev(); return; }
|
|
253
|
+
if (key === 'g') {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
if (ggArmed.current) { ggArmed.current = false; goTop(); }
|
|
256
|
+
else ggArmed.current = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
ggArmed.current = false;
|
|
260
|
+
if (key === 'j') { e.preventDefault(); move(1); }
|
|
261
|
+
else if (key === 'k') { e.preventDefault(); move(-1); }
|
|
262
|
+
else if (key === '/') { e.preventDefault(); openSearch(); }
|
|
263
|
+
else if (key === 'n') { e.preventDefault(); searchNext(); }
|
|
264
|
+
else if (key === 'y') { e.preventDefault(); copyDoc(); }
|
|
265
|
+
else if (key === 'escape') {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
if (searchOpen) closeSearch();
|
|
268
|
+
else onClose();
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const matchSet = new Set(matches);
|
|
273
|
+
const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
|
|
274
|
+
|
|
275
|
+
const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
|
|
276
|
+
|
|
277
|
+
const searchBar = searchOpen ? createElement('div', { className: 'rec-doc-search' },
|
|
278
|
+
createElement('input', { className: 'rec-doc-search-input', value: query, placeholder: '/ search doc…', onChange: onSearchChange, onKeyDown: onSearchKey }),
|
|
279
|
+
createElement('span', { className: 'rec-doc-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : '')),
|
|
280
|
+
) : null;
|
|
281
|
+
|
|
282
|
+
const statusText = error || notice || (docLines.length + ' lines');
|
|
283
|
+
const statusCls = 'rec-doc-status' + (error ? ' rec-doc-status-error' : notice ? ' rec-doc-status-ok' : '');
|
|
284
|
+
|
|
285
|
+
return createElement('div', { className: 'rec-doc', 'data-theme': theme, onKeyDown: onKey, ref: rootRef, tabIndex: -1 },
|
|
286
|
+
createElement('header', { className: 'rec-doc-header' },
|
|
287
|
+
createElement('div', { className: 'rec-doc-heading' },
|
|
288
|
+
createElement('span', { className: 'rec-doc-badge' }, 'Phase doc'),
|
|
289
|
+
createElement('h2', { className: 'rec-doc-title' }, fileName),
|
|
290
|
+
createElement('span', { className: 'rec-doc-run' }, runId),
|
|
291
|
+
),
|
|
292
|
+
createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-copy', onClick: copyDoc, title: 'Copy doc', 'aria-label': 'Copy doc' }, 'Copy doc'),
|
|
293
|
+
createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-close', onClick: onClose, title: 'Close', 'aria-label': 'Close' }, 'Close'),
|
|
294
|
+
),
|
|
295
|
+
searchBar,
|
|
296
|
+
createElement('div', { className: 'rec-doc-body' },
|
|
297
|
+
text === null && error === null ? createElement('p', { className: 'rec-doc-text' }, 'Loading doc…') : null,
|
|
298
|
+
error !== null ? createElement('p', { className: 'rec-doc-text rec-doc-error' }, error) : null,
|
|
299
|
+
text !== null ? lineEls : null,
|
|
300
|
+
),
|
|
301
|
+
createElement('footer', { className: 'rec-doc-footer' },
|
|
302
|
+
createElement('div', { className: statusCls, role: 'status' }, statusText),
|
|
303
|
+
createElement('div', { className: 'rec-doc-hints' }, SEARCH_HINTS.map((h, n) => createElement('span', { key: 'hint-' + String(n) }, createElement('kbd', null, h[0]), ' ' + h[1] + (n < SEARCH_HINTS.length - 1 ? ' |' : '')))),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
package/src/client/host-api.ts
CHANGED
|
@@ -55,6 +55,34 @@ export async function fetchLiveState(scope: LiveScope, signal?: AbortSignal): Pr
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** One doc fetch request: the host-known workspace root + run id + phase doc file. */
|
|
59
|
+
export interface PhaseDocRequest {
|
|
60
|
+
root: string
|
|
61
|
+
runId: string
|
|
62
|
+
file: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch one per-phase run doc (0.2.4) via the lazy GET /.recursive/api/doc
|
|
67
|
+
* route. Raw markdown text; the host validates the root (known workspace) and
|
|
68
|
+
* guards the file path (phase-doc basename only, containment under the run
|
|
69
|
+
* dir). Same 15s AbortController timeout as fetchLiveState.
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchPhaseDoc(req: PhaseDocRequest): Promise<string> {
|
|
72
|
+
const controller = new AbortController()
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
74
|
+
try {
|
|
75
|
+
const q = 'root=' + encodeURIComponent(req.root)
|
|
76
|
+
+ '&runId=' + encodeURIComponent(req.runId)
|
|
77
|
+
+ '&file=' + encodeURIComponent(req.file)
|
|
78
|
+
const res = await fetch(RECURSIVE_API_PREFIX + '/doc?' + q, { signal: controller.signal, cache: 'no-store' })
|
|
79
|
+
if (!res.ok) throw new Error('recursive phase doc: HTTP ' + res.status)
|
|
80
|
+
return await res.text()
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
/**
|
|
59
87
|
* Subscribe to the SSE events feed. Returns a disposer. The host pushes a full
|
|
60
88
|
* {revision, root, projection} frame on every fs change + a 15s heartbeat.
|
package/src/client/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { claimClientApply, releaseClientApply } from './apply-guard.ts'
|
|
|
17
17
|
|
|
18
18
|
export { Board, listRuns } from './board.tsx'
|
|
19
19
|
export { Inspector } from './inspector.tsx'
|
|
20
|
+
export { DocViewer, parseDoc, parseInline } from './doc-viewer.tsx'
|
|
20
21
|
export { RecursiveView } from './slots.ts'
|
|
21
22
|
export { RecursiveSettings } from './settings.tsx'
|
|
22
23
|
export { useLiveProjection } from './use-live.ts'
|
package/src/client/inspector.tsx
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* toggle, and solid status pills. All from the live host-route snapshot.
|
|
5
5
|
* useBoardTheme is hoisted ABOVE the not-found early return.
|
|
6
6
|
*/
|
|
7
|
-
import { createElement, type ReactNode } from 'react'
|
|
7
|
+
import { createElement, useState, type ReactNode } from 'react'
|
|
8
8
|
import type { LiveProjectionValue } from './contract.ts'
|
|
9
9
|
import { cardFacts, expandPhaseRows, cardPill, phaseStatusPill, PILL_LABELS } from './derive.ts'
|
|
10
|
+
import { DocViewer } from './doc-viewer.tsx'
|
|
10
11
|
import { useBoardTheme, ThemeToggle } from './theme.ts'
|
|
11
12
|
import type { BoardTheme } from './theme.ts'
|
|
12
13
|
|
|
@@ -44,6 +45,7 @@ function detailShell(runId: string, onBackToToolDetails: () => void, onClose: ((
|
|
|
44
45
|
|
|
45
46
|
export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails, onClose }: InspectorProps) {
|
|
46
47
|
const { theme, toggle } = useBoardTheme()
|
|
48
|
+
const [openFileName, setOpenFileName] = useState<string | null>(null)
|
|
47
49
|
const card = snapshot?.projection?.[worktreeRoot]?.[runId]
|
|
48
50
|
if (card === undefined) {
|
|
49
51
|
return detailShell(runId, onBackToToolDetails, onClose, null,
|
|
@@ -55,13 +57,22 @@ export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails,
|
|
|
55
57
|
const rows = expandPhaseRows(card)
|
|
56
58
|
const pill = cardPill(card)
|
|
57
59
|
const headerExtra = solidPill(pill)
|
|
58
|
-
const body =
|
|
60
|
+
const body = openFileName !== null
|
|
61
|
+
? createElement(DocViewer, { runId, worktreeRoot, fileName: openFileName, theme, onClose: () => setOpenFileName(null) })
|
|
62
|
+
: createElement('section', { className: 'rec-detail-section' },
|
|
59
63
|
createElement('h3', {}, 'Phases'),
|
|
60
64
|
rows.map((row) => createElement('div', { key: row.phase, className: 'rec-phase-row' },
|
|
61
65
|
createElement('span', { className: 'rec-phase-id' }, row.phase),
|
|
62
66
|
createElement('span', { className: 'rec-phase-name' }, row.fileName ?? '—'),
|
|
63
67
|
solidPill(phaseStatusPill(row.status)),
|
|
64
68
|
row.lockHash !== undefined && createElement('code', { className: 'rec-lockhash' }, '#' + row.lockHash.slice(0, 8)),
|
|
69
|
+
row.present && row.fileName !== null && createElement('button', {
|
|
70
|
+
type: 'button',
|
|
71
|
+
className: 'rec-phase-view',
|
|
72
|
+
onClick: () => setOpenFileName(row.fileName!),
|
|
73
|
+
title: 'View phase doc',
|
|
74
|
+
'aria-label': 'View phase doc',
|
|
75
|
+
}, 'View phase'),
|
|
65
76
|
)),
|
|
66
77
|
facts.gateBlocked && createElement('div', { className: 'rec-detail-section' },
|
|
67
78
|
createElement('h3', {}, 'Gate'),
|