@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.
@@ -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 = createElement('section', { className: 'rec-detail-section' },
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'),
@@ -64,11 +64,54 @@ export function RecursiveLauncherGate({ useSessions }: { useSessions: SnapshotSe
64
64
  return createElement('button', { className: 'rec-launcher', title: 'Recursive runs', onClick: () => boardState.openBoard() }, '⧉')
65
65
  }
66
66
 
67
+ /**
68
+ * RecursiveView: the conversation.view tab body. Renders the run board INLINE
69
+ * (fills the view area) keyed on the CURRENT workspace, and swaps to the
70
+ * inspector modal when a run is opened. Read-only (R9): uses the live host
71
+ * route. No preset gate — the tab is discoverable in any session.
72
+ */
73
+ export function RecursiveView({ useSessions, useWorkspaces }: { useSessions: SnapshotSelectorHook<SessionListStateLike>; useWorkspaces: SnapshotSelectorHook<WorkspaceListStateLike> }): ReactNode {
74
+ const board = useBoardState()
75
+ const sessions = useRecursiveSessions(useSessions)
76
+ const workspaces: WorkspaceListStateLike = useWorkspaces((s) => s) ?? { items: [], recentWorkspaceId: undefined }
77
+ const wsPath = currentWorkspacePath(workspaces, sessions)
78
+ const scope = { cwd: wsPath }
79
+ const snapshot = useLiveProjection(scope)
80
+ // Inspector drill-down (modal over the inline board) when a run is selected.
81
+ if (board.selection !== null) {
82
+ return createElement(Inspector, {
83
+ runId: board.selection.runId,
84
+ worktreeRoot: board.selection.worktreeRoot,
85
+ snapshot,
86
+ onBackToToolDetails: () => boardState.backToBoard(),
87
+ onClose: () => boardState.close(),
88
+ })
89
+ }
90
+ return createElement(Board, { snapshot, workspacePath: wsPath, variant: 'view', onOpenInspector: (sel) => boardState.openInspector(sel) })
91
+ }
92
+
67
93
  export function registerSlots(ctx: ClientContext): () => void {
68
94
  const disposers: (() => void)[] = []
69
95
  // Run 15: inject the one-shot theme-token stylesheet once per document (idempotent).
70
96
  disposers.push(injectBoardStyles())
71
97
 
98
+ // Conversation view tab (Chat | Trajectory | Recursive): the recursive run
99
+ // board as a first-class tab in the conversation header, rendered INLINE in
100
+ // the view area (not a fixed overlay). Order 20 places it to the RIGHT of
101
+ // Trajectory (order 10). Always present — no recursive-preset gate, so the
102
+ // entry point is discoverable in any session.
103
+ disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
104
+ name: 'conversation.view',
105
+ id: 'recursive',
106
+ order: 20,
107
+ label: 'Recursive',
108
+ }, (props: RootSlotProps) => {
109
+ const useSessions = props?.useSessions
110
+ const useWorkspaces = props?.useWorkspaces
111
+ if (useSessions === undefined || useWorkspaces === undefined) return null
112
+ return createElement(RecursiveView, { useSessions, useWorkspaces })
113
+ })))
114
+
72
115
  // Board launcher in the sidebar footer action list — OPENS the shared board store (run 08 R1).
73
116
  // Run 11 (UX gate): the seat is root-scoped (visible in every session), but the board it
74
117
  // opens is recursive-preset-gated. In a code/other session the icon was a dead click — a
@@ -136,6 +136,17 @@ const BOARD_CSS = `
136
136
  overflow: hidden;
137
137
  }
138
138
 
139
+ /* Inline board (conversation.view tab): fills its parent view area, not fixed. */
140
+ .rec-board-view {
141
+ position: relative;
142
+ inset: auto;
143
+ z-index: auto;
144
+ width: 100%;
145
+ height: 100%;
146
+ min-height: 0;
147
+ flex: 1;
148
+ }
149
+
139
150
  .rec-board-header {
140
151
  display: flex;
141
152
  align-items: center;
@@ -521,6 +532,316 @@ const BOARD_CSS = `
521
532
  color: var(--board-muted-fg);
522
533
  }
523
534
 
