feat(coach): attach multiple documents to a conversation

Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.

The fence is re-established at both enforcement points, fail-closed:

- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
  fresh chat with staged attachments) to the new /api/insights/chat/fenced
  endpoint; the fenced body is strict and never carries scope, guided-question,
  or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
  fetched row still carries an attachment (flag/join drift), emitting a
  fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
  code path — the fenced pipeline — with no tools and no health snapshot; a
  module-graph test pins that the tool loop is unreachable from it.

The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.

Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.

Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 23:32:28 +02:00
1 parent d72f078764
commit 502783a3ea
45 files changed
+4541 -861

No files matched your search

@@ -62,6 +62,8 @@ describe("Coach chart rendering", () => {
createdAt: "2026-06-01T10:00:00.000Z",
updatedAt: "2026-06-01T10:00:00.000Z",
messageCount: 1,
fenced: false,
attachmentCount: 0,
messages: [
{
id: "m1",
@@ -42,6 +42,7 @@ const RAIL: CoachConversationsPage = {
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T09:00:00.000Z",
messageCount: 3,
fenced: false,
},
{
id: "older",
@@ -49,6 +50,7 @@ const RAIL: CoachConversationsPage = {
createdAt: "2026-06-18T08:00:00.000Z",
updatedAt: "2026-06-18T08:05:00.000Z",
messageCount: 2,
fenced: false,
},
],
nextCursor: null,
@@ -13,33 +13,36 @@ import { queryKeys } from "@/lib/query-keys";
import { CoachConversation } from "../coach-conversation";
/**
* v1.28.51 (Documents R3, Design A) — the doc-scope banner.
* v1.29.x (S7) — the fenced-conversation banner.
*
* When the Coach is scoped to a document (the `/coach?doc=<id>` hand-off from
* the vault, before any thread exists), the shared `<CoachConversation>` surface
* paints a "Document" badge + "Chatting about: <title>" and, when the document
* is not yet content-indexed, the calm "read it with AI first" hint. On a normal
* health thread the banner is absent. Replaces the deleted drawer's not-indexed /
* scope coverage at the component level; the fenced-send guarantee is pinned by
* `coach-send-target.test.ts`.
* When the Coach is fenced — here seeded via the `/coach?doc=<id>` hand-off,
* which stages ONE document as a pending first-turn attachment before any
* thread exists — the shared `<CoachConversation>` surface paints the
* `coach-doc-scope` banner: a paperclip + "N documents attached" count and the
* honest fencing line (the coach runs WITHOUT access to health data and reads
* only the attached documents). While a staged attachment is still being
* content-indexed the banner adds the "still being indexed" hint. On a normal
* health thread the banner is absent. The fenced-send routing guarantee itself
* is pinned by `coach-send-target.test.ts`.
*
* `pendingDocumentId` seeds from the `initialDocumentId` prop via a `useState`
* initializer, so the banner resolves in a single static-markup pass. The
* document detail (`/api/documents/inbound/<id>`) is seeded into the cache so the
* title + indexed status render without a network call.
* `pendingAttachmentIds` seeds from the `initialDocumentId` prop via a
* `useState` initializer, so the banner resolves in a single static-markup
* pass. The document detail (`/api/documents/inbound/<id>`) is seeded into the
* cache so the staged pill's title + indexed status render without a network
* call.
*/
const DOC_ID = "e2edocscopedoc0000000001";
const TITLE = "Chest X-ray report";
const NOT_INDEXED_HINT =
"Read this document with AI first in the vault so the Coach can answer about it.";
const FENCING_LINE = "without access to your health data";
const INDEXING_HINT = "still being indexed";
function makeClient(hasContentIndex: boolean): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// A fresh doc scope has no thread yet — keep the rail empty so the surface
// A fresh fenced chat has no thread yet — keep the rail empty so the surface
// stays on the new-chat hero and the banner is the thing under test.
client.setQueryData(queryKeys.coachConversations(), {
conversations: [],
@@ -63,29 +66,31 @@ function render(node: React.ReactNode, client: QueryClient): string {
);
}
describe("<CoachConversation> doc-scope banner (R3)", () => {
it("shows the badge + 'Chatting about' title for an indexed document scope", () => {
describe("<CoachConversation> fenced banner (S7)", () => {
it("shows the paperclip count + honest fencing line for a staged attachment", () => {
const html = render(
<CoachConversation surface="page" initialDocumentId={DOC_ID} />,
makeClient(true),
);
expect(html).toContain('data-slot="coach-doc-scope"');
expect(html).toContain("Document");
expect(html).toContain(`Chatting about: ${TITLE}`);
// Indexed → no "read it first" hint.
expect(html).not.toContain(NOT_INDEXED_HINT);
// Exactly one document staged → the singular count.
expect(html).toContain("1 document attached");
// The honest fencing line is always present on a fenced conversation.
expect(html).toContain(FENCING_LINE);
// Indexed → no "still being indexed" hint.
expect(html).not.toContain(INDEXING_HINT);
});
it("shows the calm not-indexed hint when the document has no content index", () => {
it("shows the still-indexing hint when the staged document has no content index", () => {
const html = render(
<CoachConversation surface="page" initialDocumentId={DOC_ID} />,
makeClient(false),
);
expect(html).toContain('data-slot="coach-doc-scope"');
expect(html).toContain(NOT_INDEXED_HINT);
expect(html).toContain(INDEXING_HINT);
});
it("paints no banner on a normal health thread (no document scope)", () => {
it("paints no banner on a normal health thread (no attachments, not fenced)", () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@@ -3,50 +3,76 @@ import { describe, it, expect } from "vitest";
import { resolveCoachSendTarget } from "../use-coach";
/**
* v1.28.51 (Documents R3, Design A) — the prompt-injection fence rests on ONE
* decision: a document-scoped turn must go to the hardened fenced document
* endpoint and NEVER to the full Coach tool route (`/api/insights/chat`, which
* injects the health snapshot + runs the write-tool loop). These tests pin that
* branch so a regression that routes untrusted document text through the tool
* loop fails loudly here.
* v1.29.x (S7) — the prompt-injection fence rests on ONE decision: a FENCED turn
* must go to the hardened `/api/insights/chat/fenced` endpoint and NEVER to the
* full Coach tool route (`/api/insights/chat`, which injects the health snapshot
* + runs the write-tool loop). A turn is fenced when the loaded conversation's
* server `fenced` flag is true OR the composer has staged first-turn attachments.
* These tests pin that branch so a regression that routes untrusted document text
* through the tool loop fails loudly here.
*/
describe("resolveCoachSendTarget", () => {
it("routes a doc-scoped turn to the fenced document endpoint, never the tool route", () => {
it("routes a fenced conversation (server flag) to the fenced endpoint, never the tool route", () => {
const target = resolveCoachSendTarget({
conversationId: "conv-1",
message: "What does this lab say?",
documentId: "doc-abc",
fenced: true,
// Coach-only fields that MUST NOT influence the fenced path.
scope: { sources: ["bp"] },
guidedQuestion: "how are you sleeping?",
prefill: "seed",
});
expect(target.url).toBe("/api/documents/inbound/doc-abc/chat");
// The acceptance criterion above all others: a doc turn never hits the
// coach tool route.
expect(target.url).not.toBe("/api/insights/chat");
expect(target.url).not.toContain("/api/insights/chat");
expect(target.url).toBe("/api/insights/chat/fenced");
expect(target.url).not.toContain("/api/insights/chat?");
expect(target.url.endsWith("/chat")).toBe(false);
const body = JSON.parse(target.body);
// Existing conversation → NO attachmentIds (attach-to-existing uses the
// attach endpoint), and none of the tool-mode fields.
expect(body).toEqual({
conversationId: "conv-1",
message: "What does this lab say?",
locale: undefined,
});
// The tool-driving fields are stripped — the fenced endpoint has no tools,
// no snapshot, no guided flow.
expect(body).not.toHaveProperty("scope");
expect(body).not.toHaveProperty("guidedQuestion");
expect(body).not.toHaveProperty("prefill");
expect(body).not.toHaveProperty("attachmentIds");
});
it("URL-encodes the document id in the fenced path", () => {
it("routes a NEW chat with staged attachments to the fenced endpoint, carrying attachmentIds", () => {
const target = resolveCoachSendTarget({
message: "hi",
documentId: "doc/../etc",
message: "summarise these",
pendingAttachmentIds: ["doc-a", "doc-b"],
});
expect(target.url).toBe("/api/documents/inbound/doc%2F..%2Fetc/chat");
expect(target.url).toBe("/api/insights/chat/fenced");
const body = JSON.parse(target.body);
expect(body.attachmentIds).toEqual(["doc-a", "doc-b"]);
expect(body).not.toHaveProperty("conversationId");
});
it("the fenced flag wins even without pending attachments", () => {
const target = resolveCoachSendTarget({
conversationId: "conv-9",
message: "hi",
fenced: true,
pendingAttachmentIds: [],
});
expect(target.url).toBe("/api/insights/chat/fenced");
});
it("an EXISTING conversation NEVER emits attachmentIds even if pending ids are set", () => {
// Attach-to-existing must go through the attach endpoint, not a first-turn
// payload — the function drops attachmentIds when a conversationId exists.
const target = resolveCoachSendTarget({
conversationId: "conv-2",
message: "hi",
fenced: true,
pendingAttachmentIds: ["doc-x"],
});
expect(target.url).toBe("/api/insights/chat/fenced");
const body = JSON.parse(target.body);
expect(body).not.toHaveProperty("attachmentIds");
});
it("routes a normal health turn to the Coach tool route", () => {
@@ -63,12 +89,13 @@ describe("resolveCoachSendTarget", () => {
expect(body.guidedQuestion).toBe("q");
});
it.each([undefined, null, ""])(
"treats %p documentId as a normal health turn (tool route)",
(documentId) => {
it.each([undefined, false])(
"treats fenced=%p with no pending attachments as a normal health turn (tool route)",
(fenced) => {
const target = resolveCoachSendTarget({
message: "hello",
documentId: documentId as string | null | undefined,
fenced: fenced as boolean | undefined,
pendingAttachmentIds: [],
});
expect(target.url).toBe("/api/insights/chat");
},
@@ -43,6 +43,7 @@ const samplePage: CoachConversationsPage = {
createdAt: "2026-05-10T08:00:00.000Z",
updatedAt: "2026-05-10T08:01:00.000Z",
messageCount: 2,
fenced: false,
},
{
id: "c2",
@@ -50,6 +51,7 @@ const samplePage: CoachConversationsPage = {
createdAt: "2026-05-09T14:00:00.000Z",
updatedAt: "2026-05-09T14:05:00.000Z",
messageCount: 4,
fenced: false,
},
],
nextCursor: null,
@@ -94,6 +94,8 @@ const baseConversation: CoachConversationDetailDTO = {
createdAt: "2026-05-10T09:00:00.000Z",
updatedAt: "2026-05-10T09:01:00.000Z",
messageCount: 2,
fenced: false,
attachmentCount: 0,
messages: [
{
id: "m1",
@@ -0,0 +1,259 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
/**
* v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault
* documents to a Coach conversation. Lists the user's live documents via the
* existing `GET /api/documents/inbound` list and lets the user multi-select up
* to the remaining attachment cap. Documents that are not yet content-indexed
* render disabled with a hint (the fenced endpoint can only answer over indexed
* text). Documents already attached / staged are excluded from the list.
*
* The dialog owns only its own local selection; on confirm it hands the chosen
* ids to the parent, which performs the attach (an existing conversation) or
* stages them as pending first-turn attachments (a not-yet-created chat).
*/
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export interface AttachmentPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** How many more documents may be attached (MAX_COACH_ATTACHMENTS − current). */
remainingSlots: number;
/** Document ids already attached / staged — hidden from the list. */
excludeIds: string[];
onConfirm: (ids: string[]) => void;
}
export function AttachmentPicker({
open,
onOpenChange,
remainingSlots,
excludeIds,
onConfirm,
}: AttachmentPickerProps) {
const { t } = useTranslations();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
// Reset the transient selection + search on close so a cancelled pass never
// leaks into the next open. Done in the close handler (not an effect) so no
// setState-in-effect cascade fires.
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
setSelected([]);
setSearchDraft("");
setQuery("");
}
onOpenChange(next);
},
[onOpenChange],
);
// 200 ms debounce, matching the vault's search feel.
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
};
const list = useQuery({
queryKey: queryKeys.inboundDocumentPicker(query),
enabled: open,
queryFn: () => {
const sp = new URLSearchParams();
if (query) sp.set("q", query);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", "50");
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const excludeSet = useMemo(() => new Set(excludeIds), [excludeIds]);
const documents = useMemo(
() => (list.data?.documents ?? []).filter((d) => !excludeSet.has(d.id)),
[list.data, excludeSet],
);
const capReached = selected.length >= remainingSlots;
function toggle(id: string) {
setSelected((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= remainingSlots) return prev; // honour the cap
return [...prev, id];
});
}
function handleConfirm() {
if (selected.length === 0) return;
onConfirm(selected);
handleOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
data-slot="coach-attachment-picker"
className="flex max-h-[85vh] flex-col sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>{t("insights.coach.attach.pickerTitle")}</DialogTitle>
<DialogDescription>
{t("insights.coach.attach.pickerDescription")}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("insights.coach.attach.pickerSearchPlaceholder")}
aria-label={t("insights.coach.attach.pickerSearchPlaceholder")}
data-slot="coach-attachment-picker-search"
className="pl-9"
/>
</div>
<p
data-slot="coach-attachment-picker-cap"
className="text-muted-foreground text-xs"
>
{t("insights.coach.attach.pickerCap", { max: remainingSlots })}
</p>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
) : (
<ul className="space-y-1.5">
{documents.map((doc) => {
const isSelected = selected.includes(doc.id);
const selectable = doc.hasContentIndex;
const disabled = !selectable || (!isSelected && capReached);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
data-slot="coach-attachment-picker-cancel"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={selected.length === 0}
data-slot="coach-attachment-picker-confirm"
>
{t("insights.coach.attach.pickerConfirm", {
count: selected.length,
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,96 @@
"use client";
import { FileText, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.29.x (S7) — the attachment pills row that sits above the Coach composer on
* a FENCED conversation. Each pill names one attached document (its resolved
* title, truncated) with a remove control. A freshly uploaded document that is
* still being content-indexed renders in an `indexing` state (spinner + a muted
* "Indexing…" tag); the parent blocks send while any pill is indexing because
* the fenced endpoint 422s on an un-indexed attachment.
*
* Purely presentational — the parent (`<CoachConversation>`) owns whether a
* remove mutates local pending state (a not-yet-created chat) or calls the
* detach endpoint (an existing conversation).
*/
export interface AttachmentPillItem {
documentId: string;
title: string;
/** True while the document's content index is still being built. */
indexing?: boolean;
}
export interface AttachmentPillsProps {
items: AttachmentPillItem[];
onRemove: (documentId: string) => void;
/** Disables every remove control (e.g. while a turn streams). */
disabled?: boolean;
className?: string;
}
export function AttachmentPills({
items,
onRemove,
disabled = false,
className,
}: AttachmentPillsProps) {
const { t } = useTranslations();
if (items.length === 0) return null;
return (
<div
data-slot="coach-attachment-pills"
className={cn("flex flex-wrap items-center gap-1.5", className)}
>
{items.map((item) => (
<span
key={item.documentId}
data-slot="coach-attachment-pill"
data-indexing={item.indexing ? "true" : undefined}
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"inline-flex max-w-[15rem] items-center gap-1.5 rounded-full border py-0.5 pr-0.5 pl-2.5 text-xs",
)}
>
{item.indexing ? (
<Loader2
className="text-muted-foreground size-3 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<FileText
className="text-primary size-3 shrink-0"
aria-hidden="true"
/>
)}
<span className="truncate font-medium">{item.title}</span>
{item.indexing ? (
<span className="text-muted-foreground shrink-0">
{t("insights.coach.attach.indexing")}
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
onClick={() => onRemove(item.documentId)}
data-slot="coach-attachment-pill-remove"
aria-label={t("insights.coach.attach.pillRemove", {
title: item.title,
})}
title={t("insights.coach.attach.pillRemove", { title: item.title })}
className="text-muted-foreground hover:text-foreground size-5 shrink-0 rounded-full"
>
<X className="size-3" aria-hidden="true" />
</Button>
</span>
))}
</div>
);
}
@@ -3,19 +3,27 @@
import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText, Plus, Settings, Sparkles } from "lucide-react";
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Paperclip, Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { parseUploadResponse } from "@/components/documents/vault-utils";
import {
metricScopeLabelFallback,
scopeSourceMetricLabelKey,
@@ -40,11 +48,15 @@ import { MessageThread, type InterleavedThreadItem } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
import { AttachmentPicker } from "./attachment-picker";
import { useResettableValue } from "./use-resettable-value";
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
import {
useAttachCoachDocument,
useCoachConversation,
useCoachConversations,
useDetachCoachDocument,
useSendCoachMessage,
} from "./use-coach";
@@ -193,12 +205,13 @@ export interface CoachConversationProps {
*/
autoOpenMostRecent?: boolean;
/**
* v1.28.51 (Documents R3, Design A) — seed a fresh chat SCOPED to a stored
* document. The `/coach?doc=<id>` deep-link (the detail sheet's "Ask the
* Coach" action) passes it so the first turn is created + sent through the
* HARDENED fenced document endpoint, and the scope badge + not-indexed hint
* render before anything is typed. Once a thread exists the scope is read
* authoritatively from the loaded conversation's `documentId` instead.
* v1.28.51 (Documents R3) / v1.29.x (S7) — seed a fresh chat with ONE stored
* document attached. The `/coach?doc=<id>` deep-link (the detail sheet's "Ask
* the Coach" action) passes it so the first turn is created + sent FENCED
* (through the hardened `/api/insights/chat/fenced` endpoint) with that
* document staged. Seeds the local `pendingAttachmentIds` for a not-yet-created
* chat; once a thread exists the attachment set is read authoritatively from
* the loaded conversation's `attachments` + sticky `fenced` flag instead.
*/
initialDocumentId?: string | null;
/**
@@ -280,18 +293,24 @@ export function CoachConversation({
const { data: conversation } = useCoachConversation(currentConversationId);
// v1.28.51 (Documents R3, Design A) — document scope. `pendingDocumentId`
// seeds a fresh, not-yet-created chat from the `?doc=<id>` deep-link; once a
// thread exists the loaded conversation's own `documentId` is authoritative.
// `activeDocumentId` is what drives the send-path branch (fenced document
// endpoint vs. Coach tool route) and the scope badge. Cleared whenever the
// user starts a new chat or switches threads, so a stale doc scope can never
// leak onto a health thread.
const [pendingDocumentId, setPendingDocumentId] = useState<string | null>(
initialDocumentId ?? null,
// v1.29.x (S7) — multi-document attachments. `pendingAttachmentIds` holds the
// documents STAGED locally in the composer:
// • a not-yet-created chat → the full set to send FENCED on the first turn;
// • an existing conversation → uploads still being indexed, which auto-attach
// via the attach endpoint the moment their content index lands.
// Seeds from the `/coach?doc=<id>` deep-link (a single document). Cleared on a
// new chat / thread switch so a stale attachment can never leak onto a health
// thread.
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>(
initialDocumentId ? [initialDocumentId] : [],
);
const activeDocumentId =
conversation?.documentId ?? pendingDocumentId ?? null;
const [pickerOpen, setPickerOpen] = useState(false);
// Whether the document-attach affordance is available at all (module-gated).
const attachEnabled = useModuleEnabled("inboundDocuments");
const attachDoc = useAttachCoachDocument(currentConversationId ?? "");
const detachDoc = useDetachCoachDocument(currentConversationId ?? "");
const send = useSendCoachMessage({
onDone: (resolvedId) => {
@@ -299,27 +318,175 @@ export function CoachConversation({
},
});
// Resolve the document's badge title + indexed status. `documentTitle` on the
// loaded conversation covers a persisted thread; the detail fetch covers the
// fresh `?doc=` case (no thread yet) AND supplies `hasContentIndex` for the
// "read it with AI first" hint (the conversation DTO does not carry it).
const { data: docDetail } = useQuery({
queryKey: queryKeys.inboundDocument(activeDocumentId ?? "none"),
enabled: activeDocumentId !== null,
staleTime: 60_000,
queryFn: async () =>
apiGet<InboundDocumentDetailDto>(
`/api/documents/inbound/${activeDocumentId}`,
),
// v1.29.x (S7) — the SERVER-AUTHORITATIVE fence. For an existing conversation
// the sticky `fenced` flag on the loaded DTO decides; for a not-yet-created
// chat the presence of any staged attachment makes it fenced. A stale client
// can never mis-route an already-fenced conversation to the tool route because
// the existing-conversation branch reads the server flag, never local state.
const serverAttachments = conversation?.attachments ?? [];
const fenced =
currentConversationId !== null
? (conversation?.fenced ?? false)
: pendingAttachmentIds.length > 0;
// Resolve title + indexed status for every locally-staged id (the server
// attachments already carry their titles). Un-indexed docs poll until their
// content index lands (freshly-uploaded documents index in the background).
const stagedDetailResults = useQueries({
queries: pendingAttachmentIds.map((id) => ({
queryKey: queryKeys.inboundDocument(id),
queryFn: async () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
staleTime: 15_000,
refetchInterval: (query: {
state: { data?: InboundDocumentDetailDto };
}) =>
query.state.data && !query.state.data.hasContentIndex ? 2500 : false,
})),
});
const docScopeTitle =
conversation?.documentTitle ??
docDetail?.title ??
docDetail?.filename ??
t("documents.card.untitled");
// Only warn when we positively know the document is NOT indexed. While the
// detail is still loading we say nothing rather than flash a false hint.
const docScopeNotIndexed = docDetail ? !docDetail.hasContentIndex : false;
const stagedDetailById = new Map<
string,
InboundDocumentDetailDto | undefined
>();
pendingAttachmentIds.forEach((id, i) => {
stagedDetailById.set(id, stagedDetailResults[i]?.data);
});
const isStagedIndexed = (id: string): boolean =>
stagedDetailById.get(id)?.hasContentIndex === true;
const stagedTitle = (id: string): string => {
const d = stagedDetailById.get(id);
return d?.title ?? d?.filename ?? t("documents.card.untitled");
};
// The pills row above the composer. On a fresh chat every pill is a staged id;
// on an existing conversation the server attachments are the live pills and any
// staged uploads (still indexing) render alongside them.
const attachmentPills: AttachmentPillItem[] =
currentConversationId === null
? pendingAttachmentIds.map((id) => ({
documentId: id,
title: stagedTitle(id),
indexing: !isStagedIndexed(id),
}))
: [
...serverAttachments.map((a) => ({
documentId: a.documentId,
title: a.title ?? t("documents.card.untitled"),
indexing: false,
})),
...pendingAttachmentIds.map((id) => ({
documentId: id,
title: stagedTitle(id),
indexing: true,
})),
];
const anyPendingUnindexed = pendingAttachmentIds.some(
(id) => !isStagedIndexed(id),
);
// Cap bookkeeping: how many documents already count, and the free slots the
// picker / upload path may still fill.
const liveAttachmentCount =
currentConversationId === null
? pendingAttachmentIds.length
: serverAttachments.length + pendingAttachmentIds.length;
const remainingSlots = Math.max(
0,
MAX_COACH_ATTACHMENTS - liveAttachmentCount,
);
const excludeIds = [
...pendingAttachmentIds,
...serverAttachments.map((a) => a.documentId),
];
// v1.29.x (S7) — once a staged UPLOAD finishes indexing on an EXISTING
// conversation, promote it to a real attachment via the attach endpoint and
// drop it from the local staging set. The ref guards against a double-mutate
// while the request is in flight. The effect is keyed on a stable string of
// the ready-to-attach ids (recomputed in render) so it fires only when the
// set of indexed-and-staged documents actually changes.
const attachingRef = useRef<Set<string>>(new Set());
const readyToAttachIds =
currentConversationId !== null
? pendingAttachmentIds.filter((id) => isStagedIndexed(id))
: [];
const readyToAttachKey = readyToAttachIds.join(",");
useEffect(() => {
if (!readyToAttachKey) return;
for (const id of readyToAttachKey.split(",")) {
if (attachingRef.current.has(id)) continue;
attachingRef.current.add(id);
attachDoc.mutate(id, {
onSettled: () => {
attachingRef.current.delete(id);
setPendingAttachmentIds((prev) => prev.filter((x) => x !== id));
},
});
}
}, [readyToAttachKey, attachDoc]);
function handleRemoveAttachment(documentId: string) {
// A staged id (fresh chat, or an upload not yet attached) is local state.
if (pendingAttachmentIds.includes(documentId)) {
setPendingAttachmentIds((prev) => prev.filter((x) => x !== documentId));
return;
}
// An already-attached document on an existing conversation → detach endpoint.
if (currentConversationId !== null) {
detachDoc.mutate(documentId);
}
}
function handlePickFromVault(ids: string[]) {
if (currentConversationId === null) {
// Fresh chat: stage them for the first-turn attach (deduped + capped).
setPendingAttachmentIds((prev) =>
Array.from(new Set([...prev, ...ids])).slice(0, MAX_COACH_ATTACHMENTS),
);
return;
}
// Existing conversation: the picker only offers indexed documents, so attach
// each straight away — the server flips the sticky fenced flag.
for (const id of ids) attachDoc.mutate(id);
}
async function handleUploadNew(files: File[]) {
const toUpload = files.slice(0, remainingSlots);
for (const file of toUpload) {
const form = new FormData();
form.append("file", file);
let response: Response;
try {
response = await apiFetchRaw("/api/documents/inbound", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: form,
});
} catch {
toast.error(t("insights.coach.attach.uploadError"));
continue;
}
const parsed = parseUploadResponse(
response.status,
await response.text(),
);
if (!parsed.ok) {
toast.error(t("insights.coach.attach.uploadError"));
continue;
}
const doc = parsed.document;
// Reflect the new document in the vault + seed its detail so the pill
// resolves its title immediately (it indexes in the background).
queryClient.invalidateQueries({
queryKey: queryKeys.documents(),
});
queryClient.setQueryData(queryKeys.inboundDocument(doc.id), doc);
setPendingAttachmentIds((prev) =>
prev.includes(doc.id) ? prev : [...prev, doc.id],
);
}
}
// v1.18.11 (W11, #67) — auto-open the most-recent conversation when the
// surface mounts with no explicit selection. Only fetches the rail list
@@ -373,6 +540,11 @@ export function CoachConversation({
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
// v1.29.x (S7) — a fenced turn cannot go out while a staged attachment is
// still being indexed: the fenced endpoint 422s on an un-indexed document,
// so mirror that gate client-side (the composer shows the "still indexing"
// hint). The server edge is the real wall; this is the honest UI mirror.
if (fenced && anyPendingUnindexed) return;
// v1.16.5 — in the guided flow the composer message IS the answer
// to the current question. Mark it answered before the send so the
// question bubble anchors above the user's message, and dismiss the
@@ -395,15 +567,28 @@ export function CoachConversation({
: undefined;
// v1.16.6 — hand the question to the turn so the Coach reaction is
// contextual (the question bubble itself is never persisted).
// v1.28.51 — `documentId` routes a doc-scoped turn through the hardened
// fenced endpoint (see `resolveCoachSendTarget`); undefined on health chats.
// v1.29.x (S7) — `fenced` + `pendingAttachmentIds` route a fenced turn
// through the hardened `/api/insights/chat/fenced` endpoint (see
// `resolveCoachSendTarget`); the tool-mode fields (`scope`,
// `guidedQuestion`) are suppressed on a fenced turn — that endpoint has no
// snapshot and no guided flow. `pendingAttachmentIds` travels ONLY on a
// brand-new conversation (first-turn attach).
const wasFreshFenced = currentConversationId === null && fenced;
const resolvedId = await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
guidedQuestion: guidedQuestion ?? undefined,
scope,
documentId: activeDocumentId ?? undefined,
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
scope: fenced ? undefined : scope,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
});
// A fresh fenced chat's first turn created the conversation server-side and
// persisted the attachments; the server now owns them via the conversation
// detail, so drop the local staging set to avoid duplicate "indexing" pills.
if (wasFreshFenced && resolvedId) {
setPendingAttachmentIds([]);
}
if (guidedQuestion !== null && guidedIndex !== null) {
setPendingAdopt({
question: guidedQuestion,
@@ -457,6 +642,7 @@ export function CoachConversation({
function handleRegenerate(userText: string) {
const trimmed = userText.trim();
if (!trimmed || send.isStreaming) return;
if (fenced && anyPendingUnindexed) return;
const scope =
currentConversationId === null
? launchScopeToCoachScope(launchScope)
@@ -464,8 +650,10 @@ export function CoachConversation({
void send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
scope,
documentId: activeDocumentId ?? undefined,
scope: fenced ? undefined : scope,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
});
}
@@ -473,8 +661,9 @@ export function CoachConversation({
setCurrentConversationId(null);
setInputValue("");
setPendingAdopt(null);
// v1.28.51 — a new chat is always a health thread; drop any document scope.
setPendingDocumentId(null);
// v1.29.x (S7) — a new chat is always a health thread; drop any staged
// attachments so a fenced scope can never leak onto a fresh health thread.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
send.reset();
}
@@ -530,24 +719,45 @@ export function CoachConversation({
// it grows a leading `+` actions menu (new chat + open conversations) and
// a settings deep-link. The drawer keeps its own header for those, so the
// hub is page-only.
// v1.29.x (S7) — the composer with its attachment chrome: the pills row + the
// "still indexing" hint stack directly above the input, so they travel with
// the composer into BOTH the new-chat hero and the docked conversation view.
const composerNode = (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => router.push("/coach/conversations")}
/>
<div className="flex flex-col gap-2">
<AttachmentPills
items={attachmentPills}
onRemove={handleRemoveAttachment}
disabled={send.isStreaming}
/>
{fenced && anyPendingUnindexed ? (
<p
data-slot="coach-attach-indexing-hint"
className="text-muted-foreground px-1 text-xs"
>
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => router.push("/coach/conversations")}
attachEnabled={attachEnabled}
onPickFromVault={() => setPickerOpen(true)}
onUploadNew={handleUploadNew}
/>
</div>
);
// v1.18.9 — the centred new-chat hero replaces the cramped empty thread
@@ -645,7 +855,12 @@ export function CoachConversation({
: null;
let scopeHint: React.ReactNode = null;
if (a2Metric && a2MetricLabel) {
if (fenced) {
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
// honest fencing banner (below) is the only chrome the fenced hero shows.
scopeHint = null;
} else if (a2Metric && a2MetricLabel) {
// A2 — the launch prefill IS the data-aware opener; fall back to the
// generic per-metric question when the launch carried no prefill.
const seedQuestion =
@@ -694,7 +909,7 @@ export function CoachConversation({
question has been answered. Self-removes after settle; v1.16.5
reports the outcome back into the guided machine for the closing
summary. */}
{pendingAdopt && !send.isStreaming ? (
{pendingAdopt && !send.isStreaming && !fenced ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
@@ -711,7 +926,7 @@ export function CoachConversation({
{/* v1.16.5 — guided clarifying-questions entry card (V2 of the
v1.16.0 chips). Offers the in-chat sequence while questions pend
and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
{guided.phase === "idle" && pendingQuestions.length > 0 && !fenced ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
@@ -733,28 +948,34 @@ export function CoachConversation({
</div>
);
// v1.28.51 (Documents R3, Design A) — the scope banner for a doc-scoped
// thread: a small "Document" badge + "Chatting about: <title>", plus the
// "read it with AI first" hint when the document is not yet indexed. Rendered
// in BOTH surfaces (page + drawer) above the thread so the fenced scope is
// always visible. Null on a normal health thread.
const docScopeBanner = activeDocumentId ? (
// v1.29.x (S7) — the fenced-conversation banner. The moment a conversation is
// fenced (server sticky flag, or ≥1 staged attachment on a fresh chat) this
// states plainly that the coach runs WITHOUT access to health data and reads
// ONLY the attached documents, and counts the attachments. Rendered in BOTH
// surfaces (page + drawer) above the thread so the fence is always visible.
// Null on a normal health thread.
const attachedCount = attachmentPills.length;
const docScopeBanner = fenced ? (
<div
data-slot="coach-doc-scope"
className="border-border/70 bg-muted/20 flex shrink-0 flex-col gap-1 border-b px-4 py-2 sm:px-6"
>
<div className="flex min-w-0 items-center gap-2 text-xs">
<span className="bg-primary/15 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium">
<FileText className="size-3" aria-hidden="true" />
{t("insights.coach.docScope.badge")}
</span>
<span className="text-muted-foreground truncate">
{t("insights.coach.docScope.chattingAbout", { title: docScopeTitle })}
<Paperclip className="size-3" aria-hidden="true" />
{attachedCount === 1
? t("insights.coach.attach.documentsAttachedOne")
: t("insights.coach.attach.documentsAttachedOther", {
count: attachedCount,
})}
</span>
</div>
{docScopeNotIndexed ? (
<p className="text-muted-foreground text-[11px] leading-snug">
{t("insights.coach.attach.fencedHint")}
</p>
{anyPendingUnindexed ? (
<p className="text-muted-foreground text-[11px] leading-snug">
{t("insights.coach.docScope.notIndexedHint")}
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
</div>
@@ -802,6 +1023,15 @@ export function CoachConversation({
</div>
</>
)}
{attachEnabled ? (
<AttachmentPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
remainingSlots={remainingSlots}
excludeIds={excludeIds}
onConfirm={handlePickFromVault}
/>
) : null}
</div>
);
}
@@ -913,9 +1143,10 @@ export function CoachConversation({
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
// v1.28.51 — the selected thread's own `documentId` is now
// authoritative; drop any pending `?doc=` seed so it can't leak.
setPendingDocumentId(null);
// v1.29.x (S7) — the selected thread's own `fenced` flag +
// `attachments` are now authoritative; drop any staged `?doc=`
// seed so a pending attachment can't leak across a thread switch.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
}}
/>
@@ -933,6 +1164,16 @@ export function CoachConversation({
/>
}
/>
{attachEnabled ? (
<AttachmentPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
remainingSlots={remainingSlots}
excludeIds={excludeIds}
onConfirm={handlePickFromVault}
/>
) : null}
</div>
);
}
@@ -11,14 +11,17 @@ import {
} from "react";
import Link from "next/link";
import {
FolderOpen,
Loader2,
MessagesSquare,
Mic,
Paperclip,
Plus,
Send,
Settings,
Square,
Target,
Upload,
} from "lucide-react";
import { toast } from "sonner";
@@ -120,6 +123,25 @@ export interface CoachInputProps {
* `+` actions menu. Required when `showHub` is set.
*/
onOpenHistory?: () => void;
/**
* v1.29.x (S7) — mount the document-attach trigger (a leading paperclip menu:
* "Choose from documents" + "Upload new document"). Set by the parent only
* when the `inboundDocuments` module is enabled for the user; hidden entirely
* otherwise. Separate from the `showHub` `+` actions menu — this affordance
* stages fenced document attachments, it does not manage conversations.
*/
attachEnabled?: boolean;
/**
* v1.29.x (S7) — open the vault picker dialog. The parent owns the dialog +
* the staged-attachment state. Required when `attachEnabled` is set.
*/
onPickFromVault?: () => void;
/**
* v1.29.x (S7) — the user chose files from the "Upload new document" item.
* The parent uploads them through the existing `/api/documents/inbound`
* pipeline and stages the resulting documents as indexing pills.
*/
onUploadNew?: (files: File[]) => void;
}
/**
@@ -238,9 +260,13 @@ export function CoachInput({
showHub = false,
onNewChat,
onOpenHistory,
attachEnabled = false,
onPickFromVault,
onUploadNew,
}: CoachInputProps) {
const { t, locale } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const attachFileInputRef = useRef<HTMLInputElement | null>(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
// parent flags this surface as the freshly-opened one. The empty
@@ -534,6 +560,65 @@ export function CoachInput({
</DropdownMenu>
);
// v1.29.x (S7) — the document-attach affordance: a leading paperclip menu
// ("Choose from documents" opens the vault picker; "Upload new document"
// opens the file input, reusing the existing `/api/documents/inbound`
// pipeline). Rendered only when the parent enables it (module-gated). It is a
// SEPARATE control from the `showHub` `+` actions menu — the two never merge.
const attachButton = attachEnabled ? (
<>
<input
ref={attachFileInputRef}
type="file"
multiple
className="hidden"
data-slot="coach-input-attach-file"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
// Reset so re-selecting the same file re-fires `change`.
event.target.value = "";
if (files.length > 0) onUploadNew?.(files);
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-attach"
aria-label={t("insights.coach.attach.trigger")}
title={t("insights.coach.attach.trigger")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Paperclip className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-56">
<DropdownMenuItem
data-slot="coach-input-attach-vault"
onSelect={() => onPickFromVault?.()}
>
<FolderOpen className="size-4" aria-hidden="true" />
{t("insights.coach.attach.menuChooseFromDocuments")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-attach-upload"
// Defer the input click a tick so the Radix menu finishes closing
// and returning focus before the native file dialog opens.
onSelect={() =>
setTimeout(() => attachFileInputRef.current?.click(), 0)
}
>
<Upload className="size-4" aria-hidden="true" />
{t("insights.coach.attach.menuUploadNew")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null;
return (
<form
data-slot="coach-input"
@@ -562,8 +647,10 @@ export function CoachInput({
"flex items-end gap-1.5 p-1.5",
)}
>
{/* Leading control. Page surface: the `+` actions menu. Drawer
surface: the dictation mic. */}
{/* Leading controls. The document-attach paperclip (module-gated)
leads, then the surface control: the `+` actions menu (page) or the
dictation mic (drawer). */}
{attachButton}
{showHub ? actionsButton : micButton}
<textarea
id={inputId}
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { FileText, MessagesSquare, Search, Trash2 } from "lucide-react";
import { MessagesSquare, Paperclip, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -162,14 +162,22 @@ export function HistoryRail({
data-slot="coach-history-select"
>
<span className="flex min-w-0 items-center gap-1.5 font-medium">
{/* v1.28.51 — a doc-scoped thread runs on the hardened
fenced endpoint; badge it so the two are visibly
distinct in the rail. */}
{c.documentId ? (
<FileText
className="text-primary size-3.5 shrink-0"
aria-label={t("insights.coach.docScope.badge")}
/>
{/* v1.29.x (S7) — a fenced thread runs on the hardened
fenced endpoint over its attached documents; badge it
with a paperclip (+ count when more than one) so it
reads as distinct in the rail. */}
{c.fenced ? (
<span
className="text-primary inline-flex shrink-0 items-center gap-0.5"
aria-label={t("insights.coach.attach.railBadge")}
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-[10px] font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
</span>
) : null}
<span className="truncate">{c.title}</span>
</span>
+100 -23
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CoachConversationAttachmentDTO,
CoachConversationDetailDTO,
CoachConversationDTO,
CoachConversationsPage,
@@ -14,7 +15,7 @@ import type {
CoachUsage,
} from "@/lib/ai/coach/types";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -127,6 +128,57 @@ export function useDeleteCoachConversation() {
});
}
/**
* v1.29.x (S7) — attach a stored document to an EXISTING fenced (or about-to-be-
* fenced) conversation. On success invalidates the conversation detail + rail so
* the pills + badge refetch from server truth. The server flips the sticky flag
* true (the one legal, privilege-reducing tool→fenced transition).
*/
export function useAttachCoachDocument(conversationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.coachAttachmentMutation(conversationId),
mutationFn: async (documentId: string): Promise<CoachAttachmentsResponse> =>
apiPost<CoachAttachmentsResponse>(
`/api/insights/chat/${conversationId}/attachments`,
{ documentId },
),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.coachConversation(conversationId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.coachConversations(),
});
},
});
}
/**
* v1.29.x (S7) — detach a document from a fenced conversation. The conversation
* stays fenced (sticky flag). Invalidates the detail + rail on success.
*/
export function useDetachCoachDocument(conversationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.coachAttachmentMutation(conversationId),
mutationFn: async (documentId: string): Promise<CoachAttachmentsResponse> =>
apiDelete<CoachAttachmentsResponse>(
`/api/insights/chat/${conversationId}/attachments/${encodeURIComponent(
documentId,
)}`,
),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.coachConversation(conversationId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.coachConversations(),
});
},
});
}
/**
* Pure SSE parser used by the streaming hook AND the unit tests.
*
@@ -268,42 +320,56 @@ export interface SendCoachMessageParams {
*/
guidedQuestion?: string;
/**
* v1.28.51 (Documents R3, Design A) — when set, this turn belongs to a
* DOCUMENT-scoped conversation. It is sent to the HARDENED fenced document
* endpoint (`/api/documents/inbound/<id>/chat`) — no tools, no health
* snapshot, fenced doc text — and NEVER to the full Coach tool route. This is
* the single branch that keeps untrusted document text off the write tools;
* see `resolveCoachSendTarget`.
* v1.29.x (S7) — SERVER-AUTHORITATIVE: the loaded conversation's sticky
* `documentScoped` flag. When true the turn is FENCED and routes to the
* hardened `/api/insights/chat/fenced` endpoint (no tools, no snapshot, fenced
* document text) — NEVER the coach tool route. A stale client cannot mis-route
* an already-fenced conversation to the tool route because this comes from the
* server DTO, not client-side attachment bookkeeping.
*/
documentId?: string | null;
fenced?: boolean;
/**
* v1.29.x (S7) — LOCAL state: documents staged in the composer for a NOT-yet-
* created conversation (first-turn attach). Empty / omitted otherwise. Their
* presence also forces the fenced route (a fresh chat born with attachments is
* fenced). Ignored once a conversation exists — attach-to-existing goes through
* `POST /api/insights/chat/{id}/attachments`.
*/
pendingAttachmentIds?: string[];
}
/**
* v1.28.51 (Documents R3, Design A) — the ONE branch point that decides which
* backend a Coach turn hits. When `documentId` is set the turn goes to the
* hardened, fenced document chat endpoint; otherwise the normal Coach route
* (tool loop + health snapshot). Extracted as a pure function so a unit test
* can prove a doc-scoped send never resolves to the tool route — the whole
* prompt-injection fence rests on this decision.
* v1.29.x (S7) — the ONE branch point that decides which backend a Coach turn
* hits. A FENCED turn (the conversation's server `fenced` flag OR staged
* first-turn attachments) goes to the hardened `/api/insights/chat/fenced`
* endpoint; otherwise the normal Coach route (tool loop + health snapshot).
* Extracted as a pure function so a unit test can prove a fenced send never
* resolves to the tool route — the whole prompt-injection fence rests on this.
*/
export function resolveCoachSendTarget(params: SendCoachMessageParams): {
url: string;
body: string;
} {
const documentId =
typeof params.documentId === "string" && params.documentId.length > 0
? params.documentId
: null;
if (documentId) {
// Fenced document path. Only the three fields the document route accepts —
// NOT `scope` / `guidedQuestion` / `prefill`, which drive the coach's
// snapshot + tool behaviour and have no place on the hardened endpoint.
const fenced =
params.fenced === true || (params.pendingAttachmentIds?.length ?? 0) > 0;
if (fenced) {
// Fenced path. The body carries ONLY the fields the fenced endpoint accepts
// — NOT `scope` / `guidedQuestion` / `prefill`, which drive the coach's
// snapshot + tool behaviour and have no place on the hardened endpoint (the
// server schema is `.strict()` and would 422 them). `attachmentIds` travels
// ONLY on a brand-new conversation (first-turn attach); attach-to-existing
// uses the dedicated attach endpoint, so it is omitted once an id exists.
const attachmentIds =
!params.conversationId && (params.pendingAttachmentIds?.length ?? 0) > 0
? params.pendingAttachmentIds
: undefined;
return {
url: `/api/documents/inbound/${encodeURIComponent(documentId)}/chat`,
url: "/api/insights/chat/fenced",
body: JSON.stringify({
conversationId: params.conversationId,
message: params.message,
locale: params.locale,
attachmentIds,
}),
};
}
@@ -320,6 +386,16 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
};
}
/**
* v1.29.x (S7) — the shape both attach + detach mutations return (and the fenced
* conversation detail carries): the refreshed live attachment set + the sticky
* fenced flag (always true after an attach; unchanged by a detach).
*/
export interface CoachAttachmentsResponse {
attachments: CoachConversationAttachmentDTO[];
fenced: boolean;
}
export interface UseSendCoachMessageOptions {
/**
* Fired with the resolved `conversationId` after the `done` frame.
@@ -642,6 +718,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// Re-exports for adjacent components that don't want to reach into
// `@/lib/ai/coach/types` directly.
export type {
CoachConversationAttachmentDTO,
CoachConversationDTO,
CoachConversationDetailDTO,
CoachProvenance,