diff --git a/.claude/skills/changelog/watermark b/.claude/skills/changelog/watermark index 314f69ee9..09876abd5 100644 --- a/.claude/skills/changelog/watermark +++ b/.claude/skills/changelog/watermark @@ -1 +1 @@ -5a9f568e4630751d4ebfa06595bced73f07eb24e +9a3ab8bbba2e71d72ff7703a68e9892e8ce94d8d diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark index 6a5b8ed11..578b5ffc5 100644 --- a/.claude/skills/docs/watermark +++ b/.claude/skills/docs/watermark @@ -1 +1 @@ -588f546b6c54006bc1e634f0510d32f6d89cf7c3 +2f10ee39afe6bd9a1c1df987da52fb82c91b422e diff --git a/.fabro/workflows/interview/workflow.fabro b/.fabro/workflows/interview/workflow.fabro new file mode 100644 index 000000000..4fa8ce18f --- /dev/null +++ b/.fabro/workflows/interview/workflow.fabro @@ -0,0 +1,60 @@ +digraph Interview { + graph [goal="Run a progressive human interview and summarize the answers"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + yes_no [ + shape=hexagon, + label="Is this interview workflow easy to follow so far?", + question_type="yes_no" + ] + confirmation [ + shape=hexagon, + label="Confirm that you want to continue into more structured questions.", + question_type="confirmation" + ] + multiple_choice [ + shape=hexagon, + label="Which theme should be the center of the final summary?", + question_type="multiple_choice" + ] + multi_select [ + shape=hexagon, + label="Which supporting areas should the final summary emphasize?", + question_type="multi_select" + ] + freeform [ + shape=hexagon, + label="Add any final context, constraints, or nuance for the summary.", + question_type="freeform" + ] + summarize [ + shape=tab, + label="Summarize Interview", + fidelity="summary:high", + prompt="Summarize the full human interview. Include each question and answer in order, then synthesize the user's priorities, constraints, and open questions. Use the human.gate..question and human.gate..answer context keys when present. Do not invent missing answers." + ] + + start -> yes_no + + yes_no -> confirmation [label="[Y] Yes"] + yes_no -> confirmation [label="[N] No"] + + confirmation -> multiple_choice [label="[Y] Continue"] + confirmation -> multiple_choice [label="[N] Stop after this, but still summarize"] + + multiple_choice -> multi_select [label="[G] Goals"] + multiple_choice -> multi_select [label="[R] Risks"] + multiple_choice -> multi_select [label="[T] Tradeoffs"] + multiple_choice -> multi_select [label="[Q] Open questions"] + + multi_select -> freeform [label="[S] Success criteria"] + multi_select -> freeform [label="[B] Blockers"] + multi_select -> freeform [label="[D] Decisions needed"] + multi_select -> freeform [label="[N] Next steps"] + + freeform -> summarize [freeform=true] + summarize -> exit +} diff --git a/.fabro/workflows/interview/workflow.toml b/.fabro/workflows/interview/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/interview/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/.fabro/workflows/sleeper/workflow.fabro b/.fabro/workflows/sleeper/workflow.fabro new file mode 100644 index 000000000..3f50c3000 --- /dev/null +++ b/.fabro/workflows/sleeper/workflow.fabro @@ -0,0 +1,11 @@ +digraph Sleeper { + graph [goal="Sleep long enough for a human to practice interrupts and steering"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + nap [label="Nap", timeout="360s", prompt="Run exactly one shell command using the Bash tool: `sleep 300`, with the tool `timeout_ms` parameter set to 310000 or higher. Wait for it to return. Do not run any other commands, do not read or edit any files, do not check the environment first. After `sleep 300` returns, reply with the single word 'done' and stop."] + + start -> nap -> exit +} diff --git a/.fabro/workflows/sleeper/workflow.toml b/.fabro/workflows/sleeper/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/sleeper/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/Cargo.lock b/Cargo.lock index e9e0bd94b..78eea0eb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1575,7 +1575,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1620,7 +1620,7 @@ dependencies = [ [[package]] name = "fabro-checkpoint" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1636,7 +1636,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1697,6 +1697,7 @@ dependencies = [ "jsonwebtoken", "libc", "miette", + "nix 0.30.1", "object_store", "openssl", "paste", @@ -1731,7 +1732,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1760,7 +1761,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1787,7 +1788,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1802,7 +1803,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1821,7 +1822,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "fabro-http", "fabro-static", @@ -1838,7 +1839,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1852,7 +1853,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1874,7 +1875,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1888,7 +1889,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1912,7 +1913,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "fabro-static", "http", @@ -1922,7 +1923,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1937,7 +1938,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -1952,7 +1953,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1984,7 +1985,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -1995,7 +1996,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -2011,7 +2012,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "fabro-static", "insta", @@ -2022,7 +2023,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2044,7 +2045,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2052,7 +2053,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "cc", "libc", @@ -2061,7 +2062,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2077,7 +2078,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2117,7 +2118,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2197,7 +2198,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2218,18 +2219,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" [[package]] name = "fabro-store" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2256,7 +2257,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2282,7 +2283,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2294,7 +2295,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2317,7 +2318,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2331,7 +2332,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2352,7 +2353,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2372,7 +2373,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2382,7 +2383,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "chrono", "fabro-types", @@ -2394,7 +2395,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -7097,7 +7098,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "axum", "base64", @@ -7116,7 +7117,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 726d8ad42..c3988367d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.225.0-nightly.0" +version = "0.228.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx new file mode 100644 index 000000000..1b0bd94a5 --- /dev/null +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo } from "react"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + CheckIcon, + ChevronUpDownIcon, + FunnelIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/16/solid"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +import { Tooltip } from "./ui"; +import { formatAbsoluteTs } from "../lib/format"; + +const DEBUG_CATEGORY_TONE: Record = { + agent: "bg-teal-500/15 text-teal-500", + command: "bg-mint/15 text-mint", + interview: "bg-coral/15 text-coral", + run: "bg-overlay-strong text-fg-2", + stage: "bg-amber/15 text-amber", + tool: "bg-mint/15 text-mint", +}; + +export function debugCategory(eventName: string): string { + const dot = eventName.indexOf("."); + return dot < 0 ? eventName : eventName.slice(0, dot); +} + +export function debugCategoryLabel(category: string): string { + if (!category) return "Other"; + return category.charAt(0).toUpperCase() + category.slice(1); +} + +export function debugCategoryTone(category: string): string { + return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted"; +} + +export function formatElapsed(eventTs: string, runStart: string | undefined): string { + if (!runStart) return ""; + const startMs = Date.parse(runStart); + const eventMs = Date.parse(eventTs); + if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return ""; + const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000)); + const hours = Math.floor(delta / 3600); + const minutes = Math.floor((delta % 3600) / 60); + const seconds = delta % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +const JSON_TOKEN_RE = + /"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g; + +export function highlightJson(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + JSON_TOKEN_RE.lastIndex = 0; + while ((match = JSON_TOKEN_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const token = match[0]; + let cls: string; + if (token.startsWith('"')) { + const after = text.slice(JSON_TOKEN_RE.lastIndex); + cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint"; + } else if (token === "true" || token === "false") { + cls = "text-coral"; + } else if (token === "null") { + cls = "text-fg-muted"; + } else { + cls = "text-amber"; + } + parts.push( + + {token} + , + ); + lastIndex = JSON_TOKEN_RE.lastIndex; + } + if (lastIndex < text.length) parts.push(text.slice(lastIndex)); + return parts; +} + +export function DebugEventRow({ + event, + runStart, + selected, + onSelect, +}: { + event: EventEnvelope; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; +}) { + const eventName = event.event ?? ""; + const category = debugCategory(eventName); + return ( + + ); +} + +export function DetailsPanel({ + title, + isOpen, + onClose, + children, +}: { + title: string; + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + useEffect(() => { + if (!isOpen) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + return ( +
+
+
+

{title}

+ +
+
+ {isOpen ? children : null} +
+
+
+ ); +} + +export function DebugEventDetailsPanel({ + event, + onClose, +}: { + event: EventEnvelope | null; + onClose: () => void; +}) { + return ( + + {event ? : null} + + ); +} + +function DebugEventDetails({ event }: { event: EventEnvelope }) { + const text = useMemo(() => JSON.stringify(event, null, 2), [event]); + const tokens = useMemo(() => highlightJson(text), [text]); + return ( +
+      {tokens}
+    
+ ); +} + +export function MultiSelectFilter({ + selected, + options, + labelOf, + onChange, + emptyMeansAll = false, +}: { + selected: T[]; + options: readonly T[]; + labelOf: (item: T) => string; + onChange: (next: T[]) => void; + emptyMeansAll?: boolean; +}) { + const allSelected = selected.length === options.length; + const summary = useMemo(() => { + if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types"; + if (selected.length === 0) return "No types"; + if (selected.length <= 2) { + return options + .filter((o) => selected.includes(o)) + .map(labelOf) + .join(", "); + } + return `${selected.length} types`; + }, [allSelected, emptyMeansAll, selected, options, labelOf]); + + return ( + + + + + {options.map((option) => ( + + + + {labelOf(option)} + + ))} + + + ); +} + +export function EventSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+
+ ); +} diff --git a/apps/fabro-web/app/components/icons.tsx b/apps/fabro-web/app/components/icons.tsx new file mode 100644 index 000000000..6ef921ad6 --- /dev/null +++ b/apps/fabro-web/app/components/icons.tsx @@ -0,0 +1,7 @@ +export function GitPullRequestIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/fabro-web/app/components/inline-markdown.test.tsx b/apps/fabro-web/app/components/inline-markdown.test.tsx new file mode 100644 index 000000000..ae9bef4db --- /dev/null +++ b/apps/fabro-web/app/components/inline-markdown.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { InlineMarkdown } from "./inline-markdown"; + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +async function render(content: string, className?: string) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let renderer: TestRenderer.ReactTestRenderer | undefined; + await act(async () => { + renderer = TestRenderer.create( + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textFromInstance(node: TestRenderer.ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : textFromInstance(child))) + .join(""); +} + +describe("InlineMarkdown", () => { + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test("renders backtick spans as without visible backticks", async () => { + const renderer = await render( + "Move from `[server.integrations.github]` to `[run.integrations.github]`", + ); + + const codes = renderer.root.findAllByType("code"); + expect(codes).toHaveLength(2); + expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]"); + expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]"); + + const fullText = textFromInstance(renderer.root); + expect(fullText).not.toContain("`"); + }); + + test("renders **bold** as and _italic_ as ", async () => { + const renderer = await render("**bold** and _italic_"); + + const strongs = renderer.root.findAllByType("strong"); + expect(strongs).toHaveLength(1); + expect(textFromInstance(strongs[0]!)).toBe("bold"); + + const ems = renderer.root.findAllByType("em"); + expect(ems).toHaveLength(1); + expect(textFromInstance(ems[0]!)).toBe("italic"); + }); + + test("renders *italic* (asterisk form) as ", async () => { + const renderer = await render("*italic*"); + const ems = renderer.root.findAllByType("em"); + expect(ems).toHaveLength(1); + expect(textFromInstance(ems[0]!)).toBe("italic"); + }); + + test("block markdown like headings, lists, blockquotes stays text", async () => { + const renderer = await render("# heading - item > quote"); + + expect(renderer.root.findAllByType("h1")).toHaveLength(0); + expect(renderer.root.findAllByType("h2")).toHaveLength(0); + expect(renderer.root.findAllByType("ul")).toHaveLength(0); + expect(renderer.root.findAllByType("li")).toHaveLength(0); + expect(renderer.root.findAllByType("blockquote")).toHaveLength(0); + + expect(textFromInstance(renderer.root)).toContain("# heading"); + expect(textFromInstance(renderer.root)).toContain("> quote"); + }); + + test("link syntax renders the label as text without an ", async () => { + const renderer = await render("[label](javascript:alert(1))"); + + expect(renderer.root.findAllByType("a")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain("label"); + expect(textFromInstance(renderer.root)).not.toContain("javascript:"); + }); + + test("image syntax renders the alt text without an ", async () => { + const renderer = await render("![alt](x)"); + + expect(renderer.root.findAllByType("img")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain("alt"); + }); + + test("raw HTML is shown literally as text, not interpreted", async () => { + const renderer = await render(""); + + expect(renderer.root.findAllByType("script")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain(""); + }); + + test("applies a className to the wrapper span", async () => { + const renderer = await render("hello", "text-fg-2"); + const wrapper = renderer.root.findByType("span"); + expect(wrapper.props.className).toBe("text-fg-2"); + }); +}); diff --git a/apps/fabro-web/app/components/inline-markdown.tsx b/apps/fabro-web/app/components/inline-markdown.tsx new file mode 100644 index 000000000..907430577 --- /dev/null +++ b/apps/fabro-web/app/components/inline-markdown.tsx @@ -0,0 +1,51 @@ +import { Fragment, useMemo, type ReactNode } from "react"; +import { Lexer, type Token } from "marked"; + +const CODE_CLASSNAME = + "rounded bg-overlay-strong px-1 py-0.5 font-mono text-[0.85em] text-fg-2"; + +function renderTokens(tokens: Token[]): ReactNode[] { + return tokens.map((token, index) => ( + {renderToken(token)} + )); +} + +function renderToken(token: Token): ReactNode { + switch (token.type) { + case "codespan": + return {token.text}; + case "strong": + return {renderTokens(token.tokens)}; + case "em": + return {renderTokens(token.tokens)}; + case "del": + return renderTokens(token.tokens); + case "link": + return token.tokens.length > 0 ? renderTokens(token.tokens) : token.text; + case "image": + return token.text; + case "html": + return token.raw; + case "br": + return " "; + case "escape": + return token.text; + case "text": + return token.tokens && token.tokens.length > 0 + ? renderTokens(token.tokens) + : token.text; + default: + return "raw" in token ? token.raw : ""; + } +} + +export function InlineMarkdown({ + content, + className, +}: { + content: string; + className?: string; +}) { + const children = useMemo(() => renderTokens(Lexer.lexInline(content)), [content]); + return {children}; +} diff --git a/apps/fabro-web/app/components/interview-dock.test.tsx b/apps/fabro-web/app/components/interview-dock.test.tsx index 9ac272ee8..34a58269f 100644 --- a/apps/fabro-web/app/components/interview-dock.test.tsx +++ b/apps/fabro-web/app/components/interview-dock.test.tsx @@ -7,6 +7,7 @@ import { } from "@qltysh/fabro-api-client"; import { InterviewDock, displayLabel } from "./interview-dock"; +import { generatedAxios } from "../lib/api-client"; function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { let tree: TestRenderer.ReactTestRenderer | undefined; @@ -81,6 +82,41 @@ describe("InterviewDock", () => { expect(buttons.No).toBeDefined(); }); + test("yes/no question submits typed yes and no answers", async () => { + const submitted: unknown[] = []; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + submitted.push(JSON.parse(String(config.data))); + return { + data: undefined, + status: 204, + statusText: "No Content", + headers: {}, + config, + }; + }; + + try { + const tree = render( + , + ); + const buttons = buttonsByText(tree); + + await act(async () => { + buttons.Yes.props.onClick(); + await Promise.resolve(); + }); + await act(async () => { + buttons.No.props.onClick(); + await Promise.resolve(); + }); + + expect(submitted).toEqual([{ kind: "yes" }, { kind: "no" }]); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + test("multiple choice question renders option buttons with stripped accelerator prefixes", () => { const question = makeQuestion({ question_type: QuestionType.MULTIPLE_CHOICE, diff --git a/apps/fabro-web/app/components/interview-dock.tsx b/apps/fabro-web/app/components/interview-dock.tsx index c45c67492..35eb2fcf5 100644 --- a/apps/fabro-web/app/components/interview-dock.tsx +++ b/apps/fabro-web/app/components/interview-dock.tsx @@ -22,6 +22,7 @@ import { useSubmitInterviewAnswer, type SubmitInterviewAnswerArg, } from "../lib/mutations"; +import { ApiError } from "../lib/api-client"; import { ErrorMessage } from "./ui"; const PRIMARY_BUTTON = @@ -33,6 +34,8 @@ const CHOICE_BUTTON = const CHOICE_BUTTON_SELECTED = "inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"; +type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"]; + export interface InterviewDockProps { runId: string; questions: ApiQuestion[]; @@ -52,15 +55,13 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) { }, [question?.id, submitMutation.reset]); const submit = useCallback( - async (arg: Omit) => { + async (answer: SubmitInterviewAnswer) => { if (!question) return; setError(null); try { - await submitMutation.trigger({ ...arg, questionId: question.id }); + await submitMutation.trigger({ questionId: question.id, answer }); } catch (caught) { - setError( - caught instanceof Error ? caught.message : "Couldn't submit your answer.", - ); + setError(interviewSubmitErrorMessage(caught)); } }, [question, submitMutation], @@ -172,7 +173,7 @@ function QuestionBody({ }: { question: ApiQuestion; submitting: boolean; - onSubmit: (arg: Omit) => Promise; + onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { switch (question.question_type) { case QuestionType.YES_NO: @@ -216,14 +217,14 @@ function YesNoBody({ onSubmit, }: { submitting: boolean; - onSubmit: (arg: Omit) => Promise; + onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { return (
diff --git a/apps/fabro-web/app/components/steer-bar.tsx b/apps/fabro-web/app/components/steer-bar.tsx index db3bb5bb7..e2db026d3 100644 --- a/apps/fabro-web/app/components/steer-bar.tsx +++ b/apps/fabro-web/app/components/steer-bar.tsx @@ -6,7 +6,10 @@ import { type FormEvent, type KeyboardEvent, } from "react"; -import { CheckIcon } from "@heroicons/react/16/solid"; + +import { ApiError } from "../lib/api-client"; +import { useInterruptRun, useSteerRun } from "../lib/mutations"; +import { ErrorMessage } from "./ui"; export interface SteerBarProps { runId: string; @@ -17,32 +20,55 @@ export interface SteerBarHandle { } export const SteerBar = forwardRef(function SteerBar( - { runId: _runId }, + { runId }, ref, ) { const [text, setText] = useState(""); - const [interrupt, setInterrupt] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const textareaRef = useRef(null); + const steer = useSteerRun(runId); + const interrupt = useInterruptRun(runId); + const pending = steer.isMutating || interrupt.isMutating; + useImperativeHandle(ref, () => ({ focus() { textareaRef.current?.focus(); }, })); + const trimmed = text.trim(); - const canSubmit = trimmed.length > 0; + const canSend = trimmed.length > 0 && !pending; + + async function sendSteering() { + if (!canSend) return; + setErrorMessage(null); + try { + await steer.trigger({ text: trimmed, interrupt: false }); + setText(""); + } catch (err) { + setErrorMessage(formatSteerError(err)); + } + } + + async function fireInterrupt() { + if (pending) return; + setErrorMessage(null); + try { + await interrupt.trigger(); + } catch (err) { + setErrorMessage(formatInterruptError(err)); + } + } function handleSubmit(e: FormEvent) { e.preventDefault(); - if (!canSubmit) return; - setText(""); + void sendSteering(); } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (canSubmit) { - setText(""); - } + void sendSteering(); } } @@ -50,51 +76,63 @@ export const SteerBar = forwardRef(function Steer
-