535
+ /* ===== Per-phase doc viewer (0.2.4, inside the inspector detail body) ===== */
536
+
537
+ /* View phase ghost button on each present phase row. */
538
+ .rec-phase-view {
539
+ flex: none;
540
+ padding: 3px 10px;
541
+ font-size: var(--board-text-xs);
542
+ color: var(--board-info);
543
+ background: transparent;
544
+ border: 1px solid var(--board-border);
545
+ border-radius: 999px;
546
+ cursor: pointer;
547
+ white-space: nowrap;
548
+ }
549
+
550
+ .rec-phase-view:hover {
551
+ background: var(--board-accent);
552
+ }
553
+
554
+ /* Viewer shell: fills the inspector detail body (the panel owns data-theme). */
555
+ .rec-doc {
556
+ display: flex;
557
+ flex-direction: column;
558
+ min-height: 0;
559
+ gap: var(--board-space-12);
560
+ color: var(--board-fg);
561
+ font-family: var(--board-font-sans);
562
+ background: var(--board-bg);
563
+ border: 1px solid var(--board-border);
564
+ border-radius: var(--board-radius-xl);
565
+ overflow: hidden;
566
+ }
567
+
568
+ .rec-doc-header {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: var(--board-space-12);
572
+ padding: var(--board-space-12) var(--board-space-16);
573
+ border-bottom: 1px solid var(--board-border);
574
+ flex: none;
575
+ }
576
+
577
+ .rec-doc-heading {
578
+ display: flex;
579
+ align-items: baseline;
580
+ gap: var(--board-space-12);
581
+ flex: 1;
582
+ min-width: 0;
583
+ }
584
+
585
+ .rec-doc-badge {
586
+ flex: none;
587
+ padding: 2px 10px;
588
+ font-size: 11px;
589
+ font-weight: var(--board-fw-semibold);
590
+ letter-spacing: 0.05em;
591
+ text-transform: uppercase;
592
+ border-radius: 999px;
593
+ background: var(--board-accent);
594
+ color: var(--board-muted-fg);
595
+ }
596
+
597
+ .rec-doc-title {
598
+ margin: 0;
599
+ font-size: var(--board-text-sm);
600
+ font-weight: var(--board-fw-semibold);
601
+ letter-spacing: var(--board-tracking-tight);
602
+ overflow: hidden;
603
+ text-overflow: ellipsis;
604
+ white-space: nowrap;
605
+ }
606
+
607
+ .rec-doc-run {
608
+ flex: none;
609
+ font-family: var(--board-font-mono);
610
+ font-size: var(--board-text-xs);
611
+ color: var(--board-muted-fg);
612
+ }
613
+
614
+ .rec-doc-btn {
615
+ flex: none;
616
+ padding: 5px 12px;
617
+ font-size: var(--board-text-xs);
618
+ color: var(--board-fg);
619
+ background: transparent;
620
+ border: 1px solid var(--board-border);
621
+ border-radius: 999px;
622
+ cursor: pointer;
623
+ white-space: nowrap;
624
+ }
625
+
626
+ .rec-doc-btn:hover {
627
+ background: var(--board-accent);
628
+ }
629
+
630
+ /* Search bar (/ to open). */
631
+ .rec-doc-search {
632
+ display: flex;
633
+ align-items: center;
634
+ gap: var(--board-space-12);
635
+ padding: var(--board-space-8) var(--board-space-16);
636
+ border-bottom: 1px solid var(--board-border);
637
+ flex: none;
638
+ }
639
+
640
+ .rec-doc-search-input {
641
+ flex: 1 1 auto;
642
+ background: var(--board-muted);
643
+ color: var(--board-fg);
644
+ border: 1px solid var(--board-border);
645
+ border-radius: var(--board-radius-md);
646
+ padding: 5px 10px;
647
+ font-size: var(--board-text-xs);
648
+ font-family: inherit;
649
+ min-width: 0;
650
+ }
651
+
652
+ .rec-doc-search-input::placeholder {
653
+ color: var(--board-muted-fg);
654
+ opacity: 1;
655
+ }
656
+
657
+ .rec-doc-search-count {
658
+ font-size: var(--board-text-xs);
659
+ color: var(--board-muted-fg);
660
+ white-space: nowrap;
661
+ }
662
+
663
+ /* Body: scrollable line list. */
664
+ .rec-doc-body {
665
+ flex: 1 1 auto;
666
+ min-height: 0;
667
+ overflow-y: auto;
668
+ padding: var(--board-space-12) var(--board-space-16) var(--board-space-16);
669
+ }
670
+
671
+ .rec-doc-line {
672
+ font-size: var(--board-text-sm);
673
+ line-height: 1.7;
674
+ padding: 0 6px;
675
+ border-left: 2px solid transparent;
676
+ }
677
+
678
+ .rec-doc-line-current {
679
+ background: var(--board-muted);
680
+ border-left-color: var(--board-info);
681
+ }
682
+
683
+ .rec-doc-blank {
684
+ height: 10px;
685
+ }
686
+
687
+ .rec-doc-plain {
688
+ white-space: pre-wrap;
689
+ }
690
+
691
+ .rec-doc-h1 {
692
+ font-size: 22px;
693
+ font-weight: 700;
694
+ margin: 8px 0 6px;
695
+ line-height: 1.3;
696
+ }
697
+
698
+ .rec-doc-h2 {
699
+ font-size: 18px;
700
+ font-weight: 600;
701
+ margin: 12px 0 4px;
702
+ line-height: 1.3;
703
+ }
704
+
705
+ .rec-doc-h3 {
706
+ font-size: 15px;
707
+ font-weight: 600;
708
+ margin: 10px 0 4px;
709
+ line-height: 1.3;
710
+ }
711
+
712
+ .rec-doc-h4 {
713
+ font-size: 14px;
714
+ font-weight: 600;
715
+ margin: 8px 0 4px;
716
+ line-height: 1.3;
717
+ }
718
+
719
+ .rec-doc-li {
720
+ display: flex;
721
+ align-items: baseline;
722
+ gap: 8px;
723
+ font-size: var(--board-text-sm);
724
+ line-height: 1.65;
725
+ margin: 0 0 4px;
726
+ }
727
+
728
+ .rec-doc-bullet {
729
+ flex: none;
730
+ color: var(--board-muted-fg);
731
+ width: 14px;
732
+ text-align: center;
733
+ }
734
+
735
+ .rec-doc-li-text {
736
+ flex: 1;
737
+ min-width: 0;
738
+ }
739
+
740
+ .rec-doc-pre {
741
+ margin: 6px 0;
742
+ padding: var(--board-space-12);
743
+ background: var(--board-muted);
744
+ border: 1px solid var(--board-border);
745
+ border-radius: var(--board-radius-md);
746
+ overflow-x: auto;
747
+ }
748
+
749
+ .rec-doc-code,
750
+ .rec-doc-inline-code {
751
+ font-family: var(--board-font-mono);
752
+ font-size: 12.5px;
753
+ letter-spacing: var(--board-tracking-mono);
754
+ }
755
+
756
+ .rec-doc-inline-code {
757
+ background: var(--board-muted);
758
+ border: 1px solid var(--board-border);
759
+ border-radius: 4px;
760
+ padding: 0 4px;
761
+ }
762
+
763
+ .rec-doc-inline-link {
764
+ color: var(--board-info);
765
+ text-decoration: underline;
766
+ cursor: pointer;
767
+ }
768
+
769
+ .rec-doc-table {
770
+ width: 100%;
771
+ border-collapse: collapse;
772
+ margin: 6px 0;
773
+ font-size: var(--board-text-xs);
774
+ }
775
+
776
+ .rec-doc-table th,
777
+ .rec-doc-table td {
778
+ border: 1px solid var(--board-border);
779
+ padding: 4px 8px;
780
+ text-align: left;
781
+ }
782
+
783
+ .rec-doc-table th {
784
+ background: var(--board-muted);
785
+ color: var(--board-muted-fg);
786
+ font-weight: var(--board-fw-semibold);
787
+ }
788
+
789
+ /* Footer: status + key hints. */
790
+ .rec-doc-footer {
791
+ flex: none;
792
+ display: flex;
793
+ align-items: center;
794
+ gap: var(--board-space-16);
795
+ padding: var(--board-space-8) var(--board-space-16);
796
+ border-top: 1px solid var(--board-border);
797
+ flex-wrap: wrap;
798
+ }
799
+
800
+ .rec-doc-status {
801
+ font-size: var(--board-text-xs);
802
+ color: var(--board-muted-fg);
803
+ min-width: 120px;
804
+ }
805
+
806
+ .rec-doc-status-error {
807
+ color: var(--board-error);
808
+ }
809
+
810
+ .rec-doc-status-ok {
811
+ color: var(--board-success);
812
+ }
813
+
814
+ .rec-doc-hints {
815
+ display: flex;
816
+ align-items: center;
817
+ gap: 2px;
818
+ flex-wrap: wrap;
819
+ font-size: var(--board-text-xs);
820
+ color: var(--board-muted-fg);
821
+ }
822
+
823
+ .rec-doc-hints kbd {
824
+ background: var(--board-muted);
825
+ border: 1px solid var(--board-border);
826
+ border-radius: 4px;
827
+ padding: 1px 6px;
828
+ font-family: inherit;
829
+ font-size: 11px;
830
+ font-weight: 600;
831
+ color: var(--board-fg);
832
+ margin: 0 3px 0 8px;
833
+ }
834
+
835
+ .rec-doc-text {
836
+ margin: 0;
837
+ font-size: var(--board-text-sm);
838
+ color: var(--board-muted-fg);
839
+ }
840
+
841
+ .rec-doc-error {
842
+ color: var(--board-error);
843
+ }
844
+
524
845
  /* ===== Strip (session dock) — keeps the shell --dsw-* theme ===== */
