From e83d1957b44a31b2246b3a3207c029e368acd70d Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Fri, 3 Jul 2026 11:29:05 +0100 Subject: [PATCH] core+app: agent headline for board rows + resizable preview rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table's agent's-read column showed a mid-sentence 2-line cut of the intent paragraph — dense, not scannable. The pre-review contract now asks for a dedicated one-line HEADLINE (<=80 chars) alongside the intent; parse caps it at 120 and flattens whitespace; ingest stores it on Review.distilled_headline (cleared with the other head-anchored annotations). The table renders the headline (or the intent's first sentence for pre-headline data) on a single line. Goldens regenerated DELIBERATELY: the review output contract gained the headline field. The preview rail is now resizable: drag its left divider (or arrow keys when focused; double-click resets), width persisted and clamped to 300-720px. --- app/src-tauri/src/lib.rs | 5 + app/src/App.tsx | 97 +++++++++++++++++-- app/src/bindings/Review.ts | 9 ++ app/src/components/BoardTable.tsx | 21 ++-- app/src/lib/rail-width.test.ts | 27 ++++++ app/src/lib/rail-width.ts | 46 +++++++++ app/src/lib/review-headline.test.ts | 47 +++++++++ app/src/lib/review-headline.ts | 41 ++++++++ app/src/test/fixtures.ts | 1 + crates/cockpit-core/src/adapters/github.rs | 2 + crates/cockpit-core/src/findings.rs | 60 +++++++++++- crates/cockpit-core/src/gate.rs | 1 + crates/cockpit-core/src/kickoff.rs | 3 + crates/cockpit-core/src/model.rs | 9 ++ crates/cockpit-core/src/persist.rs | 1 + crates/cockpit-core/src/prompt.rs | 4 +- crates/cockpit-core/src/restack.rs | 1 + crates/cockpit-core/src/store.rs | 1 + .../cockpit-core/tests/batch_fan_out_e2e.rs | 1 + crates/cockpit-core/tests/e2e_round_trip.rs | 1 + crates/cockpit-core/tests/fix_loop_e2e.rs | 1 + .../tests/golden/review_prompt.txt | 4 +- .../tests/golden/review_prompt_panel.txt | 4 +- .../tests/golden/review_prompt_recap.txt | 4 +- 24 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 app/src/lib/rail-width.test.ts create mode 100644 app/src/lib/rail-width.ts create mode 100644 app/src/lib/review-headline.test.ts create mode 100644 app/src/lib/review-headline.ts diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4f93468..92bc8dd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -674,6 +674,9 @@ fn ingest_review_findings(state: &AppState, object_id: &str) -> &'static str { if let Some(intent) = review.intent { r.distilled_intent = Some(intent); } + if let Some(headline) = review.headline { + r.distilled_headline = Some(headline); + } } if recap_present { r.recap_sha = Some(r.head_sha.clone()); @@ -788,6 +791,7 @@ async fn refresh_review_diff(state: &AppState, pr_ref: &PrRef) { fn clear_head_anchored_annotations(review: &mut Review) { review.review_findings.clear(); review.distilled_intent = None; + review.distilled_headline = None; } /// Extract the PR number from a PR URL or reference string. @@ -1420,6 +1424,7 @@ mod tests { conversation: vec![], last_reviewed_sha: None, distilled_intent: None, + distilled_headline: None, recap_sha: None, } } diff --git a/app/src/App.tsx b/app/src/App.tsx index d6a6d52..412be8e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -52,6 +52,15 @@ import { saveDensity, WIDE_BREAKPOINT, } from "./lib/board-density"; +import { + clampRailWidth, + loadRailWidth, + saveRailWidth, + RAIL_DEFAULT_WIDTH, + RAIL_KEY_STEP, + RAIL_MIN_WIDTH, + RAIL_MAX_WIDTH, +} from "./lib/rail-width"; import type { GateState } from "./bindings/GateState"; import type { DiffSide } from "./bindings/DiffSide"; import type { Review } from "./bindings/Review"; @@ -301,6 +310,62 @@ function App() { // Triage-table selection (drives the preview rail), by PR ref. const [selectedPr, setSelectedPr] = useState(null); + + // User-resizable preview-rail width, persisted. Dragging tracks the pointer + // from a captured start position; the write happens once on release. + const [railWidth, setRailWidth] = useState(loadRailWidth); + const railDrag = useRef<{ startX: number; startWidth: number } | null>(null); + + const handleRailDragStart = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + railDrag.current = { startX: e.clientX, startWidth: railWidth }; + }, + [railWidth], + ); + const handleRailDragMove = useCallback( + (e: React.PointerEvent) => { + const drag = railDrag.current; + if (drag === null) return; + // Dragging left widens the rail (the divider sits on its left edge). + setRailWidth(clampRailWidth(drag.startWidth + (drag.startX - e.clientX))); + }, + [], + ); + const handleRailDragEnd = useCallback( + (e: React.PointerEvent) => { + const drag = railDrag.current; + if (drag === null) return; + railDrag.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + saveRailWidth(clampRailWidth(drag.startWidth + (drag.startX - e.clientX))); + }, + [], + ); + const resetRailWidth = useCallback(() => { + setRailWidth(RAIL_DEFAULT_WIDTH); + saveRailWidth(RAIL_DEFAULT_WIDTH); + }, []); + const handleRailKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Left widens / right narrows, mirroring the drag direction. + const delta = + e.key === "ArrowLeft" + ? RAIL_KEY_STEP + : e.key === "ArrowRight" + ? -RAIL_KEY_STEP + : null; + if (delta === null) return; + e.preventDefault(); + setRailWidth((w) => { + const next = clampRailWidth(w + delta); + saveRailWidth(next); + return next; + }); + }, + [], + ); const requestDiffJump = useAppStore((s) => s.requestDiffJump); // Only render the preview rail when the window is wide enough for it. const railFits = useMediaQuery(RAIL_MIN_WIDTH_QUERY); @@ -892,13 +957,33 @@ function App() { )} {railFits && selectedReview !== null && ( - + + )} ); diff --git a/app/src/bindings/Review.ts b/app/src/bindings/Review.ts index 698a28a..b4fed4e 100644 --- a/app/src/bindings/Review.ts +++ b/app/src/bindings/Review.ts @@ -146,6 +146,15 @@ last_reviewed_sha: string | null, * legacy data. */ distilled_intent: string | null, +/** + * The pre-review agent's one-line (≤120 chars) summary of what the PR + * does, for card and table-row display where a paragraph doesn't scan. + * + * Same lifecycle as [`Self::distilled_intent`]: advisory display only, + * cleared when the head moves, `None` before the first pre-pass and for + * legacy data (display falls back to the intent's first sentence). + */ +distilled_headline: string | null, /** * The head SHA the rendered pre-review recap was generated at, for * staleness. `None` before any recap and for legacy data. diff --git a/app/src/components/BoardTable.tsx b/app/src/components/BoardTable.tsx index cd81312..c51e680 100644 --- a/app/src/components/BoardTable.tsx +++ b/app/src/components/BoardTable.tsx @@ -23,7 +23,7 @@ import { StatusLed } from "./StatusLed"; import { cardSignal, toneTextClass } from "../lib/card-signal"; import { actionConfigForState } from "../lib/review-action"; import { parsePrDisplay } from "../lib/pr-ref"; -import { stripMarkdown } from "../lib/markdown-text"; +import { reviewHeadline } from "../lib/review-headline"; import { findingCounts, severityMeta } from "../lib/finding-severity"; import { ciState } from "../lib/ci"; import { diffTotals, sizeClass, sizeClassLabel } from "../lib/diff-signals"; @@ -163,13 +163,10 @@ function BoardRow({ const signal = cardSignal(review); const action = actionConfigForState(review.gate_state); const { number: prNumber } = parsePrDisplay(review.pr); - const intent = useMemo( - () => - review.distilled_intent !== null - ? stripMarkdown(review.distilled_intent) - : null, - [review.distilled_intent], - ); + // One scannable line, not a mid-cut paragraph: the agent's dedicated + // headline, or the intent's first sentence for pre-headline data. The full + // read lives in the preview rail and the Briefing. + const headline = useMemo(() => reviewHeadline(review), [review]); return (
{/* Agent's read */} - {intent !== null ? ( + {headline !== null ? (

- {intent} + {headline}

) : (