rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
3dcbe9b
·
1 Parent(s): 7ef3ca3

feat(upload-ux): lock canonical sequence — ack → wait (everything gated) → card → choice

Browse files

Per 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>

Files changed (1) hide show
  1. frontend/src/app/page.tsx +32 -14
frontend/src/app/page.tsx CHANGED
@@ -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 (NO citations yet → no card rendered)
 
 
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
- // Step 6 fallback. We DON'T render a card on the heuristic
1552
- // stub (per user directive "lets generate the card inline
1553
- // ONLY after full data extraction"), so on timeout / failure
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>