From 6c766cc6b0b16dd218c28e789cef2f54ec3ff268 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:26:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(chatbot):=20plan=20approval=20flips=20the?= =?UTF-8?q?=20card=20to=20a=20Building=E2=80=A6=20badge=20immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging E2E (2026-07-17): clicking 开始搭建 (plan approve) showed no change at the card for ~10s — the approval sends a chat message whose visible effects (user bubble + streaming turn) land at the BOTTOM of the thread, outside the viewport when the card is in view — so users assumed the click was lost and clicked again (#2627). - Track approved plan ids locally; the clicked card's buttons flip to a spinning "Building…" badge on click (both the structured plan card and the unstructured fallback gate). Built state still derives from the message stream (#432 semantics unchanged). - An approval that never left the client (unsent error) rolls the badge back so the button returns; a newer typed/suggestion send supersedes the approve as "last send" so ITS failure can't roll back a delivered approval. - New planBuildingLabel prop; AiChatPage passes 正在搭建… for zh conversations. Partially addresses #2627 — the conversation-history-clears-after-build race needs a live repro and stays open. Co-Authored-By: Claude Fable 5 --- .changeset/ai-plan-approve-feedback.md | 12 ++++ .../app-shell/src/console/ai/AiChatPage.tsx | 3 + .../plugin-chatbot/src/ChatbotEnhanced.tsx | 66 ++++++++++++++++++- .../src/__tests__/ChatbotEnhanced.test.tsx | 17 +++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 .changeset/ai-plan-approve-feedback.md diff --git a/.changeset/ai-plan-approve-feedback.md b/.changeset/ai-plan-approve-feedback.md new file mode 100644 index 000000000..65edf2c81 --- /dev/null +++ b/.changeset/ai-plan-approve-feedback.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/app-shell': patch +--- + +Plan-card approval gives immediate in-card feedback (#2627): clicking +"Build it" flips the clicked card to a spinning "Building…" badge right away +(the approval's chat-level effects land at the bottom of the thread, outside +the viewport, so the card looked untouched for ~10s and users double-clicked). +The durable Built state still derives from the message stream; an approval +that never left the client (rate limit / offline) rolls the badge back so the +button returns. New `planBuildingLabel` prop (AiChatPage passes zh). diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index e18d0f97f..294cd6bf6 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2158,6 +2158,9 @@ export function ChatPane({ planApproveLabel={t('console.ai.planApprove', { defaultValue: 'Build it' })} planAdjustLabel={t('console.ai.planAdjust', { defaultValue: 'Adjust' })} planBuiltLabel={t('console.ai.planBuilt', { defaultValue: 'Built' })} + planBuildingLabel={ + convZh ? '正在搭建…' : t('console.ai.planBuilding', { defaultValue: 'Building…' }) + } planReadyLabel={t('console.ai.planReady', { defaultValue: 'The plan is ready. Build it now, or tell me what to adjust.', })} diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index ddb1ae312..9997cdb6e 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -666,6 +666,8 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes( planApproveLabel = 'Build it', planAdjustLabel = 'Adjust', planBuiltLabel = 'Built', + planBuildingLabel = 'Building…', planReadyLabel = 'The plan is ready. Build it now, or tell me what to adjust.', planApproveMessage = 'Looks good — build it as proposed.', planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.', @@ -1501,6 +1504,10 @@ const ChatbotEnhanced = React.forwardRef( // clears any prior send-failure restore guard. lastSubmittedRef.current = text; restoredErrorRef.current = null; + // A newer send supersedes any pending approve as "the last send" — an + // unsent failure of THIS message must not roll back a plan card whose + // approval already reached the server (#2627). + lastApprovedPlanIdRef.current = null; onSendMessage?.(text, files); }, [onSendMessage] @@ -1508,6 +1515,7 @@ const ChatbotEnhanced = React.forwardRef( const handleSuggestionClick = React.useCallback( (text: string) => { + lastApprovedPlanIdRef.current = null; onSendMessage?.(text); }, [onSendMessage] @@ -1519,8 +1527,28 @@ const ChatbotEnhanced = React.forwardRef( // approval explicitly authorizes sensible defaults so a click never silently // drops them. const promptInputWrapRef = React.useRef(null); + // Optimistic per-card feedback (#2627): the approval's visible effect (a + // new user bubble + a streaming turn) lands at the BOTTOM of the thread — + // outside the viewport when the plan card is scrolled into view — so the + // card itself looked untouched for the ~10s until apply_blueprint started + // and users clicked again. Flip the clicked card to a "Building…" badge + // immediately; the durable Built state still derives from the message + // stream (builtPlanIds). A send that never left the client (rate limit / + // offline) rolls the flip back so the buttons return. + const [approvedPlanIds, setApprovedPlanIds] = React.useState>( + () => new Set(), + ); + const lastApprovedPlanIdRef = React.useRef(null); const handlePlanApprove = React.useCallback( - (hasOpenQuestions: boolean) => { + (hasOpenQuestions: boolean, toolCallId?: string) => { + if (toolCallId) { + lastApprovedPlanIdRef.current = toolCallId; + setApprovedPlanIds((prev) => { + const next = new Set(prev); + next.add(toolCallId); + return next; + }); + } onSendMessage?.(hasOpenQuestions ? planApproveDefaultsMessage : planApproveMessage); }, [onSendMessage, planApproveMessage, planApproveDefaultsMessage] @@ -1544,6 +1572,18 @@ const ChatbotEnhanced = React.forwardRef( if (!error || !isUnsentSendError(error)) return; if (restoredErrorRef.current === error) return; restoredErrorRef.current = error; + // The approval message never left the client — roll the optimistic + // "Building…" badge back so the Build-it button returns (#2627). + if (lastApprovedPlanIdRef.current) { + const failedId = lastApprovedPlanIdRef.current; + lastApprovedPlanIdRef.current = null; + setApprovedPlanIds((prev) => { + if (!prev.has(failedId)) return prev; + const next = new Set(prev); + next.delete(failedId); + return next; + }); + } const text = lastSubmittedRef.current; if (!text) return; const textarea = promptInputWrapRef.current?.querySelector('textarea'); @@ -2149,6 +2189,16 @@ const ChatbotEnhanced = React.forwardRef( {planBuiltLabel} + ) : approvedPlanIds.has(tool.toolCallId) ? ( +
+ + + {planBuildingLabel} + +
) : (
(
+ ) : approvedPlanIds.has(tool.toolCallId) ? ( +
+ + + {planBuildingLabel} + +
) : (
(