From 953fbc1a6349c225cb1386a04708bb5d4630950e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 23:44:52 -0500 Subject: [PATCH] Queue tab redesign: meta row, scope segment, accent-rail rows, markdown note composer (tasks-list-redesign-refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Queue.dc.html design handoff for the Familiar Work Queue (the Tasks page's Queue tab). Keeps the queue's real lane model, per-familiar rollup, degradation banners, and bead inspector intact — this is a visual/interaction refresh, not a data-model change. - Meta row: actionable · total · "updated Xm ago" + Refresh (was surface-compact). - Scope segment: All / Unassigned (whole-queue split), composes with familiar chips. - Segmented triage toolbar: search + priority bands + sort, matching the mock. - Group headers gain a per-tone left accent border; rows become full-width list rows with a priority/lane accent rail, an assignment pill + sigil, a bead chip, a check-state dot-pill, and a mono priority/updated readout. - Note composer upgraded to the design's inline markdown editor: Write/Preview tabs + a formatting toolbar over a self-contained, HTML-escaping renderer. Keeps the "Handoff note for " AT name and the cleanup Close-gating flow; commit button is now "Save note". - Claim-for-familiar split control becomes the design's "Forward to familiar" dropdown (ForwardMenu), keeping the "Claim for familiar…" trigger name and menuitemradio items so the a11y + e2e contract is unchanged. Tests: unit source-pins rewritten for the redesign; e2e updated (Add note → Save note). tsc/eslint/design-codemod/tests-wired/build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../familiar-work-queue-sections.tsx | 514 ++++++++--- .../familiar-work-queue-view.test.ts | 99 +- src/components/familiar-work-queue-view.tsx | 116 ++- src/lib/design-token-drift.test.ts | 2 +- src/styles/familiar-work-queue.css | 846 ++++++++++++++---- tests/familiar-work-queue.spec.ts | 2 +- 6 files changed, 1234 insertions(+), 345 deletions(-) diff --git a/src/components/familiar-work-queue-sections.tsx b/src/components/familiar-work-queue-sections.tsx index ce3b5c414..9191a17e8 100644 --- a/src/components/familiar-work-queue-sections.tsx +++ b/src/components/familiar-work-queue-sections.tsx @@ -5,7 +5,6 @@ import { Icon } from "@/lib/icon"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; import { SkeletonRows } from "@/components/ui/skeleton"; -import { StandardSelect } from "@/components/ui/select"; import { useAnnouncer } from "@/components/ui/live-region"; import { relativeTime } from "@/lib/relative-time"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; @@ -222,6 +221,224 @@ export function AttentionStrip({ ); } +/** The row's left accent-rail tone (a finite enum → a `fwq-row--rail-*` class, + * so the colour stays in CSS rather than an inline style). Bead-only rows read + * by priority (what to pick up first); PR-backed rows read by lane state (what + * is blocking the merge). */ +function railClass(item: WorkQueueItem): string { + if (!item.pr && !item.merged && item.bead) { + if (item.bead.priority === 0) return "danger"; + if (item.bead.priority === 1) return "warning"; + return "neutral"; + } + switch (item.lane) { + case "checks-failing": + return "danger"; + case "changes-requested": + return "warning"; + case "waiting": + return "waiting"; + case "ready-to-merge": + case "post-merge-cleanup": + return "success"; + default: + return "neutral"; + } +} + +// ── Inline markdown note editor (queue redesign) ────────────────────────────── +// A lightweight Write/Preview composer with a formatting toolbar, ported from +// the design prototype. The heavy CodeMirror MdEditor is overkill for a short +// handoff note; this stays self-contained and matches the mock pixel-for-pixel. + +type MdKind = "bold" | "italic" | "code" | "h" | "ul" | "quote" | "link"; + +/** Wrap/insert markdown around the textarea's current selection, returning the + * next value and the caret position to restore. */ +function applyMarkdown(value: string, start: number, end: number, kind: MdKind): { next: string; caret: number } { + const sel = value.slice(start, end); + const atLineStart = start === 0 || value[start - 1] === "\n"; + const nl = atLineStart ? "" : "\n"; + let ins: string; + switch (kind) { + case "bold": + ins = `**${sel || "bold"}**`; + break; + case "italic": + ins = `*${sel || "italic"}*`; + break; + case "code": + ins = `\`${sel || "code"}\``; + break; + case "h": + ins = `${nl}## ${sel || "Heading"}`; + break; + case "ul": + ins = `${nl}- ${sel || "List item"}`; + break; + case "quote": + ins = `${nl}> ${sel || "Quote"}`; + break; + case "link": + ins = `[${sel || "label"}](https://)`; + break; + default: + ins = sel; + } + return { next: value.slice(0, start) + ins + value.slice(end), caret: start + ins.length }; +} + +/** Minimal, escaping markdown → HTML for the preview pane. Every user string is + * HTML-escaped before any tag is emitted, so the innerHTML is inert. */ +function renderMarkdown(src: string): string { + if (!src || !src.trim()) { + return '

Nothing to preview yet — switch to Write to add details.

'; + } + const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + const inline = (s: string) => + esc(s) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") + .replace( + /`([^`]+)`/g, + '$1', + ) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + const lines = src.split(/\r?\n/); + let html = ""; + let list: "ul" | "ol" | null = null; + const closeList = () => { + if (list) { + html += list === "ul" ? "" : ""; + list = null; + } + }; + for (const raw of lines) { + const line = raw.replace(/\s+$/, ""); + let m: RegExpMatchArray | null; + if (!line.trim()) { + closeList(); + continue; + } + if ((m = line.match(/^(#{1,3})\s+(.*)/))) { + closeList(); + const lv = m[1].length; + const sz = lv === 1 ? 18 : lv === 2 ? 15.5 : 13.5; + html += `
${inline(m[2])}
`; + continue; + } + if ((m = line.match(/^>\s?(.*)/))) { + closeList(); + html += `
${inline(m[1])}
`; + continue; + } + if ((m = line.match(/^[-*]\s+(.*)/))) { + if (list !== "ul") { + closeList(); + html += '
    '; + list = "ul"; + } + html += `
  • ${inline(m[1])}
  • `; + continue; + } + if ((m = line.match(/^\d+\.\s+(.*)/))) { + if (list !== "ol") { + closeList(); + html += '
      '; + list = "ol"; + } + html += `
    1. ${inline(m[1])}
    2. `; + continue; + } + closeList(); + html += `

      ${inline(line)}

      `; + } + closeList(); + return html; +} + +/** Forward-to-familiar dropdown (queue redesign) — replaces the split + * StandardSelect. Claims the bead on a familiar's behalf (cave-p63a). Keeps + * the "Claim for familiar…" trigger name and menuitemradio items so the a11y + * contract (and the e2e claim-for flow) is unchanged. */ +function ForwardMenu({ + familiars, + disabled, + onClaimFor, +}: { + familiars: ResolvedFamiliar[]; + disabled: boolean; + onClaimFor: (familiar: ResolvedFamiliar) => void; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + + return ( +
      + {open ? ( + + {open ? ( +
      +

      Forward to familiar

      + {familiars.map((f) => ( + + ))} +
      + ) : null} +
      + ); +} + export function WorkQueueCard({ item, familiarLabel, @@ -252,8 +469,12 @@ export function WorkQueueCard({ const title = item.pr?.title ?? item.merged?.title ?? item.bead?.title ?? "Untitled"; const prNumber = item.pr?.number ?? item.merged?.number ?? null; const url = item.pr?.url ?? item.merged?.url ?? null; + const isBeadOnly = !item.pr && !item.merged && !!item.bead; + const isUnassigned = item.familiar === "unassigned"; + const checkStatus = item.pr?.checkStatus ?? null; const [composing, setComposing] = useState(false); const [draft, setDraft] = useState(""); + const [noteMode, setNoteMode] = useState<"write" | "preview">("write"); const noteInputRef = useRef(null); const noteButtonRef = useRef(null); const isCleanup = item.lane === "post-merge-cleanup"; @@ -262,11 +483,11 @@ export function WorkQueueCard({ const closeBlocked = isCleanup && !hasEvidence; // Keyboard/AT flow for the inline composer: focus lands in the textarea when - // it opens, and returns to the Note toggle whenever it closes (submit, - // Cancel, Escape) — otherwise focus drops to on unmount. + // it opens (Write mode), and returns to the Note toggle whenever it closes + // (submit, Cancel, Escape) — otherwise focus drops to on unmount. useEffect(() => { - if (composing) noteInputRef.current?.focus(); - }, [composing]); + if (composing && noteMode === "write") noteInputRef.current?.focus(); + }, [composing, noteMode]); const closeComposer = (opts?: { clearDraft?: boolean }) => { if (opts?.clearDraft) setDraft(""); @@ -280,177 +501,244 @@ export function WorkQueueCard({ if (ok) closeComposer({ clearDraft: true }); }; + const runMd = (kind: MdKind) => { + const el = noteInputRef.current; + if (!el) return; + const { next, caret } = applyMarkdown(draft, el.selectionStart, el.selectionEnd, kind); + setDraft(next); + requestAnimationFrame(() => { + const t = noteInputRef.current; + if (t) { + t.focus(); + t.setSelectionRange(caret, caret); + } + }); + }; + return ( -
    3. -
      -
      +
    4. +
      +
      {prNumber != null ? #{prNumber} : null} {onInspect ? ( ) : ( - {title} + {title} )}
      -
      - {familiarLabel} - {item.surface ? {item.surface} : null} - {beadId ? {beadId} : null} - {item.bead && !item.pr && !item.merged ? ( - P{item.bead.priority} - ) : null} - {item.pr ? ( - <> - - checks {item.pr.checkStatus ?? "unknown"} - - {item.pr.reviewDecision && item.pr.reviewDecision !== "UNKNOWN" ? ( - {item.pr.reviewDecision.toLowerCase().replace(/_/g, " ")} - ) : null} - {item.lane === "ready-to-merge" ? merge eligible : null} - - ) : null} - {item.stale ? stale : null} - {item.pr?.updatedAt ? ( - - updated {relativeTime(item.pr.updatedAt)} +
      + + + {familiarLabel} + + {beadId ? {beadId} : null} + {checkStatus ? ( + + + checks {checkStatus} ) : null} - {item.merged?.mergedAt ? ( - - merged {relativeTime(item.merged.mergedAt)} - - ) : null} - {!item.pr && !item.merged && item.bead?.updated_at ? ( - - updated {relativeTime(item.bead.updated_at)} - + {item.pr?.reviewDecision && item.pr.reviewDecision !== "UNKNOWN" ? ( + {item.pr.reviewDecision.toLowerCase().replace(/_/g, " ")} ) : null} + {item.lane === "ready-to-merge" ? merge eligible : null} + {item.stale ? stale : null} + + {isBeadOnly && item.bead ? ( + + P{item.bead.priority} + + ) : null} + {isBeadOnly && item.bead?.updated_at ? ( + <> + · + + updated {relativeTime(item.bead.updated_at)} + + + ) : null} + {item.pr?.updatedAt ? ( + + updated {relativeTime(item.pr.updatedAt)} + + ) : null} + {item.merged?.mergedAt ? ( + + merged {relativeTime(item.merged.mergedAt)} + + ) : null} +
      -
      +
      {url ? ( - + + ) : null} {beadId ? ( - + ) : null} {item.lane === "no-open-PR" && beadId ? ( <> - + {/* Split control: bare Claim assigns the connected user; the picker claims on a familiar's behalf instead (cave-p63a). */} {familiars.length > 0 ? ( - ({ value: f.id, label: f.display_name }))} - onChange={(id) => { - const familiar = familiars.find((f) => f.id === id); - if (familiar) onClaimFor(familiar); - }} - /> + ) : null} ) : null} {isCleanup && beadId ? ( - + ) : null}
      {closeBlocked && !composing ? ( -

      Add a handoff note to record verification before closing.

      +

      Add a handoff note to record verification before closing.

      ) : null} {composing && beadId ? (
      -