From 6572b277ab7ee6287f82d65334fda5e56013b552 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:17:09 +0000 Subject: [PATCH] refactor(components): extract answer-thread turn from ClinicalDashboard (maturity X3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClinicalDashboard.tsx is the second maintainability-ratchet monolith. It is already heavily decomposed (72 clinical-dashboard/* modules), so this is an incremental ratchet: extract the self-contained answer-thread turn leaf — the `AnswerTurn` type, `maxVisiblePriorTurns`, and the `PriorAnswerTurnSurface` component (the collapsible read-only view of a previous answer turn) — into a new `src/components/clinical-dashboard/answer-thread-turn.tsx`. Verbatim move — the component body is unchanged and the container imports the three symbols exactly as before. ClinicalDashboard.tsx 4,271 -> 4,157 lines; the maintainability budget is ratcheted 4,272 -> 4,157 to lock in the reduction. A new module name (answer-thread-turn) avoids colliding with the unrelated pre-existing prior-answer-turn-surface.tsx. The residual ClinicalDashboard is a tightly-coupled orchestrator core with no large safe leaves left; further extractions are smaller incremental units. Verified: typecheck, lint, the full unit suite (3,047 passing; only the pre-existing container-only pdf-extraction-budget flake fails), knip (no orphaned exports), maintainability budgets, and the targeted "prior turns visible" Chromium spec that renders the moved component — all pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Jc1ZYHFjXjn6mE6U6riVU --- docs/maturity-backlog-workorders.md | 42 +++--- scripts/check-maintainability-budgets.mjs | 2 +- src/components/ClinicalDashboard.tsx | 126 +----------------- .../clinical-dashboard/answer-thread-turn.tsx | 126 ++++++++++++++++++ 4 files changed, 157 insertions(+), 139 deletions(-) create mode 100644 src/components/clinical-dashboard/answer-thread-turn.tsx diff --git a/docs/maturity-backlog-workorders.md b/docs/maturity-backlog-workorders.md index 601d66cfc..b19b81ba7 100644 --- a/docs/maturity-backlog-workorders.md +++ b/docs/maturity-backlog-workorders.md @@ -76,7 +76,7 @@ structural change, not a single mixed PR. ### X3 · Decompose the monoliths — `IN PROGRESS` - **Outcome:** shrink the three files the maintainability ratchet caps but never reduces: - `src/lib/rag/rag.ts` (5,018), `src/components/ClinicalDashboard.tsx` (4,271), + `src/lib/rag/rag.ts` (5,018), `src/components/ClinicalDashboard.tsx` (was 4,271, now 4,157), `src/components/DocumentViewer.tsx` (was 3,164, now 1,733). - **Progress (#997):** extracted the evidence-gate predicates from `rag.ts` into `src/lib/rag/rag-evidence-gates.ts` (rag.ts 5,147 → 5,018), pure moves behind the existing @@ -87,8 +87,14 @@ structural change, not a single mixed PR. behaviour-bearing `manual-tag-editor.tsx` (add/rename/delete manual labels), and `document-overview-landing.tsx`. The moves are verbatim (no logic changed); the container is now composition-focused — it retains the detail fetch, dynamic PDF loading, and state - orchestration (3,164 → 1,733, budget ratcheted to 1,733). `ClinicalDashboard.tsx` (4,271) - and `rag.ts` remain the open decomposition targets. + orchestration (3,164 → 1,733, budget ratcheted to 1,733). +- **Progress (`ClinicalDashboard.tsx`):** the dashboard was already heavily decomposed (72 + `clinical-dashboard/*` modules), so this is an incremental ratchet — extracted the + self-contained answer-thread turn leaf (`AnswerTurn` type, `maxVisiblePriorTurns`, and the + `PriorAnswerTurnSurface` component) into `clinical-dashboard/answer-thread-turn.tsx` as a + verbatim move (4,271 → 4,157, budget ratcheted to 4,157). The residual is a tightly-coupled + orchestrator core; further safe extractions are smaller, incremental units. `rag.ts` remains + the largest open target. - **Approach:** extract cohesive units behind the existing budgets; the components decompose into their `*/` sibling directories, and `rag.ts` is the natural seam now that X2 has landed. - **Risk:** HIGH (behavioural surface). One file per PR. @@ -198,18 +204,18 @@ collaborators join — `AGENTS.md` + the PR template already carry that load. ## Progress summary -| Item | Priority | Status | -| ------------------------------ | -------- | ------------------------------------------- | -| N1 Dependabot grouping | Now | **DONE** (#985) | -| N2 Dependency-report decision | Now | **DONE** (#986, enabled) | -| X1 Import-boundary linter | Next | **DONE** (#986; service-role rule dropped) | -| X2 `src/lib` rag extraction | Next | **DONE** (#994) | -| X3 Monolith decomposition | Next | IN PROGRESS (rag #997; DocumentViewer done) | -| X4 SAST-blocking on parser | Next | **DONE** (gate + policy check) | -| X5 ACL-migration consolidation | Next | PROVIDER-GATED (DB owner) | -| X6 Coverage floors | Next | OPEN | -| L1 Archive one-shot scripts | Later | OPEN (index shipped) | -| L2 Action-SHA uniformity | Later | **DONE** (#992) | -| L3 Single gate manifest | Later | **DONE** (#1002) | -| L4 Ledger rotation | Later | OPEN | -| L5 AI map / WCAG / RPO-RTO | Later | **DONE / SATISFIED** (#985) | +| Item | Priority | Status | +| ------------------------------ | -------- | ------------------------------------------------------- | +| N1 Dependabot grouping | Now | **DONE** (#985) | +| N2 Dependency-report decision | Now | **DONE** (#986, enabled) | +| X1 Import-boundary linter | Next | **DONE** (#986; service-role rule dropped) | +| X2 `src/lib` rag extraction | Next | **DONE** (#994) | +| X3 Monolith decomposition | Next | IN PROGRESS (rag #997; DocumentViewer + Dashboard done) | +| X4 SAST-blocking on parser | Next | **DONE** (gate + policy check) | +| X5 ACL-migration consolidation | Next | PROVIDER-GATED (DB owner) | +| X6 Coverage floors | Next | OPEN | +| L1 Archive one-shot scripts | Later | OPEN (index shipped) | +| L2 Action-SHA uniformity | Later | **DONE** (#992) | +| L3 Single gate manifest | Later | **DONE** (#1002) | +| L4 Ledger rotation | Later | OPEN | +| L5 AI map / WCAG / RPO-RTO | Later | **DONE / SATISFIED** (#985) | diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index 352b509b0..63b86064e 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ - ["src/components/ClinicalDashboard.tsx", 4272], + ["src/components/ClinicalDashboard.tsx", 4157], ["src/lib/rag/rag.ts", 5030], ["src/components/DocumentViewer.tsx", 1733], ["supabase/functions/indexing-v3-agent/index.ts", 2191], diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c6269c59e..12fdc1213 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5,7 +5,6 @@ import dynamic from "next/dynamic"; import { CircleAlert, BookOpen, - ChevronDown, Clock3, ExternalLink, FileImage, @@ -42,7 +41,6 @@ import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { appBackdrop, - answerSurface, cn, EmptyState, floatingControl, @@ -77,12 +75,7 @@ import { } from "@/components/clinical-dashboard/DocumentManagerPanel"; import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; -import { - isPreformattedGroundedAnswer, - NaturalLanguageAnswer, - ScopeAndGovernanceNotice, - UserQuestionBubble, -} from "@/components/clinical-dashboard/answer-content"; +import { isPreformattedGroundedAnswer, ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerProgressStepper, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { type AnswerProgressUpdate, @@ -253,6 +246,11 @@ import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { toolCatalogRecords } from "@/lib/tools-catalog"; import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; +import { + type AnswerTurn, + maxVisiblePriorTurns, + PriorAnswerTurnSurface, +} from "@/components/clinical-dashboard/answer-thread-turn"; const documentPageSize = 150; const activeIndexingPollFallbackMs = 5_000; @@ -335,118 +333,6 @@ type SearchResultModePayload = type SourceLibrarySearchMode = Extract; -/** - * A completed Q&A exchange kept on screen after a newer answer arrives, so - * Answer mode reads as a conversation thread instead of replacing each result. - */ -type AnswerTurn = { - id: string; - query: string; - answer: RagAnswer; - sources: SearchResult[]; -}; - -const maxVisiblePriorTurns = 10; - -/** - * Renders a collapsible, read-only view of a previous answer-thread turn with its question, answer, sources, and source-review notice. - * - * @param turn - The previous question and answer turn to display - * @param copied - Whether the turn's answer has been copied - * @param collapsed - Whether the answer content is collapsed - * @param onToggleCollapsed - Called when the answer visibility is toggled - * @param onCopy - Called with the answer text when copying is requested - */ -function PriorAnswerTurnSurface({ - turn, - copied, - collapsed, - onToggleCollapsed, - onCopy, -}: { - turn: AnswerTurn; - copied: boolean; - collapsed: boolean; - onToggleCollapsed: () => void; - onCopy: (text: string) => void; -}) { - const renderModel = useMemo( - () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), - [turn.answer, turn.sources], - ); - const turnPreformatted = isPreformattedGroundedAnswer(turn.answer); - const safeText = useMemo( - () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), - [turn.answer.answer, turnPreformatted], - ); - const sourceCount = - renderModel.primarySources.length || - turn.sources.length || - turn.answer.sources?.length || - turn.answer.citations.length; - const previewText = safeText || turn.answer.answer; - const needsSourceReview = - turn.answer.answerQualityTier === "source_only" || - turn.answer.grounded === false || - renderModel.trust === "low" || - renderModel.trust === "unsupported"; - - return ( -
-
- - - {collapsed ? ( -

{previewText}

- ) : ( - <> - onCopy(renderModel.copyText || previewText)} - /> - {needsSourceReview ? ( -
- - - Review source match. Verify cited - passages before relying on this previous answer. - -
- ) : null} - - )} -
-
- ); -} - type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; type IndexingMonitorFilter = "all" | "active" | "failed"; type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; diff --git a/src/components/clinical-dashboard/answer-thread-turn.tsx b/src/components/clinical-dashboard/answer-thread-turn.tsx new file mode 100644 index 000000000..f6c3325f9 --- /dev/null +++ b/src/components/clinical-dashboard/answer-thread-turn.tsx @@ -0,0 +1,126 @@ +// A completed Q&A exchange kept on screen after a newer answer arrives (the +// answer-thread turn) and its collapsible read-only surface. Extracted from +// ClinicalDashboard.tsx (maturity X3) as a pure move. +import { useMemo } from "react"; +import { ChevronDown, CircleAlert } from "lucide-react"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; +import { + isPreformattedGroundedAnswer, + NaturalLanguageAnswer, + UserQuestionBubble, +} from "@/components/clinical-dashboard/answer-content"; +import { sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text"; +import { answerSurface, cn, textMuted } from "@/components/ui-primitives"; +import type { RagAnswer, SearchResult } from "@/lib/types"; + +/** + * A completed Q&A exchange kept on screen after a newer answer arrives, so + * Answer mode reads as a conversation thread instead of replacing each result. + */ +export type AnswerTurn = { + id: string; + query: string; + answer: RagAnswer; + sources: SearchResult[]; +}; + +export const maxVisiblePriorTurns = 10; + +/** + * Renders a collapsible, read-only view of a previous answer-thread turn with its question, answer, sources, and source-review notice. + * + * @param turn - The previous question and answer turn to display + * @param copied - Whether the turn's answer has been copied + * @param collapsed - Whether the answer content is collapsed + * @param onToggleCollapsed - Called when the answer visibility is toggled + * @param onCopy - Called with the answer text when copying is requested + */ +export function PriorAnswerTurnSurface({ + turn, + copied, + collapsed, + onToggleCollapsed, + onCopy, +}: { + turn: AnswerTurn; + copied: boolean; + collapsed: boolean; + onToggleCollapsed: () => void; + onCopy: (text: string) => void; +}) { + const renderModel = useMemo( + () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), + [turn.answer, turn.sources], + ); + const turnPreformatted = isPreformattedGroundedAnswer(turn.answer); + const safeText = useMemo( + () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), + [turn.answer.answer, turnPreformatted], + ); + const sourceCount = + renderModel.primarySources.length || + turn.sources.length || + turn.answer.sources?.length || + turn.answer.citations.length; + const previewText = safeText || turn.answer.answer; + const needsSourceReview = + turn.answer.answerQualityTier === "source_only" || + turn.answer.grounded === false || + renderModel.trust === "low" || + renderModel.trust === "unsupported"; + + return ( +
+
+ + + {collapsed ? ( +

{previewText}

+ ) : ( + <> + onCopy(renderModel.copyText || previewText)} + /> + {needsSourceReview ? ( +
+ + + Review source match. Verify cited + passages before relying on this previous answer. + +
+ ) : null} + + )} +
+
+ ); +}