525
846
  .rec-badge {
526
847
  flex: none;
package/src/live-route.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * would be both wrong and a crash).
18
18
  */
19
19
  import type { IncomingMessage, ServerResponse } from 'node:http'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { join, resolve, sep } from 'node:path'
20
22
  import type { RecursiveProjection } from './types.ts'
23
+ import { RUN_ARTIFACT_SEQUENCE } from './status.ts'
21
24
 
22
25
  /** API prefix the board/strip fetch. */
23
26
  export const RECURSIVE_API_PREFIX = '/.recursive/api'
@@ -65,8 +68,72 @@ function queryOf(req: IncomingMessage, name: string): string {
65
68
  return new URLSearchParams(url.slice(q + 1)).get(name) ?? ''
66
69
  }
67
70
 
71
+ /** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
72
+ const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE)
73
+
74
+ /** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
75
+ const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/
76
+
77
+ /** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
78
+ function docTargetError(runId: string, file: string): string | null {
79
+ if (!DOC_SAFE_RE.test(runId) || runId.includes('..')) return 'invalid runId'
80
+ if (!DOC_SAFE_RE.test(file) || file.includes('..') || file.includes('/') || file.includes('\\')) return 'invalid file'
81
+ if (!file.endsWith('.md')) return 'invalid file: must be a .md phase doc'
82
+ if (!PHASE_DOC_FILES.has(file)) return 'invalid file: not a phase doc'
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
88
+ * doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
89
+ * projection payloads (they stay fold-only). Same browser-marker tripwire as
90
+ * state/events; the client root is re-validated by the host (never trusted:
91
+ * an unknown root -> 400), then the resolved doc path is containment-checked
92
+ * under join(root, '.recursive', 'run', runId).
93
+ */
94
+ function docRoute(host: RecursiveRouteHost) {
95
+ return {
96
+ kind: 'exact' as const,
97
+ path: RECURSIVE_API_PREFIX + '/doc',
98
+ handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
99
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
100
+ if (!browserMarker(req)) { res.writeHead(403); res.end(); return }
101
+ const root = queryOf(req, 'root')
102
+ const runId = queryOf(req, 'runId')
103
+ const file = queryOf(req, 'file')
104
+ const targetError = docTargetError(runId, file)
105
+ if (root === '' || targetError !== null) {
106
+ json(res, 400, { ok: false, error: targetError ?? 'missing root' })
107
+ return
108
+ }
109
+ // Root validation: the client passes back what the host already resolved.
110
+ // Re-resolve via the host and require a canonical match — never accept an
111
+ // arbitrary path (registry know-it or headless cwd pass-through).
112
+ const resolvedRoot = await host.resolveRoot(undefined, root)
113
+ if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
114
+ json(res, 400, { ok: false, error: 'root is not a known workspace' })
115
+ return
116
+ }
117
+ // Containment: the doc must stay under join(root, .recursive, run, runId).
118
+ const runBase = resolve(root, '.recursive', 'run', runId)
119
+ const docPath = resolve(runBase, file)
120
+ if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
121
+ json(res, 400, { ok: false, error: 'doc path escapes the run dir' })
122
+ return
123
+ }
124
+ if (!existsSync(docPath)) {
125
+ json(res, 404, { ok: false, error: 'phase doc not found' })
126
+ return
127
+ }
128
+ const text = readFileSync(docPath, 'utf8')
129
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' })
130
+ res.end(text)
131
+ },
132
+ }
133
+ }
134
+
68
135
  /**
69
- * Build the two read-only routes. Returns [state, events] in registration order.
136
+ * Build the read-only routes. Returns [state, events, doc] in registration order.
70
137
  * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
71
138
  */
72
139
  export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> }[] {
@@ -109,7 +176,7 @@ export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind:
109
176
  await push()
110
177
  },
111
178
  }
112
- return [state, events]
179
+ return [state, events, docRoute(host)]
113
180
  }
114
181
 
115
182
  /**