Files
ThothII/frontend/src/widgets/introFormat.ts
T
marcopan e11ffecfc4 feat(frontend): richer gate bodies + replay-friendly session exit
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.
2026-07-05 18:25:02 +02:00

72 lines
2.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) };
}