Spaces:
Sleeping
feat(upload-ux): lock canonical sequence — ack → wait (everything gated) → card → choice
Browse filesPer user directive (2026-05-27):
1. User uploads PDF.
2. Bot acknowledges "reading it now, ~30-60s". NO card, NO choice
prompt at this point.
3. EVERYTHING is gated during the wait — Send button, textarea,
PDF button, voice paths, and the send() function itself all
check `uploadStatus || extractionInFlight` and refuse to fire.
This eliminates the race where a typed/voice message during
the wait drops an unprompted reply into the chat and breaks
the staged flow.
4. LLM extraction completes (Gemini 2.5-flash with JSON-mode,
~30s for a 73-page PDF; falls back to NIM if Gemini missing).
5. Bot pushes the card-bearing message + inline scorecard card
with the fully-extracted, catalogued-grade data.
6. THEN bot pushes the choice prompt ("finish profile / dive
into PDF"). Order matters — the user sees the full picture
before being asked what to do next.
Defense-in-depth at the send() function entry (last-line guard),
so even a programmatic / future caller can't race the staging flow.
CHANGES
─────────────────────────────────────────────────────────────────────
- handleFile: choice prompt pushed AFTER card lands (not before),
AFTER the success branch and the failure-fallback branch alike.
- send(): early-return when uploadStatus || extractionInFlight.
- Send button: disabled when extractionInFlight (+ tooltip).
- Textarea: disabled when extractionInFlight.
- PDF button: disabled when extractionInFlight (prevents second
concurrent upload mid-wait).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/src/app/page.tsx +32 -14
|
@@ -779,6 +779,12 @@ export default function Page() {
|
|
| 779 |
// explicit early-return here so the guard survives any future change
|
| 780 |
// that bypasses the input-clear path.
|
| 781 |
if (!text.trim() || busy) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
// KI-204 (2026-05-15) — silence any prior bot TTS BEFORE submitting.
|
| 783 |
// User starting a new turn always takes precedence over the bot's
|
| 784 |
// current reply audio. Covers typed sends, voice barge-in, manual Send
|
|
@@ -1489,14 +1495,18 @@ export default function Page() {
|
|
| 1489 |
// for the rest of THIS conversation.
|
| 1490 |
const r = await uploadPolicy(f, sessionId);
|
| 1491 |
setUploadStatus(t("upload.success", { name: r.policy_name }));
|
| 1492 |
-
// Step 2 — ack (
|
|
|
|
|
|
|
| 1493 |
pushAssistant(t("upload.chat_ack_reading", { name: r.policy_name }));
|
| 1494 |
-
// Step 3 — choice prompt
|
| 1495 |
-
pushAssistant(t("upload.chat_choice"));
|
| 1496 |
// Refresh coverage so the uploaded doc shows up
|
| 1497 |
getCoverage().then(setCoverage).catch(() => {});
|
| 1498 |
|
| 1499 |
-
// Step 4 — poll extraction status
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1500 |
const POLL_INTERVAL_MS = 3000;
|
| 1501 |
const MAX_TRIES = 40; // 40 × 3s = 120s
|
| 1502 |
let landed = false;
|
|
@@ -1529,7 +1539,10 @@ export default function Page() {
|
|
| 1529 |
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
| 1530 |
}
|
| 1531 |
|
| 1532 |
-
// Step 5 — push the card-bearing assistant message
|
|
|
|
|
|
|
|
|
|
| 1533 |
if (landed) {
|
| 1534 |
pushAssistant(
|
| 1535 |
t("upload.chat_card_ready", { name: r.policy_name }),
|
|
@@ -1547,15 +1560,17 @@ export default function Page() {
|
|
| 1547 |
],
|
| 1548 |
},
|
| 1549 |
);
|
|
|
|
|
|
|
|
|
|
| 1550 |
} else {
|
| 1551 |
-
//
|
| 1552 |
-
//
|
| 1553 |
-
//
|
| 1554 |
-
// just tell the user the deep-analysis didn't complete and
|
| 1555 |
-
// they can ask questions about the PDF directly.
|
| 1556 |
pushAssistant(
|
| 1557 |
t("upload.chat_extraction_failed", { name: r.policy_name }),
|
| 1558 |
);
|
|
|
|
| 1559 |
}
|
| 1560 |
} catch (e: unknown) {
|
| 1561 |
const errMsg = e instanceof Error ? e.message : String(e);
|
|
@@ -1957,7 +1972,7 @@ export default function Page() {
|
|
| 1957 |
// overflow. overflowY starts hidden; the effect manages it.
|
| 1958 |
className="flex-1 resize-none scrollbar-thin bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px]"
|
| 1959 |
style={{ overflowY: "hidden" }}
|
| 1960 |
-
disabled={busy}
|
| 1961 |
/>
|
| 1962 |
{/* Hidden file input — the visible 📎 control drives it. PDF
|
| 1963 |
only; the backend rejects non-PDF magic bytes anyway, but
|
|
@@ -1974,11 +1989,13 @@ export default function Page() {
|
|
| 1974 |
{/* Attach-PDF control. Same 44px tap height + radius language
|
| 1975 |
as the Send button (.btn-primary → 12px radius, h-11); a
|
| 1976 |
hairline-bordered secondary so it reads as a companion to
|
| 1977 |
-
Send, not a competing primary.
|
|
|
|
|
|
|
| 1978 |
<button
|
| 1979 |
type="button"
|
| 1980 |
onClick={() => fileInputRef.current?.click()}
|
| 1981 |
-
disabled={busy}
|
| 1982 |
aria-label={t("input.upload")}
|
| 1983 |
title={t("input.upload")}
|
| 1984 |
className="shrink-0 h-11 px-3.5 rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:border-[var(--primary)] transition-colors flex items-center gap-1.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@@ -1989,8 +2006,9 @@ export default function Page() {
|
|
| 1989 |
<button
|
| 1990 |
type="button"
|
| 1991 |
onClick={() => send(input)}
|
| 1992 |
-
disabled={busy || !input.trim()}
|
| 1993 |
className="btn-primary shrink-0 h-11 px-5 text-sm"
|
|
|
|
| 1994 |
>
|
| 1995 |
Send
|
| 1996 |
</button>
|
|
|
|
| 779 |
// explicit early-return here so the guard survives any future change
|
| 780 |
// that bypasses the input-clear path.
|
| 781 |
if (!text.trim() || busy) return;
|
| 782 |
+
// ADR-044 defense-in-depth (2026-05-27) — fail-closed during the
|
| 783 |
+
// upload + extraction window. The Send button is also disabled
|
| 784 |
+
// via the `disabled` prop AND every voice path is gated, but
|
| 785 |
+
// this is the last-line guard so a programmatic / future path
|
| 786 |
+
// can never race the upload-staging flow.
|
| 787 |
+
if (uploadStatus || extractionInFlight) return;
|
| 788 |
// KI-204 (2026-05-15) — silence any prior bot TTS BEFORE submitting.
|
| 789 |
// User starting a new turn always takes precedence over the bot's
|
| 790 |
// current reply audio. Covers typed sends, voice barge-in, manual Send
|
|
|
|
| 1495 |
// for the rest of THIS conversation.
|
| 1496 |
const r = await uploadPolicy(f, sessionId);
|
| 1497 |
setUploadStatus(t("upload.success", { name: r.policy_name }));
|
| 1498 |
+
// Step 2 — ack ONLY (no card, no choice prompt yet). Per user
|
| 1499 |
+
// directive: nothing else surfaces in chat until the card is
|
| 1500 |
+
// fully populated and ready to render.
|
| 1501 |
pushAssistant(t("upload.chat_ack_reading", { name: r.policy_name }));
|
|
|
|
|
|
|
| 1502 |
// Refresh coverage so the uploaded doc shows up
|
| 1503 |
getCoverage().then(setCoverage).catch(() => {});
|
| 1504 |
|
| 1505 |
+
// Step 4 — poll extraction status until COMPLETE / FAILED / TIMEOUT.
|
| 1506 |
+
// Per user directive (2026-05-27): NO choice prompt, NO card,
|
| 1507 |
+
// NOTHING else fires during this wait — the user is asked to wait,
|
| 1508 |
+
// the Send button + voice paths are all gated by extractionInFlight,
|
| 1509 |
+
// and the chat only progresses once the card data is fully populated.
|
| 1510 |
const POLL_INTERVAL_MS = 3000;
|
| 1511 |
const MAX_TRIES = 40; // 40 × 3s = 120s
|
| 1512 |
let landed = false;
|
|
|
|
| 1539 |
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
| 1540 |
}
|
| 1541 |
|
| 1542 |
+
// Step 5 — push the card-bearing assistant message + THEN the
|
| 1543 |
+
// choice prompt. Order matters — the user explicitly directed
|
| 1544 |
+
// (2026-05-27): no choice prompt until the card has landed, so
|
| 1545 |
+
// they see the full picture before being asked what to do next.
|
| 1546 |
if (landed) {
|
| 1547 |
pushAssistant(
|
| 1548 |
t("upload.chat_card_ready", { name: r.policy_name }),
|
|
|
|
| 1560 |
],
|
| 1561 |
},
|
| 1562 |
);
|
| 1563 |
+
// Choice prompt fires AFTER the card, not before — that was the
|
| 1564 |
+
// race the previous flow exhibited.
|
| 1565 |
+
pushAssistant(t("upload.chat_choice"));
|
| 1566 |
} else {
|
| 1567 |
+
// Failure / timeout fallback: surface honestly + still defer the
|
| 1568 |
+
// choice prompt (we never want to ask the user to "dive into the
|
| 1569 |
+
// PDF" before they can SEE the analysis).
|
|
|
|
|
|
|
| 1570 |
pushAssistant(
|
| 1571 |
t("upload.chat_extraction_failed", { name: r.policy_name }),
|
| 1572 |
);
|
| 1573 |
+
pushAssistant(t("upload.chat_choice"));
|
| 1574 |
}
|
| 1575 |
} catch (e: unknown) {
|
| 1576 |
const errMsg = e instanceof Error ? e.message : String(e);
|
|
|
|
| 1972 |
// overflow. overflowY starts hidden; the effect manages it.
|
| 1973 |
className="flex-1 resize-none scrollbar-thin bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px]"
|
| 1974 |
style={{ overflowY: "hidden" }}
|
| 1975 |
+
disabled={busy || extractionInFlight}
|
| 1976 |
/>
|
| 1977 |
{/* Hidden file input — the visible 📎 control drives it. PDF
|
| 1978 |
only; the backend rejects non-PDF magic bytes anyway, but
|
|
|
|
| 1989 |
{/* Attach-PDF control. Same 44px tap height + radius language
|
| 1990 |
as the Send button (.btn-primary → 12px radius, h-11); a
|
| 1991 |
hairline-bordered secondary so it reads as a companion to
|
| 1992 |
+
Send, not a competing primary.
|
| 1993 |
+
ADR-044 (2026-05-27): also disabled while an extraction is
|
| 1994 |
+
in flight so the user can't queue a second upload mid-wait. */}
|
| 1995 |
<button
|
| 1996 |
type="button"
|
| 1997 |
onClick={() => fileInputRef.current?.click()}
|
| 1998 |
+
disabled={busy || extractionInFlight}
|
| 1999 |
aria-label={t("input.upload")}
|
| 2000 |
title={t("input.upload")}
|
| 2001 |
className="shrink-0 h-11 px-3.5 rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:border-[var(--primary)] transition-colors flex items-center gap-1.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
| 2006 |
<button
|
| 2007 |
type="button"
|
| 2008 |
onClick={() => send(input)}
|
| 2009 |
+
disabled={busy || !input.trim() || extractionInFlight}
|
| 2010 |
className="btn-primary shrink-0 h-11 px-5 text-sm"
|
| 2011 |
+
title={extractionInFlight ? "Reading the uploaded PDF — a moment, please." : undefined}
|
| 2012 |
>
|
| 2013 |
Send
|
| 2014 |
</button>
|