fix(daily-loop): stop bad ?doc= links from trapping the coach, add upload processing feedback

An invalid ?doc=<id> deep-link (a deleted document, a mistyped id) 404s
its detail fetch forever. The fenced send-gate never cleared, so every
message stayed blocked behind a phantom "untitled" attachment banner
that could never resolve — a bad link was a dead end instead of
degrading to a normal chat. Dropping a staged id the moment its detail
fetch settles into an error now clears the doc-scope, drops the banner,
and lets the coach send normally; a toast explains the link is gone.

Also gives the document vault processing feedback: the auto-index (and
optional AI summary) job that starts right after upload is
fire-and-forget server-side, so a freshly uploaded card sat with no
signal while it indexed. The vault list now polls while any recently
uploaded document is still unindexed (bounded to a few minutes so an
old, permanently-unindexed document never keeps it polling), and the
card shows a "Processing…"/"Ready" chip that clears on its own once the
index lands.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 00:37:24 +02:00
1 parent 2b3ff0e58f
commit 1417137162
13 files changed
+344 -17

No files matched your search

+5 -2
View File
@@ -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}.",
+5 -2
View File
@@ -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.",
+5 -2
View File
@@ -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}.",
+5 -2
View File
@@ -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}.",
+5 -2
View File
@@ -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}.",
+5 -2
View File
@@ -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}.",
@@ -175,6 +175,57 @@ describe("<DocumentCard>", () => {
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(
<DocumentCard
document={doc({
createdAt: new Date().toISOString(),
hasContentIndex: false,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
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(
<DocumentCard
document={doc({
createdAt: new Date().toISOString(),
hasContentIndex: true,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
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(
<DocumentCard
document={doc({ hasContentIndex: false })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-processing-badge"');
});
});
describe("<UploadStateCard>", () => {
@@ -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);
});
});
+56 -4
View File
@@ -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 (
<Card
data-slot="document-card"
@@ -163,7 +191,9 @@ export function DocumentCard({
/>
</div>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
document.servingClass === "attachment" ||
processing ||
justIndexed ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
@@ -182,6 +212,28 @@ export function DocumentCard({
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
{processing ? (
<Badge
variant="outline"
data-slot="document-processing-badge"
className="text-muted-foreground gap-1 text-xs font-normal"
>
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.card.processing")}
</Badge>
) : justIndexed ? (
<Badge
variant="outline"
data-slot="document-ready-badge"
className="text-success gap-1 text-xs font-normal"
>
<Check className="size-3" aria-hidden />
{t("documents.card.ready")}
</Badge>
) : null}
</div>
) : null}
</CardContent>
@@ -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(
+43
View File
@@ -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<InboundDocumentDto, "createdAt" | "hasContentIndex">,
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
@@ -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");
@@ -102,6 +102,24 @@ export function launchScopeToCoachScope(
};
}
/**
* v1.29.x — a stale/invalid `?doc=<id>` 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=<id>` 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.