Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1420,6 +1424,7 @@ mod tests {
conversation: vec![],
last_reviewed_sha: None,
distilled_intent: None,
distilled_headline: None,
recap_sha: None,
}
}
Expand Down
97 changes: 91 additions & 6 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -301,6 +310,62 @@ function App() {

// Triage-table selection (drives the preview rail), by PR ref.
const [selectedPr, setSelectedPr] = useState<string | null>(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<number>(loadRailWidth);
const railDrag = useRef<{ startX: number; startWidth: number } | null>(null);

const handleRailDragStart = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
railDrag.current = { startX: e.clientX, startWidth: railWidth };
},
[railWidth],
);
const handleRailDragMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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);
Expand Down Expand Up @@ -892,13 +957,33 @@ function App() {
)}
</div>
{railFits && selectedReview !== null && (
<aside className="w-[380px] shrink-0 overflow-y-auto border-l border-border bg-card">
<BoardPreviewRail
review={selectedReview}
onOpen={handleOpenReview}
onJumpTo={handleRailJump}
<>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize preview rail"
aria-valuenow={railWidth}
aria-valuemin={RAIL_MIN_WIDTH}
aria-valuemax={RAIL_MAX_WIDTH}
tabIndex={0}
onPointerDown={handleRailDragStart}
onPointerMove={handleRailDragMove}
onPointerUp={handleRailDragEnd}
onDoubleClick={resetRailWidth}
onKeyDown={handleRailKeyDown}
className="w-1.5 shrink-0 cursor-col-resize outline-none transition-colors hover:bg-primary/30 focus-visible:bg-primary/40"
/>
</aside>
<aside
style={{ width: railWidth }}
className="shrink-0 overflow-y-auto border-l border-border bg-card"
>
<BoardPreviewRail
review={selectedReview}
onOpen={handleOpenReview}
onJumpTo={handleRailJump}
/>
</aside>
</>
)}
</>
);
Expand Down
9 changes: 9 additions & 0 deletions app/src/bindings/Review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 9 additions & 12 deletions app/src/components/BoardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -212,12 +209,12 @@ function BoardRow({
</div>

{/* Agent's read */}
{intent !== null ? (
{headline !== null ? (
<p
className="line-clamp-2 text-xs leading-snug text-muted-foreground"
title={intent}
className="truncate text-xs leading-snug text-muted-foreground"
title={headline}
>
{intent}
{headline}
</p>
) : (
<span className="text-xs text-muted-foreground/50" aria-hidden="true">
Expand Down
27 changes: 27 additions & 0 deletions app/src/lib/rail-width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import {
clampRailWidth,
initialRailWidth,
RAIL_DEFAULT_WIDTH,
RAIL_MAX_WIDTH,
RAIL_MIN_WIDTH,
} from "./rail-width";

describe("clampRailWidth", () => {
it("clamps into the usable band and rounds", () => {
expect(clampRailWidth(100)).toBe(RAIL_MIN_WIDTH);
expect(clampRailWidth(10_000)).toBe(RAIL_MAX_WIDTH);
expect(clampRailWidth(400.6)).toBe(401);
expect(clampRailWidth(Number.NaN)).toBe(RAIL_DEFAULT_WIDTH);
});
});

describe("initialRailWidth", () => {
it("defaults when nothing or garbage is stored, clamps stored values", () => {
expect(initialRailWidth(null)).toBe(RAIL_DEFAULT_WIDTH);
expect(initialRailWidth("bogus")).toBe(RAIL_DEFAULT_WIDTH);
expect(initialRailWidth("500")).toBe(500);
expect(initialRailWidth("50")).toBe(RAIL_MIN_WIDTH);
expect(initialRailWidth("9999")).toBe(RAIL_MAX_WIDTH);
});
});
46 changes: 46 additions & 0 deletions app/src/lib/rail-width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Width model + persistence for the board's briefing preview rail.
*
* The rail is user-resizable by dragging its left edge (or arrow keys on the
* focused divider). The chosen width persists; malformed or out-of-range
* stored values clamp back into the usable band so a bad write can never
* produce an invisible or page-eating rail.
*/

/** localStorage key the persisted rail width (px) is stored under. */
export const RAIL_WIDTH_KEY = "cockpit.board.railWidth";

/** Narrowest useful rail: below this the findings rows wrap unreadably. */
export const RAIL_MIN_WIDTH = 300;

/** Widest allowed rail: keeps the triage table usable beside it. */
export const RAIL_MAX_WIDTH = 720;

/** Width before the user ever resizes (the D1 mockup's proportion). */
export const RAIL_DEFAULT_WIDTH = 380;

/** Step (px) for one arrow-key press on the focused resize divider. */
export const RAIL_KEY_STEP = 16;

/** Clamp any candidate width into the usable band. */
export function clampRailWidth(width: number): number {
if (Number.isNaN(width)) return RAIL_DEFAULT_WIDTH;
return Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, Math.round(width)));
}

/** Parse a stored raw value into a usable width, defaulting when malformed. */
export function initialRailWidth(stored: string | null): number {
if (stored === null) return RAIL_DEFAULT_WIDTH;
const parsed = Number.parseInt(stored, 10);
return Number.isNaN(parsed) ? RAIL_DEFAULT_WIDTH : clampRailWidth(parsed);
}

/** Read the persisted rail width from `localStorage`. */
export function loadRailWidth(): number {
return initialRailWidth(localStorage.getItem(RAIL_WIDTH_KEY));
}

/** Persist the user's rail width. */
export function saveRailWidth(width: number): void {
localStorage.setItem(RAIL_WIDTH_KEY, String(clampRailWidth(width)));
}
47 changes: 47 additions & 0 deletions app/src/lib/review-headline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { reviewHeadline } from "./review-headline";
import { makeReview } from "../test/fixtures";

describe("reviewHeadline", () => {
it("prefers the agent-authored headline verbatim", () => {
const review = {
...makeReview(),
distilled_headline: "Adds share-by-email behind a flag",
distilled_intent: "A long paragraph. With more sentences.",
};
expect(reviewHeadline(review)).toBe("Adds share-by-email behind a flag");
});

it("falls back to the intent's first sentence for pre-headline data", () => {
const review = {
...makeReview(),
distilled_headline: null,
distilled_intent:
"Implements the Share flow for NEX-1605: a new QuoteEmailService renders and sends. A new table links emails to quotes.",
};
expect(reviewHeadline(review)).toBe(
"Implements the Share flow for NEX-1605: a new QuoteEmailService renders and sends.",
);
});

it("caps a boundary-free fallback with an ellipsis", () => {
const review = {
...makeReview(),
distilled_headline: null,
distilled_intent: "x".repeat(400),
};
const headline = reviewHeadline(review);
expect(headline).not.toBeNull();
expect(headline?.length).toBe(160);
expect(headline?.endsWith("…")).toBe(true);
});

it("returns null when the review has no agent read", () => {
const review = {
...makeReview(),
distilled_headline: null,
distilled_intent: null,
};
expect(reviewHeadline(review)).toBeNull();
});
});
41 changes: 41 additions & 0 deletions app/src/lib/review-headline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Review } from "../bindings/Review";
import { stripMarkdown } from "./markdown-text";

/**
* Longest fallback headline derived from the intent paragraph. The agent's own
* headline is capped harder (≤120) by the backend; this only bounds the
* first-sentence fallback for pre-headline data.
*/
const MAX_FALLBACK_CHARS = 160;

/**
* The one-line summary of a review for cards and table rows.
*
* Prefers the agent-authored `distilled_headline` (a deliberate ≤80-char
* sentence from the pre-review contract). For reviews analyzed before the
* headline existed, falls back to the first sentence of the distilled intent —
* a mid-paragraph ellipsis cut is exactly what made the table column hard to
* scan. Returns null when the review has no agent read at all.
*/
export function reviewHeadline(review: Review): string | null {
if (review.distilled_headline !== null) return review.distilled_headline;
if (review.distilled_intent === null) return null;
const plain = stripMarkdown(review.distilled_intent).trim();
if (plain === "") return null;
return truncate(firstSentence(plain), MAX_FALLBACK_CHARS);
}

/**
* The first sentence of a paragraph: up to the first `.`, `!`, or `?` that is
* followed by whitespace or the end. Falls back to the whole text when no
* sentence boundary exists.
*/
function firstSentence(text: string): string {
const match = /^[\s\S]*?[.!?](?=\s|$)/.exec(text);
return (match?.[0] ?? text).trim();
}

/** Hard char cap with a single-character ellipsis. */
function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
}
1 change: 1 addition & 0 deletions app/src/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function makeReview(overrides: Partial<Review> = {}): Review {
ci_summary: null,
last_reviewed_sha: null,
distilled_intent: null,
distilled_headline: null,
recap_sha: null,
};
return { ...base, ...overrides };
Expand Down
2 changes: 2 additions & 0 deletions crates/cockpit-core/src/adapters/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ pub fn build_review_from_pr(
conversation: vec![],
last_reviewed_sha: None,
distilled_intent: None,
distilled_headline: None,
recap_sha: None,
}
}
Expand Down Expand Up @@ -2665,6 +2666,7 @@ index 1111111..2222222 100644
conversation: vec![],
last_reviewed_sha: None,
distilled_intent: None,
distilled_headline: None,
recap_sha: None,
}
}
Expand Down
Loading
Loading