mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
2b3ff0e58f
commit
1417137162
13 files changed
+344
-17
No files matched your search
+5
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user