diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 2e7c5b21a..6bbe2c074 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -1056,6 +1056,7 @@ const BlockResponsePayloadWire = type({ kind: "'question'", answer: "string", "optionIndex?": "number", + notifiedAt: "string | null", }), ); export type BlockResponsePayload = typeof BlockResponsePayloadWire.infer; diff --git a/packages/chat-ui/src/blocks/block-responses.ts b/packages/chat-ui/src/blocks/block-responses.ts index b8c015c0c..422ffe2c6 100644 --- a/packages/chat-ui/src/blocks/block-responses.ts +++ b/packages/chat-ui/src/blocks/block-responses.ts @@ -25,6 +25,13 @@ export type QuestionResponsePayload = { readonly kind: "question"; readonly answer: string; readonly optionIndex?: number; + /** + * ISO timestamp once the asking agent was notified, or `null` when + * the answer is on file but notify never landed. Omitted only in + * tests that are not exercising the retry path — treat missing as + * already notified. + */ + readonly notifiedAt?: string | null; }; export type BlockResponsePayload = diff --git a/packages/chat-ui/src/blocks/question-block.tsx b/packages/chat-ui/src/blocks/question-block.tsx index 689520847..c39131b46 100644 --- a/packages/chat-ui/src/blocks/question-block.tsx +++ b/packages/chat-ui/src/blocks/question-block.tsx @@ -6,9 +6,12 @@ // route additionally relays it into the workbench as this principal's own // message (see `packages/chat/src/routes.ts`) -- the card itself never // asserts a resolved answer; it always re-reads `own` from the server, -// same anti-spoof rule every other block card follows. +// same anti-spoof rule every other block card follows. A question `own` +// carries `notifiedAt`: null means the answer is on file but notify never +// landed, so the card keeps a retry (including after remount) rather than +// collapsing as if the agent had already been reached. -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import type { QuestionBlockData } from "@corbits/chat/blocks"; import { Check } from "@corbits/icons"; @@ -19,14 +22,60 @@ import type { BlockResponseQuery, } from "./block-responses"; -function ownAnswer( - query: BlockResponseQuery, -): { readonly answer: string; readonly optionIndex?: number } | null { +function ownAnswer(query: BlockResponseQuery): { + readonly answer: string; + readonly optionIndex?: number; + readonly notifiedAt?: string | null; +} | null { if (query.kind !== "ready" || query.own === null) return null; if (query.own.kind !== "question") return null; - return query.own.optionIndex !== undefined - ? { answer: query.own.answer, optionIndex: query.own.optionIndex } - : { answer: query.own.answer }; + return { + answer: query.own.answer, + ...(query.own.optionIndex !== undefined + ? { optionIndex: query.own.optionIndex } + : {}), + ...(query.own.notifiedAt !== undefined + ? { notifiedAt: query.own.notifiedAt } + : {}), + }; +} + +function notifyNeverLanded(answered: ReturnType): boolean { + return answered !== null && answered.notifiedAt === null; +} + +function answeredValue( + answered: NonNullable>, +): string { + return answered.optionIndex !== undefined + ? `${CHAT_STRINGS.optionLetter(answered.optionIndex)}. ${answered.answer}` + : answered.answer; +} + +function AnsweredSummary({ + answered, + notifyFailed, +}: { + readonly answered: NonNullable>; + readonly notifyFailed: boolean; +}) { + return ( +
+ {!notifyFailed && ( + + )} +
+

+ {CHAT_STRINGS.blockQuestionAnsweredLabel} +

+

+ {answeredValue(answered)} +

+
+
+ ); } function StaticOptions({ data }: { readonly data: QuestionBlockData }) { @@ -60,6 +109,7 @@ export function QuestionBlockView({ }) { const [query, setQuery] = useState({ kind: "loading" }); const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); const [error, setError] = useState(null); const [freeText, setFreeText] = useState(""); @@ -67,6 +117,7 @@ export function QuestionBlockView({ if (actions === undefined || messageId === undefined) return; let cancelled = false; setQuery({ kind: "loading" }); + setError(null); actions.getResponses(messageId, data.questionId).then((result) => { if (!cancelled) setQuery(result); }); @@ -90,17 +141,29 @@ export function QuestionBlockView({ function submitAnswer(answer: string, optionIndex?: number) { if (actions === undefined || messageId === undefined) return; + if (submitting || submittingRef.current) return; + submittingRef.current = true; setSubmitting(true); setError(null); actions .submitQuestion(messageId, data.questionId, answer, optionIndex) - .then((result) => { - if (result.kind !== "submitted") { - setError(CHAT_STRINGS.blockQuestionAnswerError); - } - return actions.getResponses(messageId, data.questionId).then(setQuery); + .then(async (result) => { + const next = await actions.getResponses(messageId, data.questionId); + setQuery(next); + if (result.kind === "submitted") return; + // Persist failure leaves no `own`; notify_failed leaves `own` with + // `notifiedAt: null`. The card derives retry from that GET, not from + // this submit result — the result only picks the error copy. + setError( + ownAnswer(next) !== null + ? CHAT_STRINGS.blockQuestionNotifyFailed + : CHAT_STRINGS.blockQuestionAnswerError, + ); }) - .finally(() => setSubmitting(false)); + .finally(() => { + submittingRef.current = false; + setSubmitting(false); + }); } function onFreeTextSubmit(event: FormEvent) { @@ -112,25 +175,36 @@ export function QuestionBlockView({ const loading = query.kind === "loading"; const answered = ownAnswer(query); + const notifyFailed = notifyNeverLanded(answered); if (answered !== null) { return ( -
- -
-

- {CHAT_STRINGS.blockQuestionAnsweredLabel} + + {notifyFailed && ( + <> +

+ {error ?? CHAT_STRINGS.blockQuestionNotifyFailed}

-

- {answered.optionIndex !== undefined - ? `${CHAT_STRINGS.optionLetter(answered.optionIndex)}. ${answered.answer}` - : answered.answer} -

-
-
+
+ +
+ + )}
); } diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 4a9790311..70237a9ff 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -216,6 +216,9 @@ export const CHAT_STRINGS = { blockQuestionSubmit: "Send", blockQuestionSubmitting: "Sending…", blockQuestionAnswerError: "Couldn't send your answer — try again.", + blockQuestionNotifyFailed: + "Your answer was saved, but the agent couldn't be notified — try again.", + blockQuestionRetry: "Try again", blockQuestionAnsweredLabel: "Your answer", blockConnectGithubPickHeadline: "Choose what gets reviewed", blockConnectGithubStepDone: "Done", diff --git a/packages/chat-ui/test/question-block.test.tsx b/packages/chat-ui/test/question-block.test.tsx index 04091ee50..e98e708fb 100644 --- a/packages/chat-ui/test/question-block.test.tsx +++ b/packages/chat-ui/test/question-block.test.tsx @@ -1,10 +1,13 @@ // DOM tests for the interview-question card: open state (lettered options, // optional free-text field), answered state (collapsed with a checkmark, -// controls disabled), and keyboard navigation over the option buttons -// (native tab order — no custom key handling needed since each option is -// a plain `