Improves how reviewer gates render their content, and adds two small
hooks that make the frontend cooperate with the standalone replay server
(also useful as generic primitives).
Gate body rendering:
- IntroBody: gate intros now render paragraphs plus real bullet lists
with a hanging indent and emphasised bullet lead term, filling the
card width (no narrow measure cap). Backed by introFormat (parser) +
IntroBody (renderer), with tests.
- GateArtifactBody: artifact-review gates whose artifact is a bare file
reference (e.g. {kind:"phase", data:{file:"question.md"}}) now fetch
the referenced session document and render its full content, instead
of showing the file reference as text. Falls back to ArtifactView for
inline-content artifacts and unmapped files. Backed by gateArtifact
(resolver) with tests. ArtifactGateWidget passes sessionId through.
- SelectWidget renders intros via IntroBody.
Replay-friendly session exit:
- AppShell: a system_event {event:"session_exit"} now triggers
stopSession(), returning to the landing view. Generic primitive (the
real backend can emit it too); used by the replay server's "Esci".
- SessionMenu: the ⋮ trigger is now always visible (was opacity-0 /
group-hover only), so Resume is reachable without hovering.
Verified offline against the replay server (no VPN needed); tsc clean.
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
// Pure, LLM-free formatting for the intro/body of a gate widget.
|
||
// The body arrives as a plain string with blank-line-separated paragraphs and
|
||
// bullet lines prefixed by "•", "-" or "*". We fold it into structured blocks
|
||
// so the renderer can draw a real list (hanging indent) and emphasise the
|
||
// leading term of each bullet.
|
||
|
||
export type IntroBlock =
|
||
| { kind: "para"; text: string }
|
||
| { kind: "list"; items: string[] };
|
||
|
||
const BULLET_RE = /^\s*[•\-*]\s+/;
|
||
|
||
/** Fold raw intro text into paragraph and list blocks. */
|
||
export function parseIntroBlocks(intro: string): IntroBlock[] {
|
||
const lines = intro.replace(/\r\n/g, "\n").split("\n");
|
||
const blocks: IntroBlock[] = [];
|
||
let para: string[] = [];
|
||
let list: string[] = [];
|
||
|
||
const flushPara = () => {
|
||
if (para.length) {
|
||
blocks.push({ kind: "para", text: para.join(" ").trim() });
|
||
para = [];
|
||
}
|
||
};
|
||
const flushList = () => {
|
||
if (list.length) {
|
||
blocks.push({ kind: "list", items: list });
|
||
list = [];
|
||
}
|
||
};
|
||
|
||
for (const raw of lines) {
|
||
const line = raw.trimEnd();
|
||
if (BULLET_RE.test(line)) {
|
||
flushPara();
|
||
list.push(line.replace(BULLET_RE, "").trim());
|
||
} else if (line.trim() === "") {
|
||
flushPara();
|
||
flushList();
|
||
} else {
|
||
flushList();
|
||
para.push(line.trim());
|
||
}
|
||
}
|
||
flushPara();
|
||
flushList();
|
||
return blocks;
|
||
}
|
||
|
||
// Separators that mark the end of a leading term ("term → definition",
|
||
// "term: definition"). Ordered longest-first is not needed since indexOf picks
|
||
// the earliest match across all of them.
|
||
const LEAD_SEPARATORS = [" → ", " -> ", " — ", " – ", ": "];
|
||
|
||
/**
|
||
* Split a bullet into its leading term (to emphasise) and the remainder,
|
||
* or return null when there is no clear short lead worth bolding.
|
||
*/
|
||
export function splitLead(item: string): { lead: string; rest: string } | null {
|
||
let at = -1;
|
||
for (const sep of LEAD_SEPARATORS) {
|
||
const idx = item.indexOf(sep);
|
||
if (idx > 0 && (at === -1 || idx < at)) at = idx;
|
||
}
|
||
if (at === -1) return null;
|
||
const lead = item.slice(0, at);
|
||
// Only emphasise a genuinely short lead: a label, not half a sentence.
|
||
if (lead.length > 60 || lead.trim().split(/\s+/).length > 6) return null;
|
||
return { lead, rest: item.slice(at) };
|
||
}
|