-
-
Notifications
You must be signed in to change notification settings - Fork 40
DecisionBlock UX hardening take 2: keep error surfacing alive while adding in-flight disable + 409 refetch (supersedes PR #2452) #2455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| ### Fixed | ||
|
|
||
| - Disable option buttons and Submit button while a POST is in flight, preventing duplicate submissions that cause 409 errors | ||
| - Clear answerError at the start of each new submission attempt | ||
| - Distinguish refresh-failure from submit-failure: when POST succeeds but follow-up GET fails, do not show "Failed to answer" | ||
| - On 409 (someone else answered first), refetch the decision so the block flips to its answered state | ||
| - Reset answer and answerError state when block.decision_id changes |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| ### Fixed | ||
|
|
||
| - Restore error propagation from `answerDecision` so non-409 server failures (500, network errors, 4xx) surface the server-provided reason in the alert region instead of being swallowed | ||
| - Surface a fallback message when the post-409 refetch itself fails, rather than leaving the block pending with no feedback |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -500,6 +500,7 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| const [error, setError] = useState<string | null>(null); | ||
| const [answer, setAnswer] = useState(""); | ||
| const [answerError, setAnswerError] = useState<string | null>(null); | ||
| const [submitting, setSubmitting] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
|
|
@@ -524,12 +525,19 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| return () => { cancelled = true; }; | ||
| }, [block.decision_id]); | ||
|
|
||
| useEffect(() => { | ||
| setAnswer(""); | ||
| setAnswerError(null); | ||
| }, [block.decision_id]); | ||
|
|
||
| async function answerDecision( | ||
| value: string | string[], | ||
| otherValue?: string, | ||
| note?: string | ||
| ) { | ||
| setAnswerError(null); | ||
| if (!decision || decision.status !== "pending") return; | ||
| setSubmitting(true); | ||
| const body: Record<string, unknown> = { value }; | ||
| if (otherValue !== undefined) body.other_value = otherValue; | ||
| if (note !== undefined) body.note = note; | ||
|
|
@@ -543,6 +551,26 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| if (!res.ok) { | ||
| const data = await res.json().catch(() => ({})); | ||
| const detail = data?.error ?? data?.detail; | ||
| // 409 = someone else answered first; refetch so the block flips to answered | ||
| if (res.status === 409) { | ||
| // The refetch itself can fail (network reject, invalid JSON) -- | ||
| // fall through to the conflict fallback rather than surfacing a | ||
| // generic "Failed to answer" for an answer that someone else won. | ||
| try { | ||
| const updatedRes = await fetch(`/api/decisions/${decision.id}`); | ||
| if (updatedRes.ok) { | ||
| const updated = await updatedRes.json(); | ||
| setDecision(updated as DecisionData); | ||
| return; | ||
| } | ||
| } catch { | ||
| // fall through | ||
| } | ||
| setAnswerError( | ||
| "This decision was already answered -- refresh to see the outcome", | ||
| ); | ||
| return; | ||
| } | ||
| throw new Error( | ||
| typeof detail === "string" ? detail : "Could not record answer.", | ||
| ); | ||
|
|
@@ -552,10 +580,17 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| if (updatedRes.ok) { | ||
| const updated = await updatedRes.json(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: In the success path, if the follow-up GET returns a 200 with malformed JSON, Reply with |
||
| setDecision(updated as DecisionData); | ||
| } else { | ||
| // Refresh failed: answer was recorded, don't show "Failed to answer" | ||
| // (the SSE broker path will also correct it) | ||
| setSubmitting(false); | ||
| setAnswerError(null); | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to answer decision:", e); | ||
| throw e; | ||
| } finally { | ||
| setSubmitting(false); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -609,12 +644,12 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| key={opt.value} | ||
| type="button" | ||
| onClick={() => { | ||
| if (!isOpen) return; | ||
| if (!isOpen || submitting) return; | ||
| answerDecision(opt.value).catch((e) => | ||
| setAnswerError(`Failed to answer: ${e.message}`) | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }} | ||
| disabled={!isOpen} | ||
| disabled={!isOpen || submitting} | ||
| className={[ | ||
| "flex w-full flex-col gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors", | ||
| "disabled:cursor-not-allowed disabled:opacity-60", | ||
|
|
@@ -661,7 +696,7 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| onKeyDown={(e) => { | ||
| if (e.key === "Enter" && !e.shiftKey) { | ||
| e.preventDefault(); | ||
| if (!isOpen) return; | ||
| if (!isOpen || submitting) return; | ||
| const trimmed = e.currentTarget.value.trim(); | ||
| if (trimmed) { | ||
| answerDecision(trimmed).catch((e) => | ||
|
|
@@ -673,26 +708,30 @@ export function DecisionBlock({ block }: { block: DecisionContentBlock }): React | |
| /> | ||
| <button | ||
| onClick={() => { | ||
| if (!isOpen) return; | ||
| if (!isOpen || submitting) return; | ||
| const trimmed = answer.trim(); | ||
| if (trimmed) { | ||
| answerDecision(trimmed).catch((e) => | ||
| setAnswerError(`Failed to answer: ${e.message}`) | ||
| ); | ||
| } | ||
| }} | ||
| disabled={!answer || answer.trim() === ""} | ||
| disabled={!answer || answer.trim() === "" || submitting} | ||
| className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[12px] text-shell-text hover:text-shell-text-hover hover:bg-shell-surface-focus focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" | ||
| aria-label="Submit answer" | ||
| > | ||
| <ChevronRight size={12} aria-hidden="true" /> Submit | ||
| </button> | ||
| </div> | ||
| {answerError && ( | ||
| <div className="mt-2 text-[12px] text-red-400" role="alert"> | ||
| {answerError} | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| {/* submission errors: one shared alert region for option and | ||
| free-text answers alike (option errors were invisible when this | ||
| lived inside the free_text branch) */} | ||
| {answerError && ( | ||
| <div className="mt-2 px-3 text-[12px] text-red-400" role="alert"> | ||
| {answerError} | ||
| </div> | ||
| )} | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 45786
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 16030
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 337
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 421
Invalidate submissions when
block.decision_idchanges.DecisionBlockis keyed by content-block index, so React can reuse it for a new decision. An oldanswerDecisionrequest can overwritedecisionand clearsubmitting. Guard async state updates with the activedecision_idor a request token, and add a regression test for this race.🤖 Prompt for AI Agents