diff --git a/.config/nextest.toml b/.config/nextest.toml index fe7f4f778..3af76c17c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,10 +11,6 @@ leak-timeout = "500ms" filter = "package(fabro-server)" slow-timeout = { period = "5s", terminate-after = 4 } - [[profile.default.overrides]] - filter = "package(fabro-server) & test(all_spec_routes_are_routable)" - slow-timeout = { period = "15s", terminate-after = 4 } - [[profile.default.overrides]] filter = "package(fabro-workflow)" slow-timeout = { period = "2s", terminate-after = 3 } diff --git a/.env.example b/.env.example index a2a74f3e6..ea69ab76b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ GEMINI_API_KEY= INCEPTION_API_KEY= KIMI_API_KEY= MINIMAX_API_KEY= +MODAL_KIMI_K3_BASE_URL= +MODAL_TOKEN_ID= +MODAL_TOKEN_SECRET= OPENAI_API_KEY= OPENROUTER_API_KEY= POOLSIDE_API_KEY= diff --git a/Cargo.lock b/Cargo.lock index e71612fb2..b3457ea3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2304,7 +2304,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2327,7 +2327,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2352,7 +2352,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2371,11 +2371,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -2391,7 +2391,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2493,7 +2493,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2522,7 +2522,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2552,7 +2552,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2579,7 +2579,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2598,7 +2598,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2612,7 +2612,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2634,7 +2634,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2656,7 +2656,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2671,7 +2671,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -2694,7 +2694,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2704,7 +2704,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2723,7 +2723,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2738,7 +2738,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2779,7 +2779,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2790,7 +2790,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2808,7 +2808,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2828,7 +2828,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2855,7 +2855,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2873,7 +2873,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2889,7 +2889,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2919,7 +2919,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "cc", "libc", @@ -2928,7 +2928,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2944,7 +2944,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2988,7 +2988,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3028,6 +3028,7 @@ dependencies = [ "fabro-spa", "fabro-static", "fabro-store", + "fabro-template", "fabro-test", "fabro-tool", "fabro-types", @@ -3080,7 +3081,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3102,18 +3103,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" [[package]] name = "fabro-store" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3143,7 +3144,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3169,7 +3170,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3183,7 +3184,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3208,7 +3209,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3229,7 +3230,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3243,7 +3244,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "chrono", "clap", @@ -3258,6 +3259,7 @@ dependencies = [ "shlex", "strum 0.28.0", "tempfile", + "thiserror 2.0.18", "toml 0.8.23", "ulid", "url", @@ -3265,7 +3267,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3288,7 +3290,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3301,7 +3303,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3318,7 +3320,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3337,7 +3339,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3393,6 +3395,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "shlex", + "strum 0.28.0", "tempfile", "thiserror 2.0.18", "tokio", @@ -8501,7 +8504,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "axum", "base64", @@ -8520,7 +8523,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 077adbc7d..cf31d143c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.305.0-nightly.3" +version = "0.309.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/apps/fabro-web/app/components/interview-dock.test.tsx b/apps/fabro-web/app/components/interview-dock.test.tsx index 290581690..e3eb1e5ed 100644 --- a/apps/fabro-web/app/components/interview-dock.test.tsx +++ b/apps/fabro-web/app/components/interview-dock.test.tsx @@ -4,9 +4,14 @@ import { SWRConfig } from "swr"; import { type ApiQuestion, QuestionType, + ReviewTargetKind, } from "@qltysh/fabro-api-client"; -import { InterviewDock } from "./interview-dock"; +import { + contextPreview, + InterviewDock, + shouldStackOptions, +} from "./interview-dock"; import { displayLabel } from "./interview-label"; import { generatedAxios } from "../lib/api-client"; @@ -74,6 +79,58 @@ describe("InterviewDock", () => { expect(text).toContain("Awaiting input"); }); + test("renders the review target as the link in the question", () => { + const url = + "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef"; + const tree = render( + , + ); + + expect(textContent(tree.toJSON())).toContain( + "Review the Quarry review exercise document, then choose the next action.", + ); + const links = tree.root.findAllByType("a"); + expect(links).toHaveLength(1); + expect(links[0].props.href).toBe(url); + expect(links[0].props.target).toBe("_blank"); + expect(links[0].props.rel).toBe("noopener noreferrer"); + expect(links[0].props.referrerPolicy).toBe("no-referrer"); + }); + + test("does not link an unsafe review target received from the API", () => { + const fallback = "Review the document, then choose the next action."; + const tree = render( + , + ); + + expect(textContent(tree.toJSON())).toContain(fallback); + expect(tree.root.findAllByType("a")).toHaveLength(0); + }); + test("yes/no question shows two buttons", () => { const tree = render( , @@ -236,6 +293,112 @@ describe("InterviewDock", () => { expect(text).toContain("Context from preceding stage"); expect(text).toContain("1. Deploy"); }); + + test("context starts closed so it costs one line, not a standing panel", () => { + const question = makeQuestion({ + context_display: "Plan:\n1. Deploy\n2. Verify", + }); + const tree = render( + , + ); + const details = tree.root.findAllByType("details"); + expect(details).toHaveLength(1); + expect(details[0]!.props.open).toBeFalsy(); + }); + + test("omits the question type subtitle that the answer buttons already state", () => { + const question = makeQuestion({ + question_type: QuestionType.MULTIPLE_CHOICE, + options: [{ key: "A", label: "[A] Approve" }], + }); + const tree = render( + , + ); + expect(textContent(tree.toJSON())).not.toContain("Pick one"); + }); + + test("collapsing hides the answer controls but keeps the header", () => { + const tree = render( + , + ); + const toggle = tree.root.findByProps({ "aria-label": "Collapse Interview question" }); + + act(() => { + toggle.props.onClick(); + }); + + const expanded = tree.root.findByProps({ + "aria-label": "Expand Interview question", + }); + expect(expanded.props["aria-expanded"]).toBe(false); + expect(textContent(tree.toJSON())).toContain("Awaiting input"); + }); + + test("a queued question arrives expanded even after the panel was collapsed", () => { + const questions = [ + makeQuestion({ id: "q-1", stage: "stage-a" }), + makeQuestion({ id: "q-2", stage: "stage-b" }), + ]; + const tree = render(); + + act(() => { + tree.root + .findByProps({ "aria-label": "Collapse Interview question" }) + .props.onClick(); + }); + act(() => { + buttonsByText(tree)["1 more pending"]!.props.onClick(); + }); + + const toggle = tree.root.findByProps({ + "aria-label": "Collapse Interview question", + }); + expect(toggle.props["aria-expanded"]).toBe(true); + }); +}); + +describe("shouldStackOptions", () => { + test("keeps short labels as a wrapping row", () => { + expect( + shouldStackOptions([ + { key: "a", label: "Approve" }, + { key: "b", label: "Revise" }, + ]), + ).toBe(false); + }); + + test("stacks once a label is too long to sit in a pill", () => { + expect( + shouldStackOptions([ + { key: "a", label: "Approve" }, + { key: "b", label: "Review complete; sync the current Markdown document" }, + ]), + ).toBe(true); + }); + + test("stacks when any option carries a description", () => { + expect( + shouldStackOptions([ + { key: "a", label: "Approve", description: "Deploy the current patch" }, + ]), + ).toBe(true); + }); +}); + +describe("contextPreview", () => { + test("uses the first non-empty line", () => { + expect(contextPreview("\n\nPlan is ready\nSecond line")).toBe("Plan is ready"); + }); + + test("truncates a long first line", () => { + const preview = contextPreview("x".repeat(200)); + expect(preview).toHaveLength(61); + expect(preview.endsWith("…")).toBe(true); + }); + + test("returns an empty string for blank context", () => { + expect(contextPreview(" \n ")).toBe(""); + }); }); describe("displayLabel", () => { diff --git a/apps/fabro-web/app/components/interview-dock.tsx b/apps/fabro-web/app/components/interview-dock.tsx index a2164715a..ddf900daf 100644 --- a/apps/fabro-web/app/components/interview-dock.tsx +++ b/apps/fabro-web/app/components/interview-dock.tsx @@ -1,14 +1,8 @@ +import { useCallback, useState } from "react"; import { - useCallback, - useState, - type FormEvent, - type KeyboardEvent, -} from "react"; -import { - ArrowPathIcon, ArrowRightIcon, - ArrowUturnLeftIcon, CheckIcon, + ChevronRightIcon, } from "@heroicons/react/20/solid"; import { QuestionType } from "@qltysh/fabro-api-client"; import type { @@ -22,16 +16,31 @@ import { } from "../lib/mutations"; import { ApiError } from "../lib/api-client"; import { displayLabel } from "./interview-label"; -import { ErrorMessage } from "./ui"; +import { + ReviewTargetQuestion, + safeReviewTarget, +} from "./review-target-question"; +import { + DockComposer, + RunDockShell, + DOCK_CHOICE_BUTTON, + DOCK_CHOICE_BUTTON_SELECTED, + DOCK_HEADER_BUTTON, +} from "./run-dock"; +import { Spinner } from "./state"; +import { + ErrorMessage, + PRIMARY_BUTTON_CLASS, +} from "./ui"; -const PRIMARY_BUTTON = - "inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500 px-3.5 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"; +/** + * Options stack into a list once a label is long enough that a row of pills + * would wrap mid-sentence. + */ +const STACK_LABEL_LENGTH = 40; -const CHOICE_BUTTON = - "inline-flex items-center justify-center gap-1.5 rounded-lg bg-overlay px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60"; - -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"; +/** Shared by the plain question text and the review target rendering. */ +const QUESTION_TEXT = "max-w-[78ch] text-base/6 font-medium text-pretty text-fg"; type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"]; @@ -51,6 +60,8 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) { const moreCount = questions.length - 1; return ( + // Keyed by question id, so a new question always arrives expanded with an + // empty composer. A collapsed panel can never silently block a run. (null); + const [collapsed, setCollapsed] = useState(false); const submitting = submitMutation.isMutating; + const reviewTarget = safeReviewTarget(question.review_target); const submit = useCallback( async (answer: SubmitInterviewAnswer) => { setError(null); try { await submitMutation.trigger({ questionId: question.id, answer }); + return true; } catch (caught) { setError(interviewSubmitErrorMessage(caught)); + return false; } }, [question.id, submitMutation], ); return ( -
- -
-
-

- {question.text} -

-

- {questionTypeLabel(question.question_type)} -

-
- - {question.context_display && ( - - )} - - - - {error && } -
-
- ); -} - -function DockHeader({ - stage, - moreCount, - onCycle, -}: { - stage: string; - moreCount: number; - onCycle: () => void; -}) { - return ( -
-
- - Awaiting input - {stage && ( - <> - - {stage} - - )} -
- {moreCount > 0 && ( - - )} -
- ); -} - -function PulseDot() { - return ( -