diff --git a/messages/de.json b/messages/de.json index 918bf3db3..18bf94738 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 Dokument angehängt", "documentsAttachedOther": "{count} Dokumente angehängt", "railBadge": "Dokument-Chat (abgeschottet)", - "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut." + "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", + "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet." } }, "relativeJustNow": "gerade eben", @@ -8327,7 +8328,9 @@ "openLabel": "{title} öffnen", "attachmentBadge": "Nur Download", "uploading": "Wird hochgeladen…", - "dismissFailed": "Fehlgeschlagenen Upload ausblenden" + "dismissFailed": "Fehlgeschlagenen Upload ausblenden", + "processing": "Wird verarbeitet…", + "ready": "Bereit" }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", diff --git a/messages/en.json b/messages/en.json index fe4d78afc..0c2dfff82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 document attached", "documentsAttachedOther": "{count} documents attached", "railBadge": "Fenced document chat", - "uploadError": "Upload failed. Please try again." + "uploadError": "Upload failed. Please try again.", + "notFoundError": "That document link is no longer valid — starting a normal chat instead." } }, "relativeJustNow": "just now", @@ -8327,7 +8328,9 @@ "openLabel": "Open {title}", "attachmentBadge": "Download only", "uploading": "Uploading…", - "dismissFailed": "Dismiss failed upload" + "dismissFailed": "Dismiss failed upload", + "processing": "Processing…", + "ready": "Ready" }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", diff --git a/messages/es.json b/messages/es.json index 0b958601a..0d6573ea1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 documento adjunto", "documentsAttachedOther": "{count} documentos adjuntos", "railBadge": "Chat de documento aislado", - "uploadError": "Error al subir. Inténtalo de nuevo." + "uploadError": "Error al subir. Inténtalo de nuevo.", + "notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal." } }, "relativeJustNow": "ahora mismo", @@ -8327,7 +8328,9 @@ "openLabel": "Abrir {title}", "attachmentBadge": "Solo descarga", "uploading": "Subiendo…", - "dismissFailed": "Descartar subida fallida" + "dismissFailed": "Descartar subida fallida", + "processing": "Procesando…", + "ready": "Listo" }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", diff --git a/messages/fr.json b/messages/fr.json index b0e846e42..ae6c28eb8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 document joint", "documentsAttachedOther": "{count} documents joints", "railBadge": "Chat de document cloisonné", - "uploadError": "Échec du téléversement. Veuillez réessayer." + "uploadError": "Échec du téléversement. Veuillez réessayer.", + "notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place." } }, "relativeJustNow": "à l’instant", @@ -8327,7 +8328,9 @@ "openLabel": "Ouvrir {title}", "attachmentBadge": "Téléchargement uniquement", "uploading": "Téléversement…", - "dismissFailed": "Ignorer le téléversement échoué" + "dismissFailed": "Ignorer le téléversement échoué", + "processing": "Traitement en cours…", + "ready": "Prêt" }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", diff --git a/messages/it.json b/messages/it.json index a349f00e3..9feb86234 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 documento allegato", "documentsAttachedOther": "{count} documenti allegati", "railBadge": "Chat documento isolata", - "uploadError": "Caricamento non riuscito. Riprova." + "uploadError": "Caricamento non riuscito. Riprova.", + "notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale." } }, "relativeJustNow": "proprio ora", @@ -8327,7 +8328,9 @@ "openLabel": "Apri {title}", "attachmentBadge": "Solo download", "uploading": "Caricamento…", - "dismissFailed": "Ignora caricamento non riuscito" + "dismissFailed": "Ignora caricamento non riuscito", + "processing": "Elaborazione…", + "ready": "Pronto" }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", diff --git a/messages/pl.json b/messages/pl.json index e10f195fe..9f51e4099 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 załączony dokument", "documentsAttachedOther": "Załączone dokumenty: {count}", "railBadge": "Odizolowany czat dokumentu", - "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie." + "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.", + "notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat." } }, "relativeJustNow": "przed chwilą", @@ -8327,7 +8328,9 @@ "openLabel": "Otwórz {title}", "attachmentBadge": "Tylko do pobrania", "uploading": "Przesyłanie…", - "dismissFailed": "Odrzuć nieudane przesyłanie" + "dismissFailed": "Odrzuć nieudane przesyłanie", + "processing": "Przetwarzanie…", + "ready": "Gotowe" }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 8734d2ab0..5cb9a5e0b 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -175,6 +175,57 @@ describe("", () => { expect(html).toContain("Untitled document"); expect(html).toContain("ring-2"); }); + + // v1.29.x — the fire-and-forget auto-index job is invisible otherwise; + // the card surfaces its own "Processing…" chip for a freshly uploaded, + // not-yet-indexed document. `isDocumentProcessing` (vault-utils) bounds + // this to the recent-upload window; `doc()`'s default `createdAt` is old, + // so the baseline render above never shows the chip. + it("shows the Processing… chip for a freshly uploaded, not-yet-indexed document", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="document-processing-badge"'); + expect(html).toContain("Processing"); + }); + + it("shows no Processing… chip once the document is indexed", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); + + it("shows no Processing… chip for an old, never-indexed document (outside the window)", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); }); describe("", () => { diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 47f72c1ea..3a3723e91 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -10,8 +10,12 @@ import { expandRangeSelection, formatBytes, formatMonthLabel, + hasProcessingDocument, + isDocumentProcessing, + isRecentlyUploaded, parseUploadResponse, parseVaultSearchParams, + RECENT_UPLOAD_WINDOW_MS, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, @@ -343,3 +347,67 @@ describe("resolveBulkShareDocuments", () => { expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS); }); }); + +// v1.29.x — the vault's "Processing…" / "Ready" chip + the poll-while- +// indexing refetch both key off the bounded recent-upload window rather than +// a bare `!hasContentIndex`, so an old / permanently-unindexed document never +// shows a stuck spinner and the list query never polls forever. +describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", () => { + const NOW = new Date("2026-03-11T08:10:00.000Z").getTime(); + + it("is recently-uploaded just inside the window, not right at/after it", () => { + const justInside = new Date( + NOW - RECENT_UPLOAD_WINDOW_MS + 1000, + ).toISOString(); + expect(isRecentlyUploaded(justInside, NOW)).toBe(true); + + const atTheEdge = new Date(NOW - RECENT_UPLOAD_WINDOW_MS).toISOString(); + expect(isRecentlyUploaded(atTheEdge, NOW)).toBe(false); + }); + + it("is never recently-uploaded for a future createdAt (clock skew defensiveness)", () => { + const future = new Date(NOW + 1000).toISOString(); + expect(isRecentlyUploaded(future, NOW)).toBe(false); + }); + + it("is processing only while unindexed AND inside the recent-upload window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: false }, NOW), + ).toBe(true); + // Indexed already — done, not processing, regardless of age. + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: true }, NOW), + ).toBe(false); + // Old and never indexed (unsupported format, content index disabled, + // …) — outside the window, so it does NOT read as "still processing". + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: old, hasContentIndex: false }, NOW), + ).toBe(false); + }); + + it("hasProcessingDocument is true when ANY loaded document is still processing", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: false }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(true); + }); + + it("hasProcessingDocument is false when every document is indexed or outside the window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: true }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(false); + }); + + it("hasProcessingDocument is false for an empty list", () => { + expect(hasProcessingDocument([], NOW)).toBe(false); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 462d7816e..ba9f94763 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -16,8 +16,8 @@ * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ -import { Download, X } from "lucide-react"; -import { useRef, useState } from "react"; +import { Check, Download, Loader2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -28,7 +28,14 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { documentDateKey, formatBytes } from "./vault-utils"; +import { + documentDateKey, + formatBytes, + isDocumentProcessing, +} from "./vault-utils"; + +/** How long the "Ready" chip stays up after a card's index lands. */ +const READY_FLASH_MS = 3000; /** Press-and-hold duration that flips a touch press into "select" mode. */ const LONG_PRESS_MS = 500; @@ -86,6 +93,27 @@ export function DocumentCard({ const showFilename = document.filename !== null && document.filename !== title; + // v1.29.x — processing/ready feedback for the fire-and-forget auto-index + // (+ optional AI summary) job that starts right after upload. `processing` + // is derived straight from the DTO (bounded to the recent-upload window, so + // an old/never-indexed document never shows a stuck spinner). `justIndexed` + // is local: it flashes once when a card that WAS processing on this page + // transitions to indexed (the poll in `documents-view.tsx` is what drives + // the transition), then clears on its own — a one-time "it landed" cue, + // not a permanent tint. + const processing = isDocumentProcessing(document); + const wasProcessingRef = useRef(processing); + const [justIndexed, setJustIndexed] = useState(false); + useEffect(() => { + if (wasProcessingRef.current && !processing) { + setJustIndexed(true); + const timer = setTimeout(() => setJustIndexed(false), READY_FLASH_MS); + wasProcessingRef.current = processing; + return () => clearTimeout(timer); + } + wasProcessingRef.current = processing; + }, [processing]); + return ( {document.conditionLinks.length > 0 || - document.servingClass === "attachment" ? ( + document.servingClass === "attachment" || + processing || + justIndexed ? (
{document.conditionLinks.map((link) => ( ) : null} + {processing ? ( + + + {t("documents.card.processing")} + + ) : justIndexed ? ( + + + {t("documents.card.ready")} + + ) : null}
) : null} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 717ad242e..d9d61b9ef 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -65,6 +65,7 @@ import { countActiveFilters, documentDateKey, expandRangeSelection, + hasProcessingDocument, parseVaultSearchParams, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, @@ -213,6 +214,18 @@ export function DocumentsView() { ), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, + // v1.29.x — the auto-index (+ optional AI summary) job that lands right + // after upload is fire-and-forget server-side; nothing pushes its + // completion to the client. Poll while any loaded document is still + // inside its recent-upload processing window (see `hasProcessingDocument` + // — bounded, so an old/permanently-unindexed document never keeps this + // polling forever) so the card's "Processing…" chip clears on its own + // once the index lands, without a manual refresh. + refetchInterval: (query) => { + const pages = query.state.data?.pages ?? []; + const docs = pages.flatMap((p) => p.documents); + return hasProcessingDocument(docs) ? 4000 : false; + }, }); const documents = useMemo( diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index bb15c532d..68629ff77 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -115,6 +115,49 @@ export function documentMonthKey(doc: InboundDocumentDto): string { return documentDateKey(doc).slice(0, 7); } +/** + * v1.29.x — bounded "still processing" window for a freshly uploaded + * document. `hasContentIndex` stays false until the fire-and-forget + * auto-index job (`enqueueDocumentIndex`, `src/app/api/documents/inbound/ + * route.ts`) lands — which for some documents (an unsupported format, + * content indexing disabled entirely) never happens. Gating the vault's + * "Processing…" chip AND the timeline's poll-while-processing refetch on + * this window means an old, permanently-unindexed document never shows a + * stuck spinner and the list never polls forever. + */ +export const RECENT_UPLOAD_WINDOW_MS = 3 * 60 * 1000; + +/** True while `createdAt` is still inside the recent-upload processing window. */ +export function isRecentlyUploaded( + createdAt: string, + now: number = Date.now(), +): boolean { + const ageMs = now - new Date(createdAt).getTime(); + return ageMs >= 0 && ageMs < RECENT_UPLOAD_WINDOW_MS; +} + +/** + * True while a recently-uploaded document's content index has not landed + * yet — the vault card's "Processing…" chip condition. + */ +export function isDocumentProcessing( + doc: Pick, + now: number = Date.now(), +): boolean { + return !doc.hasContentIndex && isRecentlyUploaded(doc.createdAt, now); +} + +/** + * Whether ANY document in the list is still inside its processing window — + * drives the vault list query's poll-while-indexing `refetchInterval`. + */ +export function hasProcessingDocument( + documents: InboundDocumentDto[], + now: number = Date.now(), +): boolean { + return documents.some((doc) => isDocumentProcessing(doc, now)); +} + /** * One flat item in the virtualized timeline: a month section label or one * grid row of up to `columns` documents. The virtualizer windows over this diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts index 5df370787..90959e2fb 100644 --- a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { launchScopeToCoachScope } from "../coach-conversation"; +import { + dropInvalidStagedAttachments, + launchScopeToCoachScope, +} from "../coach-conversation"; import { metricScopeFromExplainer, scopeSourceFromMetricKey, @@ -61,6 +64,30 @@ describe("metricScopeFromExplainer", () => { }); }); +describe("dropInvalidStagedAttachments (v1.29.x — bad ?doc= deep-link)", () => { + it("returns an empty list when nothing errored", () => { + expect( + dropInvalidStagedAttachments(["doc-1", "doc-2"], [false, false]), + ).toEqual([]); + }); + + it("returns the id whose detail fetch settled into an error (404)", () => { + expect(dropInvalidStagedAttachments(["bad-doc"], [true])).toEqual([ + "bad-doc", + ]); + }); + + it("only drops the errored id, keeping a still-resolving/valid sibling staged", () => { + expect( + dropInvalidStagedAttachments(["good-doc", "bad-doc"], [false, true]), + ).toEqual(["bad-doc"]); + }); + + it("returns an empty list for an empty staged set", () => { + expect(dropInvalidStagedAttachments([], [])).toEqual([]); + }); +}); + describe("scopeSourceFromMetricKey", () => { it("maps the model's snapshot-key vocabulary to a scope source", () => { expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 4eddfbb7b..629046bc0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -102,6 +102,24 @@ export function launchScopeToCoachScope( }; } +/** + * v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + * mistyped id, someone else's document) 404s the staged detail fetch + * forever, which used to trap every send behind a phantom "untitled" + * attachment that could never resolve (the fenced gate never clears). + * Pure so the drop decision is unit-testable without mounting the + * component: given the currently staged ids and which of their detail + * fetches settled into an error (index-aligned with `pendingAttachmentIds`, + * matching `useQueries`' `isError`), returns the ids that should fall out of + * staging. A bad link degrades to a normal chat instead of a dead end. + */ +export function dropInvalidStagedAttachments( + pendingAttachmentIds: string[], + isErrorByIndex: boolean[], +): string[] { + return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true); +} + /** * v1.21.4 (C2) — the localStorage key that records the seeded "worth a look" * opener as dismissed for a given LOCAL calendar day. Date-stamped so the @@ -358,6 +376,43 @@ export function CoachConversation({ return d?.title ?? d?.filename ?? t("documents.card.untitled"); }; + // v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + // mistyped id, someone else's document) 404s the detail fetch above + // forever. `refetchInterval` reads `false` once a query has no data, so the + // fetch never retries on its own — `isStagedIndexed` stayed permanently + // false and the fenced send-gate (`fenced && anyPendingUnindexed` below) + // trapped every message behind a phantom "untitled" attachment that could + // never resolve. Drop a staged id the moment its detail fetch settles into + // an error instead: the doc-scope clears, the banner disappears, and the + // surface degrades to a normal chat rather than a dead end. + // + // The drop itself runs in the render-phase-update pattern (mirrors + // `autoOpenResolved` below) rather than an effect: `react-hooks/set-state- + // in-effect` rejects a setState call inside an effect whose source is a + // query result. It is self-limiting without a ref guard: once an id drops + // out of `pendingAttachmentIds`, React's render-phase-update loop + // re-renders BEFORE committing, and on that re-render the id is no longer + // in `pendingAttachmentIds` at all, so `dropInvalidStagedAttachments` can + // never surface it again. The toast is the one genuine side effect and + // stays in its own effect (no setState inside it), keyed on the ids just + // dropped so it fires exactly once per distinct drop. + const invalidStagedIds = dropInvalidStagedAttachments( + pendingAttachmentIds, + stagedDetailResults.map((q) => q.isError === true), + ); + const [invalidToastKey, setInvalidToastKey] = useState(""); + if (invalidStagedIds.length > 0) { + setPendingAttachmentIds((prev) => + prev.filter((id) => !invalidStagedIds.includes(id)), + ); + const key = invalidStagedIds.join(","); + if (key !== invalidToastKey) setInvalidToastKey(key); + } + useEffect(() => { + if (!invalidToastKey) return; + toast.error(t("insights.coach.attach.notFoundError")); + }, [invalidToastKey, t]); + // 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.