fix(coach): say when the attachment picker list is cut

The Coach's "Choose from documents" picker reads the newest fifty
documents and dropped the route's nextCursor, so with a longer vault an
older report seemed not to exist unless one already knew to search for
it. A cut list now ends with the same line the document picker shows:
how many are shown and that the search reaches the rest. The document
row moves into its own component, which takes AttachmentPicker under
the function-size cap.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:44:42 +01:00
1 parent 24cca975da
commit aacf06b7b3
4 files changed
+145 -48

No files matched your search

@@ -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": 338,
"total": 109885,
"count": 337,
"total": 109677,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -99,7 +99,6 @@
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
@@ -77,7 +77,7 @@ const ALLOWLIST: readonly Allowed[] = [
file: "components/insights/coach-panel/attachment-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
"Search-backed picker: the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
},
{
file: "components/documents/document-picker-sheet.tsx",
@@ -0,0 +1,71 @@
/**
* The Coach's "Choose from documents" picker reads one page of the vault, the
* newest fifty. With a longer vault it listed those and said nothing, so an
* older report seemed not to exist unless one already knew to search for it.
*
* ResponsiveSheet portals its body, which static markup does not render, so it
* is mocked down to a wrapper that always shows its children.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
// A cut page is a full one.
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
id: `d${i}`,
kind: "LAB_RESULT",
title: `Blood panel ${i}`,
filename: null,
hasContentIndex: true,
})),
nextCursor: page.nextCursor,
},
isPending: false,
isError: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AttachmentPicker } from "../attachment-picker";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AttachmentPicker
open
onOpenChange={() => undefined}
remainingSlots={3}
excludeIds={[]}
onConfirm={() => undefined}
/>
</I18nProvider>,
);
}
describe("AttachmentPicker", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="coach-attachment-picker-capped"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
const html = render();
expect(html).not.toContain('data-slot="coach-attachment-picker-capped"');
expect(
html.match(/data-slot="coach-attachment-picker-item"/g),
).toHaveLength(3);
});
});
@@ -45,6 +45,58 @@ export interface AttachmentPickerProps {
onConfirm: (ids: string[]) => void;
}
/** One document in the picker: a toggle, disabled until it is indexed. */
function PickerRow({
doc,
isSelected,
disabled,
onToggle,
}: {
doc: InboundDocumentDto;
isSelected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => doc.hasContentIndex && onToggle()}
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-input-focus 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>
{!doc.hasContentIndex ? (
<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>
);
}
export function AttachmentPicker({
open,
onOpenChange,
@@ -107,6 +159,8 @@ export function AttachmentPicker({
);
const capReached = selected.length >= remainingSlots;
// More match than one page: say so, and that the search reaches the rest.
const cut = Boolean(list.data?.nextCursor);
function toggle(id: string) {
setSelected((prev) => {
@@ -200,55 +254,28 @@ export function AttachmentPicker({
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{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-input-focus 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>
<PickerRow
key={doc.id}
doc={doc}
isSelected={isSelected}
disabled={!doc.hasContentIndex || (!isSelected && capReached)}
onToggle={() => toggle(doc.id)}
/>
);
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="coach-attachment-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(list.data?.documents.length ?? 0),
})}
</p>
) : null}
</div>
</ResponsiveSheet>
);