@try-works/dsh-recursive-mode 0.2.2 → 0.2.4
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/board.d.ts +7 -1
- package/lib/client/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +2 -0
- package/lib/client/slots.d.ts +10 -0
- package/lib/client.js +875 -65
- package/lib/index.js +142 -59
- package/lib/live-route.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/board.tsx +14 -7
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +2 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/slots.ts +43 -0
- package/src/client/styles.ts +321 -0
- package/src/live-route.ts +69 -2
package/lib/index.js
CHANGED
|
@@ -6661,14 +6661,30 @@ function queryOf(req, name) {
|
|
|
6661
6661
|
if (q < 0) return "";
|
|
6662
6662
|
return new URLSearchParams(url.slice(q + 1)).get(name) ?? "";
|
|
6663
6663
|
}
|
|
6664
|
+
/** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
|
|
6665
|
+
const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE);
|
|
6666
|
+
/** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
|
|
6667
|
+
const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/;
|
|
6668
|
+
/** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
|
|
6669
|
+
function docTargetError(runId, file) {
|
|
6670
|
+
if (!DOC_SAFE_RE.test(runId) || runId.includes("..")) return "invalid runId";
|
|
6671
|
+
if (!DOC_SAFE_RE.test(file) || file.includes("..") || file.includes("/") || file.includes("\\")) return "invalid file";
|
|
6672
|
+
if (!file.endsWith(".md")) return "invalid file: must be a .md phase doc";
|
|
6673
|
+
if (!PHASE_DOC_FILES.has(file)) return "invalid file: not a phase doc";
|
|
6674
|
+
return null;
|
|
6675
|
+
}
|
|
6664
6676
|
/**
|
|
6665
|
-
*
|
|
6666
|
-
*
|
|
6677
|
+
* The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
|
|
6678
|
+
* doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
|
|
6679
|
+
* projection payloads (they stay fold-only). Same browser-marker tripwire as
|
|
6680
|
+
* state/events; the client root is re-validated by the host (never trusted:
|
|
6681
|
+
* an unknown root -> 400), then the resolved doc path is containment-checked
|
|
6682
|
+
* under join(root, '.recursive', 'run', runId).
|
|
6667
6683
|
*/
|
|
6668
|
-
function
|
|
6669
|
-
return
|
|
6684
|
+
function docRoute(host) {
|
|
6685
|
+
return {
|
|
6670
6686
|
kind: "exact",
|
|
6671
|
-
path: "/.recursive/api/
|
|
6687
|
+
path: "/.recursive/api/doc",
|
|
6672
6688
|
handler: async (req, res) => {
|
|
6673
6689
|
if (req.method !== "GET") {
|
|
6674
6690
|
res.writeHead(405);
|
|
@@ -6680,75 +6696,142 @@ function makeRecursiveRoutes(host) {
|
|
|
6680
6696
|
res.end();
|
|
6681
6697
|
return;
|
|
6682
6698
|
}
|
|
6683
|
-
const
|
|
6684
|
-
const
|
|
6685
|
-
const
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6699
|
+
const root = queryOf(req, "root");
|
|
6700
|
+
const runId = queryOf(req, "runId");
|
|
6701
|
+
const file = queryOf(req, "file");
|
|
6702
|
+
const targetError = docTargetError(runId, file);
|
|
6703
|
+
if (root === "" || targetError !== null) {
|
|
6704
|
+
json(res, 400, {
|
|
6705
|
+
ok: false,
|
|
6706
|
+
error: targetError ?? "missing root"
|
|
6691
6707
|
});
|
|
6692
6708
|
return;
|
|
6693
6709
|
}
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
}, {
|
|
6701
|
-
kind: "exact",
|
|
6702
|
-
path: "/.recursive/api/events",
|
|
6703
|
-
handler: async (req, res) => {
|
|
6704
|
-
if (req.method !== "GET") {
|
|
6705
|
-
res.writeHead(405);
|
|
6706
|
-
res.end();
|
|
6710
|
+
const resolvedRoot = await host.resolveRoot(void 0, root);
|
|
6711
|
+
if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
|
|
6712
|
+
json(res, 400, {
|
|
6713
|
+
ok: false,
|
|
6714
|
+
error: "root is not a known workspace"
|
|
6715
|
+
});
|
|
6707
6716
|
return;
|
|
6708
6717
|
}
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6718
|
+
const runBase = resolve(root, ".recursive", "run", runId);
|
|
6719
|
+
const docPath = resolve(runBase, file);
|
|
6720
|
+
if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
|
|
6721
|
+
json(res, 400, {
|
|
6722
|
+
ok: false,
|
|
6723
|
+
error: "doc path escapes the run dir"
|
|
6724
|
+
});
|
|
6712
6725
|
return;
|
|
6713
6726
|
}
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
res.writeHead(200, {
|
|
6719
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
6720
|
-
"cache-control": "no-cache",
|
|
6721
|
-
connection: "keep-alive"
|
|
6727
|
+
if (!existsSync(docPath)) {
|
|
6728
|
+
json(res, 404, {
|
|
6729
|
+
ok: false,
|
|
6730
|
+
error: "phase doc not found"
|
|
6722
6731
|
});
|
|
6723
|
-
res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
|
|
6724
|
-
res.end();
|
|
6725
6732
|
return;
|
|
6726
6733
|
}
|
|
6734
|
+
const text = readFileSync(docPath, "utf8");
|
|
6727
6735
|
res.writeHead(200, {
|
|
6728
|
-
"content-type": "text/
|
|
6729
|
-
"cache-control": "no-
|
|
6730
|
-
connection: "keep-alive"
|
|
6736
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
6737
|
+
"cache-control": "no-store"
|
|
6731
6738
|
});
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6739
|
+
res.end(text);
|
|
6740
|
+
}
|
|
6741
|
+
};
|
|
6742
|
+
}
|
|
6743
|
+
/**
|
|
6744
|
+
* Build the read-only routes. Returns [state, events, doc] in registration order.
|
|
6745
|
+
* @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
|
|
6746
|
+
*/
|
|
6747
|
+
function makeRecursiveRoutes(host) {
|
|
6748
|
+
return [
|
|
6749
|
+
{
|
|
6750
|
+
kind: "exact",
|
|
6751
|
+
path: "/.recursive/api/state",
|
|
6752
|
+
handler: async (req, res) => {
|
|
6753
|
+
if (req.method !== "GET") {
|
|
6754
|
+
res.writeHead(405);
|
|
6755
|
+
res.end();
|
|
6756
|
+
return;
|
|
6757
|
+
}
|
|
6758
|
+
if (!browserMarker(req)) {
|
|
6759
|
+
res.writeHead(403);
|
|
6760
|
+
res.end();
|
|
6761
|
+
return;
|
|
6762
|
+
}
|
|
6763
|
+
const sessionId = queryOf(req, "sessionId") || void 0;
|
|
6764
|
+
const cwd = queryOf(req, "cwd");
|
|
6765
|
+
const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
|
|
6766
|
+
if (root === null) {
|
|
6767
|
+
json(res, 200, {
|
|
6768
|
+
root: null,
|
|
6769
|
+
projection: {},
|
|
6770
|
+
revision: 0
|
|
6771
|
+
});
|
|
6772
|
+
return;
|
|
6773
|
+
}
|
|
6774
|
+
json(res, 200, {
|
|
6735
6775
|
root,
|
|
6736
|
-
projection,
|
|
6776
|
+
projection: await host.snapshot(root),
|
|
6737
6777
|
revision: host.revision(root)
|
|
6778
|
+
});
|
|
6779
|
+
}
|
|
6780
|
+
},
|
|
6781
|
+
{
|
|
6782
|
+
kind: "exact",
|
|
6783
|
+
path: "/.recursive/api/events",
|
|
6784
|
+
handler: async (req, res) => {
|
|
6785
|
+
if (req.method !== "GET") {
|
|
6786
|
+
res.writeHead(405);
|
|
6787
|
+
res.end();
|
|
6788
|
+
return;
|
|
6789
|
+
}
|
|
6790
|
+
if (!browserMarker(req)) {
|
|
6791
|
+
res.writeHead(403);
|
|
6792
|
+
res.end();
|
|
6793
|
+
return;
|
|
6794
|
+
}
|
|
6795
|
+
const sessionId = queryOf(req, "sessionId") || void 0;
|
|
6796
|
+
const cwd = queryOf(req, "cwd");
|
|
6797
|
+
const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
|
|
6798
|
+
if (root === null) {
|
|
6799
|
+
res.writeHead(200, {
|
|
6800
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
6801
|
+
"cache-control": "no-cache",
|
|
6802
|
+
connection: "keep-alive"
|
|
6803
|
+
});
|
|
6804
|
+
res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
|
|
6805
|
+
res.end();
|
|
6806
|
+
return;
|
|
6807
|
+
}
|
|
6808
|
+
res.writeHead(200, {
|
|
6809
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
6810
|
+
"cache-control": "no-cache",
|
|
6811
|
+
connection: "keep-alive"
|
|
6812
|
+
});
|
|
6813
|
+
const push = async () => {
|
|
6814
|
+
const projection = await host.snapshot(root);
|
|
6815
|
+
const payload = {
|
|
6816
|
+
root,
|
|
6817
|
+
projection,
|
|
6818
|
+
revision: host.revision(root)
|
|
6819
|
+
};
|
|
6820
|
+
res.write("data: " + JSON.stringify(payload) + "\n\n");
|
|
6738
6821
|
};
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6822
|
+
const heartbeat = setInterval(() => {
|
|
6823
|
+
res.write(": ping\n\n");
|
|
6824
|
+
}, HEARTBEAT_MS);
|
|
6825
|
+
const close = () => {
|
|
6826
|
+
clearInterval(heartbeat);
|
|
6827
|
+
};
|
|
6828
|
+
req.once("close", close);
|
|
6829
|
+
res.once("close", close);
|
|
6830
|
+
await push();
|
|
6831
|
+
}
|
|
6832
|
+
},
|
|
6833
|
+
docRoute(host)
|
|
6834
|
+
];
|
|
6752
6835
|
}
|
|
6753
6836
|
/**
|
|
6754
6837
|
* Register the routes at most ONCE per process. WebServer.register throws on a
|
package/lib/live-route.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface RecursiveStatePayload {
|
|
|
40
40
|
revision: number;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Build the
|
|
43
|
+
* Build the read-only routes. Returns [state, events, doc] in registration order.
|
|
44
44
|
* @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
|
|
45
45
|
*/
|
|
46
46
|
export declare function makeRecursiveRoutes(host: RecursiveRouteHost): readonly {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@try-works/dsh-recursive-mode",
|
|
3
3
|
"description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + recursive_status tool",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/src/client/board.tsx
CHANGED
|
@@ -32,10 +32,16 @@ export interface BoardProps {
|
|
|
32
32
|
workspacePath?: string
|
|
33
33
|
onOpenInspector?: (selection: BoardSelection) => void
|
|
34
34
|
onClose?: () => void
|
|
35
|
+
/**
|
|
36
|
+
* 'overlay' (default) renders the board as a full-viewport fixed overlay
|
|
37
|
+
* (shell.overlay / launcher). 'view' renders it inline, filling its parent
|
|
38
|
+
* (a conversation.view tab), with no close button and no fixed positioning.
|
|
39
|
+
*/
|
|
40
|
+
variant?: 'overlay' | 'view'
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
function closeButton(onClose: (() => void) | undefined) {
|
|
38
|
-
if (onClose === undefined) return null
|
|
43
|
+
function closeButton(onClose: (() => void) | undefined, variant: 'overlay' | 'view') {
|
|
44
|
+
if (onClose === undefined || variant === 'view') return null
|
|
39
45
|
return createElement('button', { type: 'button', className: 'rec-close', onClick: onClose, title: 'Close', 'aria-label': 'Close' }, '×')
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -49,7 +55,7 @@ function solidPill(kind: ReturnType<typeof cardPill>) {
|
|
|
49
55
|
return createElement('span', { className: 'rec-pill', 'data-pill': kind }, PILL_LABELS[kind])
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
export function Board({ snapshot, workspacePath, onOpenInspector, onClose }: BoardProps) {
|
|
58
|
+
export function Board({ snapshot, workspacePath, onOpenInspector, onClose, variant = 'overlay' }: BoardProps) {
|
|
53
59
|
const { theme, toggle } = useBoardTheme()
|
|
54
60
|
if (snapshot === null || snapshot.root === null) return null
|
|
55
61
|
// Run 16: when the synchronous workspace path differs from the async snapshot
|
|
@@ -59,25 +65,26 @@ export function Board({ snapshot, workspacePath, onOpenInspector, onClose }: Boa
|
|
|
59
65
|
const runs = stale ? [] : listRuns(snapshot.projection)
|
|
60
66
|
const headerPath = workspacePath ?? snapshot.root
|
|
61
67
|
const open = (run: RecursiveRunCard) => () => onOpenInspector?.({ worktreeRoot: run.worktreeRoot, runId: run.runId })
|
|
68
|
+
const rootCls = variant === 'view' ? 'rec-board rec-board-view' : 'rec-board'
|
|
62
69
|
if (runs.length === 0) {
|
|
63
|
-
return createElement('div', { className:
|
|
70
|
+
return createElement('div', { className: rootCls, 'data-theme': theme, 'data-empty': true },
|
|
64
71
|
createElement('header', { className: 'rec-board-header' },
|
|
65
72
|
createElement('h2', { className: 'rec-board-title' }, 'Recursive runs'),
|
|
66
73
|
createElement('span', { className: 'rec-board-path' }, headerPath),
|
|
67
74
|
createElement('span', { className: 'rec-board-count' }, '0 runs'),
|
|
68
75
|
createElement(ThemeToggle, { theme, toggle }),
|
|
69
|
-
closeButton(onClose),
|
|
76
|
+
closeButton(onClose, variant),
|
|
70
77
|
),
|
|
71
78
|
createElement('p', { className: 'rec-board-empty' }, 'No recursive runs in this workspace yet.'),
|
|
72
79
|
)
|
|
73
80
|
}
|
|
74
|
-
return createElement('div', { className:
|
|
81
|
+
return createElement('div', { className: rootCls, 'data-theme': theme },
|
|
75
82
|
createElement('header', { className: 'rec-board-header' },
|
|
76
83
|
createElement('h2', { className: 'rec-board-title' }, 'Recursive runs'),
|
|
77
84
|
createElement('span', { className: 'rec-board-path' }, headerPath),
|
|
78
85
|
createElement('span', { className: 'rec-board-count' }, runs.length + ' runs'),
|
|
79
86
|
createElement(ThemeToggle, { theme, toggle }),
|
|
80
|
-
closeButton(onClose),
|
|
87
|
+
closeButton(onClose, variant),
|
|
81
88
|
),
|
|
82
89
|
createElement('div', { className: 'rec-columns' },
|
|
83
90
|
KANBAN_LANES.map((lane) => {
|
|
@@ -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,8 @@ 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'
|
|
21
|
+
export { RecursiveView } from './slots.ts'
|
|
20
22
|
export { RecursiveSettings } from './settings.tsx'
|
|
21
23
|
export { useLiveProjection } from './use-live.ts'
|
|
22
24
|
export type { LiveProjectionSnapshot } from './use-live.ts'
|