@try-works/dsh-recursive-mode 0.4.6 → 0.4.7
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/contract.d.ts +10 -0
- package/lib/client/doc-viewer.d.ts +50 -0
- package/lib/client/spec-sheet-view.d.ts +237 -0
- package/lib/client/spec-sheet.d.ts +103 -0
- package/lib/client/use-live.d.ts +17 -1
- package/lib/client.js +988 -14
- package/lib/errors.d.ts +13 -0
- package/lib/index.js +134 -1
- package/lib/run-spec.d.ts +78 -0
- package/lib/run-start.d.ts +27 -0
- package/package.json +1 -1
- package/src/client/contract.ts +10 -0
- package/src/client/doc-viewer.tsx +131 -12
- package/src/client/slots.ts +12 -0
- package/src/client/spec-sheet-view.ts +325 -0
- package/src/client/spec-sheet.tsx +409 -0
- package/src/client/styles.ts +298 -1
- package/src/client/use-live.ts +23 -2
- package/src/errors.ts +13 -0
- package/src/recursive_ask.tool.ts +28 -1
- package/src/run-spec.ts +148 -0
- package/src/run-start.ts +59 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUN-START SPEC SHEET — the document beside the question it decides.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS ANSWERS. The owner asked for a run spec, the plugin scaffolded one and raised the
|
|
5
|
+
* `run-start` gate — "start this run or hold?" — and NOBODY WAS SHOWN THE DOCUMENT:
|
|
6
|
+
* *"the card ui for accepting the spec appeared, but i was never shown the spec before that so how could i
|
|
7
|
+
* approve if i havent seen it"*. The gate was decidable before it was readable.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ WHERE THIS RENDERS, AND WHY NOT IN `conversation.approval.detail`. That seat was the first candidate —
|
|
10
|
+
* its catalog summary reads "Optional detail for the Tool call correlated with an approval request" — and it
|
|
11
|
+
* is the WRONG one, for a reason that is structural rather than stylistic:
|
|
12
|
+
*
|
|
13
|
+
* 1. `conversation.composer` is a CHAIN slot: its entries' selectors run in order and the FIRST non-null
|
|
14
|
+
* match renders. `ui-approval` claims it with `select: pendingInteraction instanceof PendingApproval`
|
|
15
|
+
* and declares `conversation.approval.detail` as its child; `ui-user-questions` claims it with
|
|
16
|
+
* `select: pendingInteraction instanceof PendingQuestion` and declares
|
|
17
|
+
* `conversation.plan-review.actions` instead. They are two mutually exclusive cells of ONE chain.
|
|
18
|
+
* 2. The run-start gate is asked through the USER-QUESTIONS channel (`askRunStartDirectly` →
|
|
19
|
+
* `channel.ask(...)`, correlated by `wait: { callId: exec.callId }`), so while it is pending the
|
|
20
|
+
* pending interaction IS a `PendingQuestion` — the question composer owns the composer, and
|
|
21
|
+
* `conversation.approval.detail` is not mounted at all. A sheet registered there would render on the
|
|
22
|
+
* one occasion it is not needed and never on the one it is.
|
|
23
|
+
* 3. `tool.call.toolview` keyed by tool name is the seat that exists exactly while THIS tool call is on
|
|
24
|
+
* screen, is unclaimed for `recursive_ask` (the harness's own `ask_user_question` IS claimed, which is
|
|
25
|
+
* the parallel that matters), and hands the view its own `callId`, the session `cwd` and the frozen
|
|
26
|
+
* call block — which is how the `runId` is read out of the call's own arguments.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IT SHOWS — AND THE DEFECT THAT CHANGED IT. This seat first showed the ACTUAL bytes of
|
|
29
|
+
* `.recursive/run/<runId>/00-requirements.md` as RAW TEXT, on the reasoning that verbatim is the honest
|
|
30
|
+
* thing to print. The honest answer to "is that a preview?" was NO, and the owner was being asked to APPROVE
|
|
31
|
+
* what they read: *reading `##` headings and pipe-table syntax is not reviewing a spec*. So the default is now
|
|
32
|
+
* a RENDERED PREVIEW — headings, lists, fenced code and pipe tables drawn as such — and the verbatim text is
|
|
33
|
+
* one press away behind "View source".
|
|
34
|
+
*
|
|
35
|
+
* ⚠ AND THE RAW MODE IS NOT DECORATION. A preview is an INTERPRETATION, and this seat asks a person to
|
|
36
|
+
* approve a document on the strength of it. The raw view exists so that "the preview is not paraphrasing or
|
|
37
|
+
* hiding anything" is a claim the reader can CHECK against the bytes rather than one they must take from the
|
|
38
|
+
* renderer. Its state is carried by `aria-pressed` and repeated in a `role="status"` line that names the mode
|
|
39
|
+
* on screen, so the mode is announced and not merely coloured.
|
|
40
|
+
*
|
|
41
|
+
* ⚠ ONE RENDERER, NOT TWO. The markdown is parsed and drawn by `doc-viewer.tsx` — `parseDoc` and its
|
|
42
|
+
* `PreviewLines` — the same code path the phase-doc viewer uses. A second markdown renderer in one plugin
|
|
43
|
+
* would be a second answer to "what does this document say", which is the defect this plugin exists to
|
|
44
|
+
* refuse. What `parseDoc` could not carry for a real `00-requirements.md` (the task boxes and the gate
|
|
45
|
+
* readings the template ships) was added THERE, for both readers, rather than forked here.
|
|
46
|
+
*
|
|
47
|
+
* WHAT IT STILL REFUSES TO DO. It is READ-ONLY (R9): the sheet has no approve/hold control, because a second
|
|
48
|
+
* control that looks like the question card's would be a second way to answer one question — the sheet may
|
|
49
|
+
* change HOW the document is shown, never WHETHER it is approved. And the honesty rule of
|
|
50
|
+
* `settings-view.ts` is unchanged: when the artifact is still the unfilled template the rendered view says so
|
|
51
|
+
* loudly and the unfilled marks stay visible AS unfilled (`☐ …` items and `Coverage: FAIL` gates are drawn as
|
|
52
|
+
* exactly that), so a pretty preview of an empty form can never read as an approvable spec.
|
|
53
|
+
*
|
|
54
|
+
* ACCESSIBILITY. The outer element is a `region` named by its own heading (`aria-labelledby`); the document
|
|
55
|
+
* body is a focusable (`tabIndex=0`) scrolling box with its own `aria-label` and `aria-readonly`, so the
|
|
56
|
+
* keyboard reaches the text and can scroll it without the decision controls leaving the viewport (the cap is
|
|
57
|
+
* CSS `max-height` with `overflow-y: auto`). The box is the SAME element in both modes, so toggling never
|
|
58
|
+
* drops focus. Escape is swallowed deliberately, because on a dialog-ish surface Escape is the key that
|
|
59
|
+
* dismisses and dismissing this sheet must never be read as a decision; and the one transition it has is none
|
|
60
|
+
* under `prefers-reduced-motion` (see the `.rec-spec` rules in `styles.ts`). No modal is used: nothing here
|
|
61
|
+
* needs a focus trap, and a trap would take the keyboard away from the decision controls.
|
|
62
|
+
*/
|
|
63
|
+
import { createElement, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
64
|
+
import type { SessionListStateLike, SnapshotSelectorHook, WorkspaceListStateLike } from './contract.ts'
|
|
65
|
+
import { currentSessionCwd, currentWorkspacePath } from './contract.ts'
|
|
66
|
+
import { parseDoc, PreviewLines } from './doc-viewer.tsx'
|
|
67
|
+
import { fetchPhaseDoc } from './host-api.ts'
|
|
68
|
+
import { useLiveProjection } from './use-live.ts'
|
|
69
|
+
import { unfilledEvidence } from '../run-spec.ts'
|
|
70
|
+
import {
|
|
71
|
+
decisionLine,
|
|
72
|
+
isSpecSheetCall,
|
|
73
|
+
otherMode,
|
|
74
|
+
PREPARING_NOTICE,
|
|
75
|
+
readCall,
|
|
76
|
+
RUN_START_OPTION_MEANING,
|
|
77
|
+
RUN_START_QUESTION,
|
|
78
|
+
RUN_START_SPEC_FILE,
|
|
79
|
+
SPEC_DEFAULT_MODE,
|
|
80
|
+
specBodyContent,
|
|
81
|
+
specPath,
|
|
82
|
+
specSheetModel,
|
|
83
|
+
specViewToggle,
|
|
84
|
+
TRUNCATED_NOTICE,
|
|
85
|
+
type SpecFetch,
|
|
86
|
+
type SpecSheetBlock,
|
|
87
|
+
type SpecSheetModel,
|
|
88
|
+
type SpecViewMode,
|
|
89
|
+
} from './spec-sheet-view.ts'
|
|
90
|
+
|
|
91
|
+
export {
|
|
92
|
+
RUN_START_TOOL_NAME,
|
|
93
|
+
RUN_START_SPEC_FILE,
|
|
94
|
+
parseRecursiveAskArgs,
|
|
95
|
+
isRunStartCall,
|
|
96
|
+
specPath,
|
|
97
|
+
specSheetModel,
|
|
98
|
+
decisionLine,
|
|
99
|
+
UNFILLED_NOTICE,
|
|
100
|
+
UNFILLED_SHORT,
|
|
101
|
+
NO_CALL_NOTICE,
|
|
102
|
+
PREPARING_NOTICE,
|
|
103
|
+
TRUNCATED_NOTICE,
|
|
104
|
+
SPEC_DEFAULT_MODE,
|
|
105
|
+
SPEC_MODE_LABEL,
|
|
106
|
+
SPEC_MODE_NOTE,
|
|
107
|
+
SPEC_TOGGLE_LABEL,
|
|
108
|
+
otherMode,
|
|
109
|
+
specBodyContent,
|
|
110
|
+
specViewToggle,
|
|
111
|
+
} from './spec-sheet-view.ts'
|
|
112
|
+
export type { SpecSheetModel, SpecSheetBlock, SpecFetchState, RecursiveAskArgs, SpecViewMode, SpecViewToggle } from './spec-sheet-view.ts'
|
|
113
|
+
|
|
114
|
+
/** The session-standard hooks the seat is given, all optional so the spec can render the sheet alone. */
|
|
115
|
+
export interface SpecSheetProps {
|
|
116
|
+
/** The owner's call block; `null` when the owner supplied none (a window-truncated caller). */
|
|
117
|
+
block?: SpecSheetBlock
|
|
118
|
+
/** The session workspace root the owner supplied (`ToolCallCommonProps.cwd`). */
|
|
119
|
+
cwd?: string
|
|
120
|
+
useSessions?: SnapshotSelectorHook<SessionListStateLike>
|
|
121
|
+
useWorkspaces?: SnapshotSelectorHook<WorkspaceListStateLike>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const EMPTY_WORKSPACES: WorkspaceListStateLike = { items: [], recentWorkspaceId: undefined }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A scope that resolves nothing.
|
|
128
|
+
*
|
|
129
|
+
* The hook stays UNCONDITIONAL (the run 13 lesson: a conditional hook changed the hook count across renders
|
|
130
|
+
* and threw "Rendered more hooks than during the previous render"), so a row that is not a run-start call
|
|
131
|
+
* passes this and the route is never asked for it.
|
|
132
|
+
*/
|
|
133
|
+
const NO_SCOPE = { sessionId: undefined as string | undefined, cwd: undefined as string | undefined }
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the workspace root the `/doc` route will accept.
|
|
137
|
+
*
|
|
138
|
+
* ⚠ THE ROOT CANNOT BE THE SESSION CWD BY GUESSWORK. The route re-validates whatever root it is handed
|
|
139
|
+
* against the host's own workspace registry (`live-route.ts`: `resolveRoot(undefined, root)` must return the
|
|
140
|
+
* same canonical path), so the root used here is the one the live route ITSELF answered with; the workspace
|
|
141
|
+
* path is the hydration hint every other seat passes, and the owner-supplied `cwd` is the last resort.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveSpecRoot(
|
|
144
|
+
snapshotRoot: string | null | undefined,
|
|
145
|
+
workspacePath: string,
|
|
146
|
+
cwd: string,
|
|
147
|
+
): string | null {
|
|
148
|
+
if (typeof snapshotRoot === 'string' && snapshotRoot.trim() !== '') return snapshotRoot
|
|
149
|
+
if (workspacePath.trim() !== '') return workspacePath
|
|
150
|
+
return cwd.trim() === '' ? null : cwd
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The route states the /doc fetch can produce, and what each one MEANS. */
|
|
154
|
+
function readDoc(
|
|
155
|
+
runId: string | null,
|
|
156
|
+
root: string | null,
|
|
157
|
+
file: string,
|
|
158
|
+
): SpecFetch {
|
|
159
|
+
const [fetchState, setFetchState] = useState<SpecFetch>({ state: 'loading' })
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (runId === null || root === null) {
|
|
162
|
+
// Nothing to read yet: the route has not resolved a root (or the call names no run). This is the
|
|
163
|
+
// LOADING state, not an error — the subscription is what resolves the root.
|
|
164
|
+
setFetchState({ state: 'loading' })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
let disposed = false
|
|
168
|
+
setFetchState({ state: 'loading' })
|
|
169
|
+
fetchPhaseDoc({ root, runId, file })
|
|
170
|
+
.then((text) => { if (!disposed) setFetchState({ state: 'loaded', text }) })
|
|
171
|
+
.catch((err: unknown) => {
|
|
172
|
+
if (disposed) return
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
174
|
+
// ⚠ 404 IS AN ANSWER, NOT A FAILURE. The route distinguishes "this document is not there" from every
|
|
175
|
+
// other failure, and a client that folded them together would report a broken route when the truth
|
|
176
|
+
// is that the spec has not been written — the difference between "fix the client" and "write it".
|
|
177
|
+
const missing = /HTTP 404\b/.test(message)
|
|
178
|
+
setFetchState({ state: missing ? 'absent' : 'error', error: message })
|
|
179
|
+
})
|
|
180
|
+
return () => { disposed = true }
|
|
181
|
+
}, [runId, root, file])
|
|
182
|
+
return fetchState
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The seat component: renders the spec sheet for ONE `recursive_ask` tool call, and NOTHING for any other.
|
|
187
|
+
*
|
|
188
|
+
* Returning `null` is how an unclaimed key behaves, so a `tdd-mode` / `qa-signoff` / `gate-block` ask, or a
|
|
189
|
+
* preparing call whose arguments have not been dispatched yet, keeps the generic tool row it has today.
|
|
190
|
+
*/
|
|
191
|
+
export function RunStartSpecSheet({ block, cwd, useSessions, useWorkspaces }: SpecSheetProps): ReactNode {
|
|
192
|
+
const sessions = useSessions === undefined ? null : useSessions((s) => s)
|
|
193
|
+
const workspaces = useWorkspaces === undefined ? EMPTY_WORKSPACES : useWorkspaces((s) => s) ?? EMPTY_WORKSPACES
|
|
194
|
+
const workspacePath = sessions === null ? '' : currentWorkspacePath(workspaces, sessions)
|
|
195
|
+
const sessionCwd = sessions === null ? '' : currentSessionCwd(sessions)
|
|
196
|
+
|
|
197
|
+
const read = readCall(block)
|
|
198
|
+
const claims = isSpecSheetCall(read)
|
|
199
|
+
const runId = read.ok ? read.args.runId : null
|
|
200
|
+
const scope = sessions === null
|
|
201
|
+
? NO_SCOPE
|
|
202
|
+
: { sessionId: sessions.current, cwd: workspacePath !== '' ? workspacePath : sessionCwd }
|
|
203
|
+
const snapshot = useLiveProjection(scope, { enabled: claims })
|
|
204
|
+
const root = resolveSpecRoot(snapshot?.root ?? null, workspacePath, cwd ?? '')
|
|
205
|
+
const fetchState = readDoc(claims ? runId : null, root, RUN_START_SPEC_FILE)
|
|
206
|
+
|
|
207
|
+
if (!read.ok) {
|
|
208
|
+
// Each failure says WHICH failure it is: an empty frame would read as "the document is empty", which is
|
|
209
|
+
// a different and much worse claim than "this panel could not resolve the call".
|
|
210
|
+
if (read.reason === 'missing') return null
|
|
211
|
+
const text = read.reason === 'preparing' ? PREPARING_NOTICE : TRUNCATED_NOTICE
|
|
212
|
+
return specFrame(createElement('p', { className: 'rec-spec-notice', role: 'status' }, text))
|
|
213
|
+
}
|
|
214
|
+
if (!claims) return null
|
|
215
|
+
if (read.args.runId === null) {
|
|
216
|
+
// Unreachable through `claims` (which requires a runId), kept as the explicit statement of the rule.
|
|
217
|
+
return specFrame(createElement('p', { className: 'rec-spec-notice', role: 'status' },
|
|
218
|
+
'This `recursive_ask` call names no runId, so no run spec can be shown for it.'))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return createElement(RunStartSpecSheetBody, {
|
|
222
|
+
model: specSheetModel({ args: read.args, root, fetch: fetchState }),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The body, from a MODEL — split out so the spec can drive every state (stub, filled, absent, error, a
|
|
228
|
+
* recorded Hold, a recorded approval) without a route, a session, or a fetch.
|
|
229
|
+
*
|
|
230
|
+
* The MODE is local state and starts at the rendered preview (`SPEC_DEFAULT_MODE`); everything that decides
|
|
231
|
+
* what the sheet SAYS still comes from the pure model, so a spec can assert the words and the marks without
|
|
232
|
+
* mounting a component.
|
|
233
|
+
*/
|
|
234
|
+
export function RunStartSpecSheetBody({ model }: { model: SpecSheetModel }): ReactNode {
|
|
235
|
+
const headingId = 'rec-spec-title-' + (model.runId ?? 'unresolved')
|
|
236
|
+
const [mode, setMode] = useState<SpecViewMode>(SPEC_DEFAULT_MODE)
|
|
237
|
+
const toggle = specViewToggle(mode)
|
|
238
|
+
return createElement('section', {
|
|
239
|
+
className: 'rec-spec',
|
|
240
|
+
role: 'region',
|
|
241
|
+
'aria-labelledby': headingId,
|
|
242
|
+
'aria-readonly': 'true',
|
|
243
|
+
'data-verdict': model.verdict ?? model.state,
|
|
244
|
+
'data-mode': mode,
|
|
245
|
+
// ⚠ ESCAPE MUST NOT SILENTLY APPROVE. There is no control here to dismiss and no decision to cast, so the
|
|
246
|
+
// key is consumed and dropped — written out rather than left to whatever a surrounding dialog might do
|
|
247
|
+
// with it. (The decision itself belongs to the question card, which owns its own keyboard handling. The
|
|
248
|
+
// preview/source toggle is a mode, not a dismissal, so it is a button and Escape deliberately ignores it.)
|
|
249
|
+
onKeyDown: (event: { key?: string; preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
250
|
+
if (event.key !== 'Escape') return
|
|
251
|
+
event.preventDefault?.()
|
|
252
|
+
event.stopPropagation?.()
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
createElement('header', { className: 'rec-spec-header' },
|
|
256
|
+
createElement('h3', { className: 'rec-spec-title', id: headingId },
|
|
257
|
+
'Run spec — ' + (model.runId ?? 'runId not carried') + ' / ' + model.file),
|
|
258
|
+
createElement('span', { className: 'rec-spec-tag' }, 'read-only'),
|
|
259
|
+
),
|
|
260
|
+
createElement('p', { className: 'rec-spec-path' }, specPath(model.runId, model.root, model.file)),
|
|
261
|
+
notice(model),
|
|
262
|
+
questionBlock(model),
|
|
263
|
+
viewControls(toggle, model, () => { setMode(otherMode(toggle.mode)) }),
|
|
264
|
+
documentBody(model, mode),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The mode control: one button, and the line that announces the state it just put the sheet in.
|
|
270
|
+
*
|
|
271
|
+
* ⚠ KEYBOARD-REACHABLE BY BEING A BUTTON. It is a real `<button type="button">`, so Tab reaches it and
|
|
272
|
+
* Enter/Space press it with no key handler of our own to get wrong. `aria-pressed` carries the state to a
|
|
273
|
+
* screen reader on the control, and the `role="status"` paragraph repeats it in words, naming the mode ON
|
|
274
|
+
* SCREEN ("Showing: raw source") rather than the action — so neither a pointer user nor a reader has to infer
|
|
275
|
+
* the current mode from the button's face.
|
|
276
|
+
*/
|
|
277
|
+
function viewControls(toggle: ReturnType<typeof specViewToggle>, model: SpecSheetModel, onToggle: () => void): ReactNode {
|
|
278
|
+
const shown = model.text !== null
|
|
279
|
+
return createElement('div', { className: 'rec-spec-view' },
|
|
280
|
+
createElement('button', {
|
|
281
|
+
type: 'button',
|
|
282
|
+
className: 'rec-spec-view-toggle',
|
|
283
|
+
// `aria-pressed` is the STATE; the label is the ACTION. A toggle whose label says "raw" while the
|
|
284
|
+
// preview is on screen is the classic way a control lies about which of two things is displayed.
|
|
285
|
+
'aria-pressed': toggle.pressed,
|
|
286
|
+
title: toggle.announce,
|
|
287
|
+
// ⚠ `aria-disabled`, NOT `disabled`. A real `disabled` attribute does not stop the adjacent
|
|
288
|
+
// `role="status"` line from announcing a mode, so with no document loaded the control would be inert
|
|
289
|
+
// while the line beside it claimed a mode was on screen. `aria-disabled` keeps the control reachable and
|
|
290
|
+
// its state true, and the click is refused below instead of by the browser.
|
|
291
|
+
'aria-disabled': !shown,
|
|
292
|
+
onClick: () => { if (shown) onToggle() },
|
|
293
|
+
}, toggle.action),
|
|
294
|
+
createElement('span', { className: 'rec-spec-view-state', role: 'status' }, toggle.announce),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The document body: the rendered preview, or the raw bytes — and only ever the document's OWN text.
|
|
300
|
+
*
|
|
301
|
+
* Both modes are built from the same string, so neither is a second reading of the file: `raw` prints the
|
|
302
|
+
* characters, `preview` hands those same characters to the one parser this plugin has.
|
|
303
|
+
*/
|
|
304
|
+
function documentBody(model: SpecSheetModel, mode: SpecViewMode): ReactNode {
|
|
305
|
+
const content = specBodyContent(model.text, mode)
|
|
306
|
+
// The box is rendered in EVERY state, including the empty one: it is the element the keyboard focuses and
|
|
307
|
+
// the label names, and a box that appears and disappears would move that focus target around the page.
|
|
308
|
+
const box = {
|
|
309
|
+
className: 'rec-spec-body-scroll' + (content !== null && content.mode === 'raw' ? ' rec-spec-body-raw' : ''),
|
|
310
|
+
// The text IS the point of this seat: focusable so the keyboard can reach and scroll it, and named
|
|
311
|
+
// separately from the region so the two are not announced as one thing. The same element serves both
|
|
312
|
+
// modes, so pressing the toggle changes its children and never its identity — focus survives the swap.
|
|
313
|
+
tabIndex: 0,
|
|
314
|
+
role: 'group' as const,
|
|
315
|
+
'aria-label': 'Document text of ' + (model.runId ?? 'the run') + ' / ' + model.file + ', read-only, '
|
|
316
|
+
+ (mode === 'raw' ? 'raw source' : 'rendered preview'),
|
|
317
|
+
}
|
|
318
|
+
if (content === null) {
|
|
319
|
+
return createElement('div', box,
|
|
320
|
+
createElement('p', { className: 'rec-spec-empty' }, 'No document text to show.'),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
if (content.mode === 'raw') {
|
|
324
|
+
return createElement('div', box, createElement('pre', { className: 'rec-spec-text' }, content.text))
|
|
325
|
+
}
|
|
326
|
+
return createElement('div', box, createElement(SpecPreview, { text: content.text, runId: model.runId, file: model.file }))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The rendered document.
|
|
331
|
+
*
|
|
332
|
+
* The parsing is memoised on the TEXT, so a toggle back and forth does not re-parse the document, and the
|
|
333
|
+
* elements come from `doc-viewer.tsx`'s `PreviewLines` — the one place in this plugin where a `DocLine`
|
|
334
|
+
* becomes markup.
|
|
335
|
+
*/
|
|
336
|
+
function SpecPreview({ text, runId, file }: { text: string; runId: string | null; file: string }): ReactNode {
|
|
337
|
+
const lines = useMemo(() => parseDoc(text), [text])
|
|
338
|
+
return createElement(PreviewLines, { lines, keyBase: 'rec-spec-' + (runId ?? 'run') + '-' + file })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
/** The verdict notice: what the document IS, decided from its own text. */
|
|
343
|
+
function notice(model: SpecSheetModel): ReactNode {
|
|
344
|
+
if (model.state === 'loading') {
|
|
345
|
+
return createElement('p', { className: 'rec-spec-notice', role: 'status' }, 'Reading the document…')
|
|
346
|
+
}
|
|
347
|
+
if (model.state === 'absent') {
|
|
348
|
+
return createElement('p', { className: 'rec-spec-notice rec-spec-notice-error', role: 'status' },
|
|
349
|
+
'That document does not exist yet, so there is nothing here to read and nothing to approve.')
|
|
350
|
+
}
|
|
351
|
+
if (model.state === 'error') {
|
|
352
|
+
return createElement('p', { className: 'rec-spec-notice rec-spec-notice-error', role: 'status' },
|
|
353
|
+
'The document could not be read: ' + (model.error ?? 'the route gave no reason'))
|
|
354
|
+
}
|
|
355
|
+
if (model.verdict === 'unfilled') {
|
|
356
|
+
const strong = unfilledEvidence({ verdict: 'unfilled', hits: model.evidence })
|
|
357
|
+
const context = model.evidence.filter((hit) => hit.id !== 'placeholder')
|
|
358
|
+
return createElement('div', { className: 'rec-spec-notice rec-spec-notice-unfilled', role: 'status' },
|
|
359
|
+
createElement('p', { className: 'rec-spec-unfilled-lead' },
|
|
360
|
+
'This document is still the UNFILLED TEMPLATE. There is no spec to approve yet.'),
|
|
361
|
+
createElement('p', { className: 'rec-spec-unfilled-why' },
|
|
362
|
+
'The template\'s own placeholder text is still in it:'),
|
|
363
|
+
createElement('ul', { className: 'rec-spec-evidence' },
|
|
364
|
+
strong.slice(0, 8).map((hit, n) => createElement('li', {
|
|
365
|
+
key: 'placeholder-' + String(n),
|
|
366
|
+
className: 'rec-spec-evidence-item',
|
|
367
|
+
}, 'line ' + String(hit.line) + ': ' + hit.text)),
|
|
368
|
+
),
|
|
369
|
+
strong.length > 8
|
|
370
|
+
? createElement('p', { className: 'rec-spec-unfilled-why' },
|
|
371
|
+
'…and ' + String(strong.length - 8) + ' more placeholder lines.')
|
|
372
|
+
: null,
|
|
373
|
+
context.length > 0
|
|
374
|
+
? createElement('p', { className: 'rec-spec-unfilled-why' },
|
|
375
|
+
'It also carries ' + String(context.length) + ' unfinished marker(s) of its own: '
|
|
376
|
+
+ context.slice(0, 3).map((hit) => 'line ' + String(hit.line) + ': ' + hit.text).join(' | '))
|
|
377
|
+
: null,
|
|
378
|
+
createElement('p', { className: 'rec-spec-unfilled-next' },
|
|
379
|
+
'Fill the document in first. Approving here would record a decision about a spec that does not exist yet.'),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
if (model.verdict === 'filled') {
|
|
383
|
+
return createElement('p', { className: 'rec-spec-notice rec-spec-notice-filled', role: 'status' },
|
|
384
|
+
'The document carries real content: no template placeholder remains in it.')
|
|
385
|
+
}
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The decision under way, in the gate's own words, plus the fact that the controls live elsewhere. */
|
|
390
|
+
function questionBlock(model: SpecSheetModel): ReactNode {
|
|
391
|
+
const recorded = decisionLine(model.args.answer)
|
|
392
|
+
return createElement('div', { className: 'rec-spec-question' },
|
|
393
|
+
createElement('p', { className: 'rec-spec-question-lead' }, 'The question about this document is: “' + RUN_START_QUESTION + '”'),
|
|
394
|
+
createElement('p', { className: 'rec-spec-question-options' }, RUN_START_OPTION_MEANING),
|
|
395
|
+
createElement('p', { className: 'rec-spec-question-where' },
|
|
396
|
+
'The decision controls are the question card\'s. This panel is read-only: it casts no vote and writes nothing.'),
|
|
397
|
+
recorded === null ? null : createElement('p', { className: 'rec-spec-decision' }, recorded),
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A minimal named frame for the states that precede a document (no call, no run id). */
|
|
402
|
+
function specFrame(...children: ReactNode[]): ReactNode {
|
|
403
|
+
return createElement('section', {
|
|
404
|
+
className: 'rec-spec',
|
|
405
|
+
role: 'region',
|
|
406
|
+
'aria-label': 'Run spec',
|
|
407
|
+
'aria-readonly': 'true',
|
|
408
|
+
}, ...children)
|
|
409
|
+
}
|