mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Replace the collapsed bottom-of-card "Chat about this document" outline
button with a neutral, ghost Coach icon pinned to the top-right of the
document detail-sheet header, mirroring the Insights ask-coach affordance.
The icon uses the muted-foreground token only — never the primary/FAB
gradient — and opens a document-scoped chat drawer.
Present the chat in the Coach chat drawer chrome (right-side sheet on
desktop, bottom sheet on phones; same width caps; close-only, no maximize
or conversations rail) with a quiet "Chatting about: <document>" scope
badge. The drawer reuses the existing pure conversation body and the
existing client hooks, so it still posts /api/documents/inbound/[id]/chat
verbatim: no tools, no health snapshot, fenced document, inbound/replay
refusal, outbound screen, numeric grounding, consent + budget + per-user
rate bucket, indexed-only, plain-text render — the route is untouched.
Thread isolation is unchanged (the general Coach filters documentId: null;
the document route always scopes { documentId: id }).
Add a small reusable headerAction slot to ResponsiveSheet for the header
icon. Retire the DocumentChatPanel container and the AI-section chatSlot;
keep the pure DocumentChatConversation body and documentChatErrorKey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPyLteNeTWwEC154X7vLWc
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
|
|
export interface ResponsiveSheetProps {
|
|
/** Controlled open state. */
|
|
open: boolean;
|
|
/** Open-state setter forwarded to the underlying Sheet / Dialog root. */
|
|
onOpenChange: (open: boolean) => void;
|
|
/**
|
|
* Required accessible title. Rendered inside the sheet / dialog
|
|
* header so screen-readers always announce the surface, even when
|
|
* the visual header is empty.
|
|
*/
|
|
title: React.ReactNode;
|
|
/** Optional short description rendered beneath the title. */
|
|
description?: React.ReactNode;
|
|
/**
|
|
* Optional action rendered `shrink-0` at the trailing edge of the visible
|
|
* header row (§11 inline-action-row: title `min-w-0 flex-1`, action
|
|
* `shrink-0`). Use for a single quiet header control — e.g. the neutral
|
|
* Coach icon on the document detail sheet. Ignored when `hideHeader` is set
|
|
* (there is no visible header to hang it on). Sits left of the primitive's
|
|
* absolute close button, whose space the header already reserves.
|
|
*/
|
|
headerAction?: React.ReactNode;
|
|
/**
|
|
* Hide the visual header (title + description). The title remains
|
|
* mounted in an `sr-only` block so the accessible name is still
|
|
* announced. Use when the consumer renders its own header inside
|
|
* the body.
|
|
*/
|
|
hideHeader?: boolean;
|
|
/**
|
|
* Footer slot. Pass the action `<Button>`s directly (a fragment is
|
|
* fine) — never a wrapper `<div className="flex …">`. The two
|
|
* branches deliberately stack differently and both rely on owning
|
|
* the flex context: the Sheet branch pins a `flex-row justify-end`
|
|
* bar so Cancel/Save sit side by side above the keyboard, while the
|
|
* Dialog branch defers to `DialogFooter`'s `flex-col-reverse
|
|
* sm:flex-row` so narrow dialogs stack the primary action on top.
|
|
* A consumer-supplied wrapper div collapses both into a single flex
|
|
* child and breaks that responsive stacking. This split is a
|
|
* decision, not drift — don't unify the two branches.
|
|
*
|
|
* Both branches keep the footer reachable once the body scrolls:
|
|
* the Sheet branch sticky-pins it to the bottom of the bottom-sheet,
|
|
* and the Dialog branch holds it `shrink-0` below the scrolling body.
|
|
*/
|
|
footer?: React.ReactNode;
|
|
/** Class name applied to the content surface (sheet / dialog). */
|
|
className?: string;
|
|
/** Class name applied to the body wrapper. */
|
|
bodyClassName?: string;
|
|
/**
|
|
* Forward to the underlying Sheet/Dialog `showCloseButton`
|
|
* primitive prop. Defaults to `true`; pass `false` when the
|
|
* consumer renders its own close affordance inside the header.
|
|
*/
|
|
showCloseButton?: boolean;
|
|
/**
|
|
* Desktop (Dialog branch) content width. The bottom-sheet branch
|
|
* always spans the viewport, so this prop only affects the `md+`
|
|
* Dialog. Defaults to `"md"` (448 px) to preserve every existing
|
|
* consumer; pass `"4xl"` for the wide medication editor's
|
|
* two-column layout. The class is the only width utility applied —
|
|
* the prior `cn("sm:max-w-md", className)` ordering let a
|
|
* consumer-passed width silently collide with the hardcoded
|
|
* default, so the width now resolves from this single source.
|
|
*/
|
|
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl";
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const CONTENT_WIDTH_CLASS: Record<
|
|
NonNullable<ResponsiveSheetProps["contentWidth"]>,
|
|
string
|
|
> = {
|
|
md: "sm:max-w-md",
|
|
lg: "sm:max-w-lg",
|
|
"2xl": "sm:max-w-2xl",
|
|
"3xl": "sm:max-w-3xl",
|
|
"4xl": "sm:max-w-4xl",
|
|
};
|
|
|
|
/**
|
|
* v1.4.27 R3d MB1 — shared mount primitive that flips between a
|
|
* bottom-anchored `<Sheet>` on narrow viewports and the existing
|
|
* centred `<Dialog>` on tablet/desktop. Picks the breakpoint via
|
|
* the `useIsMobile()` hook (Tailwind `md` — 768 px).
|
|
*
|
|
* Both branches use the same layout contract: cap the surface
|
|
* height, scroll ONLY the body on overflow, and keep the footer
|
|
* reachable. The Sheet branch caps at `90dvh` and sticky-pins the
|
|
* footer so Save / Cancel stay reachable when the keyboard pushes
|
|
* the bottom of the sheet up. The Dialog branch caps at the
|
|
* primitive's `max-h-[calc(100dvh-2rem)]`, runs as a flex column,
|
|
* and pins its header + footer (`shrink-0`) so the body — not the
|
|
* whole grid — is what scrolls; the primary action never slides
|
|
* below the fold under long forms, browser zoom, or OS scaling.
|
|
*
|
|
* Consumers swap `<Dialog>` → `<ResponsiveSheet>` and feed the
|
|
* existing form markup through `children` / `footer`. The rest of
|
|
* the form's chrome is unchanged because both branches expose the
|
|
* same header / body / footer slot shape.
|
|
*/
|
|
export function ResponsiveSheet({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
headerAction,
|
|
hideHeader = false,
|
|
footer,
|
|
className,
|
|
bodyClassName,
|
|
showCloseButton = true,
|
|
contentWidth = "md",
|
|
children,
|
|
}: ResponsiveSheetProps) {
|
|
const isMobile = useIsMobile();
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent
|
|
side="bottom"
|
|
showCloseButton={showCloseButton}
|
|
data-slot="responsive-sheet-content"
|
|
data-variant="sheet"
|
|
className={cn(
|
|
// Cap at 90 dvh so the user sees a slice of the page
|
|
// behind the sheet (clear "this is a sheet, not a
|
|
// takeover") + the bottom-sheet rounded top edge that
|
|
// matches the iOS feel.
|
|
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
|
|
className,
|
|
)}
|
|
>
|
|
{hideHeader ? (
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>{title}</SheetTitle>
|
|
{description ? (
|
|
<SheetDescription>{description}</SheetDescription>
|
|
) : null}
|
|
</SheetHeader>
|
|
) : (
|
|
<SheetHeader
|
|
data-slot="responsive-sheet-header"
|
|
className={cn(
|
|
"border-border/70 gap-1.5 border-b p-4 pr-12",
|
|
headerAction && "flex-row items-start justify-between gap-3",
|
|
)}
|
|
>
|
|
{headerAction ? (
|
|
<>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
<SheetTitle className="text-base font-semibold">
|
|
{title}
|
|
</SheetTitle>
|
|
{description ? (
|
|
<SheetDescription className="text-muted-foreground text-xs">
|
|
{description}
|
|
</SheetDescription>
|
|
) : null}
|
|
</div>
|
|
<div className="shrink-0">{headerAction}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<SheetTitle className="text-base font-semibold">
|
|
{title}
|
|
</SheetTitle>
|
|
{description ? (
|
|
<SheetDescription className="text-muted-foreground text-xs">
|
|
{description}
|
|
</SheetDescription>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</SheetHeader>
|
|
)}
|
|
<div
|
|
data-slot="responsive-sheet-body"
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
|
bodyClassName,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<SheetFooter
|
|
data-slot="responsive-sheet-footer"
|
|
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
|
|
>
|
|
{footer}
|
|
</SheetFooter>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={showCloseButton}
|
|
data-slot="responsive-sheet-content"
|
|
data-variant="dialog"
|
|
// v1.21.1 — flip the desktop Dialog from the primitive's single
|
|
// `overflow-y-auto` grid (where header + body + footer all scroll
|
|
// together, so the primary CTA can slide below the fold once the
|
|
// form, a long locale string, browser zoom, or OS display scaling
|
|
// pushes the content past `max-h-[calc(100dvh-2rem)]`) to a flex
|
|
// column that scrolls ONLY the body. This brings the Dialog branch
|
|
// to parity with the Sheet branch, which already pins its footer.
|
|
// The header + footer stay shrink-0 so the body absorbs every
|
|
// height shortfall and the action row is always reachable without
|
|
// scrolling. `overflow-hidden` makes the body the lone scroll port.
|
|
className={cn(
|
|
"flex flex-col overflow-hidden",
|
|
CONTENT_WIDTH_CLASS[contentWidth],
|
|
className,
|
|
)}
|
|
>
|
|
{hideHeader ? (
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description ? (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
) : null}
|
|
</DialogHeader>
|
|
) : (
|
|
<DialogHeader
|
|
data-slot="responsive-sheet-header"
|
|
className={cn(
|
|
"shrink-0",
|
|
headerAction && "flex-row items-start justify-between gap-3 pr-9",
|
|
)}
|
|
>
|
|
{headerAction ? (
|
|
<>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description ? (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
) : null}
|
|
</div>
|
|
<div className="shrink-0">{headerAction}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description ? (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</DialogHeader>
|
|
)}
|
|
<div
|
|
data-slot="responsive-sheet-body"
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto",
|
|
bodyClassName,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<DialogFooter
|
|
data-slot="responsive-sheet-footer"
|
|
className="shrink-0"
|
|
>
|
|
{footer}
|
|
</DialogFooter>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|