mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(coach): document chats live in the Coach view and history
Asking the Coach about a document opened a separate, half-finished chat panel that never appeared in Coach history. The bespoke drawer is gone; a document conversation now opens the real Coach view, appears in the Coach list with a document badge and a "Chatting about: <title>" note, and is read back through the same history rail as any other thread. The prompt-injection fence is unchanged and stays server-side. A document turn is still sent through the hardened fenced endpoint (no tools, no health snapshot, grounded in the document text only); the coach tool route continues to fetch with documentId null, so a document-scoped conversation can never be continued through the tool loop even from a hostile client. Only the surface moved into the Coach; the boundary did not. The relaxed conversation-list and detail filters drop only the documentId predicate — userId scoping is unchanged, so a document thread is still visible only to its owner. CoachConversationDTO gains documentId + documentTitle (server-derived, title/filename only); no schema change (documentId exists since 0230). OpenAPI regenerated.
This commit is contained in:
1 parent
57539283c8
commit
5b24ca0fa1
28 files changed
+605
-1112
No files matched your search
@@ -33,11 +33,16 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
// v1.27.33 — Coach detail is scoped to health threads (documentId null); a
|
||||
// document chat is read through its own document-scoped route, not here.
|
||||
const detail = await fetchConversationWithMessages(auth.user.id, id, {
|
||||
documentId: null,
|
||||
});
|
||||
// v1.28.51 (Documents R3, Design A) — the Coach detail READER now returns
|
||||
// both health and doc-scoped threads so `/coach?c=<id>` can display a document
|
||||
// conversation inside the real coach chrome. Dropping the `documentId: null`
|
||||
// opt removes the scope filter while `auth.user.id` stays narrowed, so a
|
||||
// caller can only ever read their OWN thread (a foreign id is still 404).
|
||||
// This reader only decrypts + returns persisted messages — no tool loop, no
|
||||
// snapshot — so surfacing a doc thread here does not reopen the injection
|
||||
// surface the fenced SEND path (documents route) guards. The DTO carries
|
||||
// `documentId` + `documentTitle` so the client picks the fenced send path.
|
||||
const detail = await fetchConversationWithMessages(auth.user.id, id);
|
||||
if (!detail) {
|
||||
return apiError("coach.conversation.notFound", 404);
|
||||
}
|
||||
|
||||
@@ -304,8 +304,15 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
let priorSummary: string | null = null;
|
||||
|
||||
if (conversationId) {
|
||||
// v1.27.33 — scope to Coach threads (documentId null); a document chat can
|
||||
// never be loaded through the Coach route.
|
||||
// v1.27.33 / v1.28.51 — the SEND path stays scoped to Coach threads
|
||||
// (`documentId: null`) DELIBERATELY, even though the read paths (rail list +
|
||||
// detail GET) were relaxed to surface doc-scoped threads in the Coach UI.
|
||||
// This is the load-bearing prompt-injection fence: a document chat runs its
|
||||
// tool-loop + health-snapshot NEVER — a doc turn is routed by the client to
|
||||
// the hardened `/api/documents/inbound/[id]/chat` endpoint. Should a
|
||||
// doc-scoped conversation id ever reach THIS tool route, the null-scope
|
||||
// fetch below returns nothing and the turn 404s rather than executing an
|
||||
// injected instruction against the coach's write tools. Do not relax.
|
||||
const existing = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
@@ -1437,9 +1444,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userId: auth.user.id,
|
||||
cursor,
|
||||
limit: Number.isFinite(limit) ? (limit as number) : undefined,
|
||||
// v1.27.33 — the Coach rail shows only health threads; document chats
|
||||
// (documentId set) live on the document sheet and are filtered out here.
|
||||
documentId: null,
|
||||
// v1.28.51 (Documents R3, Design A) — the rail now surfaces BOTH health
|
||||
// threads and doc-scoped threads (the DTO carries `documentId` +
|
||||
// `documentTitle` so the client badges the fenced ones). Omitting the
|
||||
// `documentId` key drops the filter entirely — a union of both scopes —
|
||||
// while `userId` stays narrowed from the session, so the relaxation never
|
||||
// widens ownership. Doc turns still POST to the hardened document endpoint,
|
||||
// never this route (see the `documentId: null` guard on the POST path).
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MessagesSquare, Search, Target, Trash2 } from "lucide-react";
|
||||
import { FileText, MessagesSquare, Search, Target, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -223,8 +223,16 @@ function CoachConversationsBody() {
|
||||
className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left"
|
||||
data-slot="coach-conversations-select"
|
||||
>
|
||||
<span className="block truncate font-medium">
|
||||
{c.title}
|
||||
<span className="flex min-w-0 items-center gap-1.5 font-medium">
|
||||
{/* v1.28.51 — badge doc-scoped threads (fenced
|
||||
endpoint) so they read as distinct in the list. */}
|
||||
{c.documentId ? (
|
||||
<FileText
|
||||
className="text-primary size-3.5 shrink-0"
|
||||
aria-label={t("insights.coach.docScope.badge")}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t)}
|
||||
|
||||
+12
-1
@@ -65,11 +65,19 @@ function CoachPageBody() {
|
||||
// defaults to the new-chat hero.
|
||||
const rawC = searchParams.get("c");
|
||||
const deepLinkedId = rawC && rawC !== "new" ? rawC : null;
|
||||
// v1.28.51 (Documents R3, Design A) — `?doc=<id>` seeds a fresh chat SCOPED
|
||||
// to a stored document (the vault detail sheet's "Ask the Coach" action). An
|
||||
// explicit `?c=<id>` thread wins over it (that thread carries its own scope).
|
||||
const rawDoc = searchParams.get("doc");
|
||||
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
|
||||
|
||||
// C1 exception — an unread coach-initiated message opens the most-recent
|
||||
// conversation (which holds that proactive turn). Only consulted when the
|
||||
// entry did not pin a specific thread or ask for a fresh chat.
|
||||
const exceptionEligible = deepLinkedId === null && rawC !== "new";
|
||||
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
|
||||
// resuming the most-recent thread on an unread nudge.
|
||||
const exceptionEligible =
|
||||
deepLinkedId === null && rawC !== "new" && seedDocumentId === null;
|
||||
const { data: nudge } = useQuery({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
queryFn: async (): Promise<CoachNudgeStatus> =>
|
||||
@@ -84,6 +92,9 @@ function CoachPageBody() {
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
initialConversationId={deepLinkedId}
|
||||
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
|
||||
// turn is created + sent through the hardened fenced document endpoint.
|
||||
initialDocumentId={seedDocumentId}
|
||||
// Default is the new-chat hero; only resume most-recent for the
|
||||
// unread coach-initiated exception.
|
||||
autoOpenMostRecent={hasUnreadCoachMessage}
|
||||
|
||||
Reference in new issue
Block a user