diff --git a/src/__tests__/ai-capability-surface-guard.test.ts b/src/__tests__/ai-capability-surface-guard.test.ts index b82232e09..931f1066f 100644 --- a/src/__tests__/ai-capability-surface-guard.test.ts +++ b/src/__tests__/ai-capability-surface-guard.test.ts @@ -139,7 +139,7 @@ const AI_REQUEST_SITES: Record = { }, "components/documents/use-document-assist.ts": { capability: "documentAi", - gate: "components/documents/document-detail-sheet.tsx", + gate: "components/documents/detail/use-document-detail-ai.ts", }, }; diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 9f392e0d0..3960d69f7 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 115, - "total": 132921, + "count": 114, + "total": 131617, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -15,7 +15,6 @@ "components/admin/backups-section.tsx": 1462, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/documents/document-detail-sheet.tsx": 1304, "components/insights/coach-panel/chat-bubble.tsx": 888, "components/insights/coach-panel/coach-conversation.tsx": 1286, "components/insights/coach-panel/use-coach.ts": 1486, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 98248142f..817a4974c 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 401, - "total": 138066, + "total": 137456, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -121,7 +121,7 @@ "components/documents/document-ai-section.tsx :: DocumentAiSection": 262, "components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204, "components/documents/document-card.tsx :: DocumentCard": 354, - "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 935, + "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 252, "components/documents/document-facts-review.tsx :: FactRow": 262, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 91bc78a7e..3521f851e 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 360, - "total": 360, + "count": 359, + "total": 359, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -190,7 +190,6 @@ "components/day/__tests__/day-door-coverage-guard.test.ts": 1, "components/day/__tests__/day-not-here-guard.test.ts": 1, "components/documents/__tests__/document-coach-launch-wiring.test.ts": 1, - "components/documents/__tests__/document-detail-download.test.ts": 1, "components/documents/__tests__/documents-delete-confirm-guard.test.ts": 1, "components/forms/__tests__/use-seeded-form-dismissal.test.ts": 1, "components/i18n/__tests__/maintainership-banner.test.tsx": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e4168d834..78ad1d038 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2947, - "total": 10231, + "count": 2946, + "total": 10225, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -833,7 +833,6 @@ "components/documents/document-ai-transport.ts": 2, "components/documents/document-bulk-bar.tsx": 1, "components/documents/document-card.tsx": 2, - "components/documents/document-detail-sheet.tsx": 6, "components/documents/document-facts-review.tsx": 2, "components/documents/document-summary-block.tsx": 2, "components/documents/use-content-index.ts": 1, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 113134c70..dcbff7a92 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -598,8 +598,9 @@ const REGISTRY: DestructiveEntry[] = [ // ── Documents ─────────────────────────────────────────────────────────── { - file: "components/documents/document-detail-sheet.tsx", + file: "components/documents/detail/use-document-detail.ts", destroys: "one document in the vault", + triggers: ["components/documents/detail/detail-sections.tsx"], recovery: "restorable", confirm: [], unconfirmed: [ diff --git a/src/__tests__/helpers/document-detail-source.ts b/src/__tests__/helpers/document-detail-source.ts new file mode 100644 index 000000000..2ec0d1ff7 --- /dev/null +++ b/src/__tests__/helpers/document-detail-source.ts @@ -0,0 +1,20 @@ +/** + * The document detail sheet's source, for the checks that pin how it is + * wired: the sheet (`document-detail-sheet.tsx`) and the hooks and sections + * it is built from (`documents/detail/`). Read together, so a rule about the + * sheet holds wherever in those modules the code sits. The folder read has a + * floor and the sheet an anchor, so a rename fails instead of reading less. + */ +import { readSource, readSourceFolder } from "./source-files"; + +export function documentDetailSource(): string { + return [ + readSource( + "src/components/documents/document-detail-sheet.tsx", + "DocumentDetailSheet", + ), + ...readSourceFolder("src/components/documents/detail", { floor: 4 }).map( + ([, text]) => text, + ), + ].join("\n"); +} diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 25f3bfecf..b9d53de54 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -176,7 +176,12 @@ const PINNED_AFFORDANCES: Record< }, "src/components/dashboard/range-display.tsx": { "text-success": 2 }, "src/components/documents/document-card.tsx": { "text-success": 1 }, - "src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 }, + "src/components/documents/detail/use-document-detail-ai.ts": { + "toast.success": 1, + }, + "src/components/documents/detail/use-document-detail.ts": { + "toast.success": 1, + }, "src/components/documents/vault/use-vault-library.ts": { "toast.success": 1, }, diff --git a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts index 626fc38a0..1e1612aa6 100644 --- a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts +++ b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { documentDetailSource } from "@/__tests__/helpers/document-detail-source"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -15,10 +16,6 @@ import { join } from "node:path"; * stays connected end-to-end. */ const ROOT = join(__dirname, "../../../.."); -const DETAIL_SHEET = join( - ROOT, - "src/components/documents/document-detail-sheet.tsx", -); const MOUNT = join(ROOT, "src/components/insights/layout-coach-mount.tsx"); const DRAWER = join( ROOT, @@ -30,7 +27,7 @@ function load(path: string): string { } describe("document detail sheet — Ask the Coach opens the drawer", () => { - const src = load(DETAIL_SHEET); + const src = documentDetailSource(); it("launches the doc-scoped coach drawer via askCoach(..., doc.id)", () => { expect(src).toContain("useCoachLaunch"); diff --git a/src/components/documents/__tests__/document-detail-download.test.ts b/src/components/documents/__tests__/document-detail-download.test.ts index b59f64e1a..39b0138ae 100644 --- a/src/components/documents/__tests__/document-detail-download.test.ts +++ b/src/components/documents/__tests__/document-detail-download.test.ts @@ -21,13 +21,10 @@ * disabled state is real. A render could not see either. */ import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -const SHEET = readFileSync( - join(process.cwd(), "src/components/documents/document-detail-sheet.tsx"), - "utf8", -); +import { documentDetailSource } from "@/__tests__/helpers/document-detail-source"; + +const SHEET = documentDetailSource(); describe("the detail sheet's original-document transport", () => { it("has exactly one eager loader for the original", () => { diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts index 8bc66631d..edb6fef15 100644 --- a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -16,13 +16,15 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { documentDetailSource } from "@/__tests__/helpers/document-detail-source"; + const dir = join(process.cwd(), "src/components/documents"); // The page and the selection hook it takes its delete path from. const view = [ readFileSync(join(dir, "documents-view.tsx"), "utf8"), readFileSync(join(dir, "vault/use-vault-selection.ts"), "utf8"), ].join("\n"); -const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8"); +const sheet = documentDetailSource(); const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8"); describe("vault delete paths confirm before deleting", () => { diff --git a/src/components/documents/detail/detail-sections.tsx b/src/components/documents/detail/detail-sections.tsx new file mode 100644 index 000000000..c3d56a28a --- /dev/null +++ b/src/components/documents/detail/detail-sections.tsx @@ -0,0 +1,409 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + Check, + FlaskConical, + Pencil, + Plus, + Share2, + Sparkles, + Trash2, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants"; +import { formatBytes } from "@/lib/format/bytes"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { cn } from "@/lib/utils"; +import type { + InboundDocumentDetailDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; + +import { + DocumentDownloadButton, + DocumentOpenInTabButton, +} from "./original-file"; +import type { DocumentDetail } from "./use-document-detail"; + +/** + * The sheet's footer. Delete sits bottom-left as a quiet text button (the + * bottom-right slot reads as the confirm action, and a destructive control + * there is a footgun); Coach, Share, open in a new tab and Download keep the + * trailing edge, icon-only on phones so they never overflow at 360 px. + */ +export function DocumentDetailFooter({ + doc, + detail, + aiEnabled, + coachAvailable, + onOpenChange, +}: { + doc: InboundDocumentDetailDto; + detail: DocumentDetail; + aiEnabled: boolean; + coachAvailable: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + // The vault's "Ask the Coach" opens the fenced conversation scoped to this + // document in the shared side drawer. + const launch = useCoachLaunch(); + const { + canManageDocuments, + setConfirmDeleteOpen, + remove, + setShareOpen, + inlineSrc, + inlineFailed, + } = detail; + return ( +
+ {/* .x — deleting, sharing and re-filing a document are the + owner's alone. Inside somebody else's record the sheet reads + the document and offers the download, and the leading slot + simply has nothing in it. */} + {canManageDocuments ? ( + + ) : ( + + )} +
+ {/* .x — `launch` is the one entry point that rendered + without asking whether a drawer exists to open. It is null + inside somebody else's record (the shell mounts no drawer + there), and `/documents` is a shared-record destination, so + this is the Coach button a delegate actually meets. */} + {aiEnabled && coachAvailable && launch ? ( + // "Ask the Coach" opens the REAL + // coach conversation scoped to this document in the SIDE + // DRAWER (the maximize control there expands it to the full + // `/coach` page). Every doc turn still runs on the hardened + // fenced document endpoint (never the coach tool route) — only + // WHERE the conversation renders changed, not the send path. + // Close the detail sheet so the drawer owns the surface. + + ) : null} + {canManageDocuments && ( + + )} + {doc.servingClass === "inline" ? ( + + ) : null} + +
+
+ ); +} + +/** Title, kind, filing date and the condition links, each saving on commit. */ +export function DocumentDetailFields({ + doc, + detail, +}: { + doc: InboundDocumentDetailDto; + detail: DocumentDetail; +}) { + const { t } = useTranslations(); + const { + canManageDocuments, + editingTitle, + setEditingTitle, + titleDraft, + setTitleDraft, + commitTitle, + patch, + episodes, + toggleEpisode, + } = detail; + return ( + <> +
+ + {editingTitle ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === "Enter") commitTitle(); + if (e.key === "Escape") setEditingTitle(false); + }} + maxLength={200} + placeholder={t("documents.detail.titlePlaceholder")} + /> + ) : !canManageDocuments ? ( +

+ {doc.title ?? t("documents.detail.titlePlaceholder")} +

+ ) : ( + + )} +
+ +
+
+ + {/* Read-only rather than absent: the value IS the content + a delegate came to read, and a form field that shows it + without accepting a change is the honest rendering. */} + +
+
+ + + patch.mutate({ + documentDate: value === "" ? null : value, + }) + } + aria-label={t("documents.detail.dateLabel")} + /> +
+
+ +
+

+ {t("documents.detail.conditionsLabel")} +

+
+ {doc.conditionLinks.map((link) => ( + + {link.name} + + ))} + {doc.conditionLinks.length === 0 ? ( + + {t("documents.detail.noConditions")} + + ) : null} + {canManageDocuments && (episodes.data?.length ?? 0) > 0 ? ( + + + + + +
    + {episodes.data?.map((episode) => { + const linked = doc.conditionLinks.some( + (l) => l.episodeId === episode.id, + ); + return ( +
  • + +
  • + ); + })} +
+
+
+ ) : null} +
+
+ + ); +} + +/** + * The lab values read from this page, when Labs holds any, and the upload, + * filename and import provenance meta lines. + */ +export function DocumentDetailMeta({ + doc, + detail, +}: { + doc: InboundDocumentDetailDto; + detail: DocumentDetail; +}) { + const { t, locale } = useTranslations(); + const format = useFormatters(); + const { labsModuleEnabled, labFactCount, sourceSystemName } = detail; + return ( + <> + {labsModuleEnabled && labFactCount > 0 ? ( + +