Skip to content

Commit c0bd483

Browse files
os-zhuangclaude
andauthored
fix(chatbot): plan approval flips the card to a Building… badge immediately (#2632)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 33b4995 commit c0bd483

4 files changed

Lines changed: 95 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
Plan-card approval gives immediate in-card feedback (#2627): clicking
7+
"Build it" flips the clicked card to a spinning "Building…" badge right away
8+
(the approval's chat-level effects land at the bottom of the thread, outside
9+
the viewport, so the card looked untouched for ~10s and users double-clicked).
10+
The durable Built state still derives from the message stream; an approval
11+
that never left the client (rate limit / offline) rolls the badge back so the
12+
button returns. New `planBuildingLabel` prop (AiChatPage passes zh).

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,6 +2158,9 @@ export function ChatPane({
21582158
planApproveLabel={t('console.ai.planApprove', { defaultValue: 'Build it' })}
21592159
planAdjustLabel={t('console.ai.planAdjust', { defaultValue: 'Adjust' })}
21602160
planBuiltLabel={t('console.ai.planBuilt', { defaultValue: 'Built' })}
2161+
planBuildingLabel={
2162+
convZh ? '正在搭建…' : t('console.ai.planBuilding', { defaultValue: 'Building…' })
2163+
}
21612164
planReadyLabel={t('console.ai.planReady', {
21622165
defaultValue: 'The plan is ready. Build it now, or tell me what to adjust.',
21632166
})}

packages/plugin-chatbot/src/ChatbotEnhanced.tsx

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
666666
planAdjustLabel?: string;
667667
/** Static badge shown in place of the "Build it" button once this plan's build has run, so it can't be re-triggered (default "Built"). */
668668
planBuiltLabel?: string;
669+
/** Badge shown on a plan card right after its Build-it was clicked (#2627). */
670+
planBuildingLabel?: string;
669671
/**
670672
* Body line of the FALLBACK confirm card shown when a propose_blueprint step
671673
* finished but produced no structured plan (so the rich card can't render).
@@ -1214,6 +1216,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
12141216
planApproveLabel = 'Build it',
12151217
planAdjustLabel = 'Adjust',
12161218
planBuiltLabel = 'Built',
1219+
planBuildingLabel = 'Building…',
12171220
planReadyLabel = 'The plan is ready. Build it now, or tell me what to adjust.',
12181221
planApproveMessage = 'Looks good — build it as proposed.',
12191222
planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.',
@@ -1501,13 +1504,18 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15011504
// clears any prior send-failure restore guard.
15021505
lastSubmittedRef.current = text;
15031506
restoredErrorRef.current = null;
1507+
// A newer send supersedes any pending approve as "the last send" — an
1508+
// unsent failure of THIS message must not roll back a plan card whose
1509+
// approval already reached the server (#2627).
1510+
lastApprovedPlanIdRef.current = null;
15041511
onSendMessage?.(text, files);
15051512
},
15061513
[onSendMessage]
15071514
);
15081515

15091516
const handleSuggestionClick = React.useCallback(
15101517
(text: string) => {
1518+
lastApprovedPlanIdRef.current = null;
15111519
onSendMessage?.(text);
15121520
},
15131521
[onSendMessage]
@@ -1519,8 +1527,28 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15191527
// approval explicitly authorizes sensible defaults so a click never silently
15201528
// drops them.
15211529
const promptInputWrapRef = React.useRef<HTMLDivElement>(null);
1530+
// Optimistic per-card feedback (#2627): the approval's visible effect (a
1531+
// new user bubble + a streaming turn) lands at the BOTTOM of the thread —
1532+
// outside the viewport when the plan card is scrolled into view — so the
1533+
// card itself looked untouched for the ~10s until apply_blueprint started
1534+
// and users clicked again. Flip the clicked card to a "Building…" badge
1535+
// immediately; the durable Built state still derives from the message
1536+
// stream (builtPlanIds). A send that never left the client (rate limit /
1537+
// offline) rolls the flip back so the buttons return.
1538+
const [approvedPlanIds, setApprovedPlanIds] = React.useState<ReadonlySet<string>>(
1539+
() => new Set<string>(),
1540+
);
1541+
const lastApprovedPlanIdRef = React.useRef<string | null>(null);
15221542
const handlePlanApprove = React.useCallback(
1523-
(hasOpenQuestions: boolean) => {
1543+
(hasOpenQuestions: boolean, toolCallId?: string) => {
1544+
if (toolCallId) {
1545+
lastApprovedPlanIdRef.current = toolCallId;
1546+
setApprovedPlanIds((prev) => {
1547+
const next = new Set(prev);
1548+
next.add(toolCallId);
1549+
return next;
1550+
});
1551+
}
15241552
onSendMessage?.(hasOpenQuestions ? planApproveDefaultsMessage : planApproveMessage);
15251553
},
15261554
[onSendMessage, planApproveMessage, planApproveDefaultsMessage]
@@ -1544,6 +1572,18 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15441572
if (!error || !isUnsentSendError(error)) return;
15451573
if (restoredErrorRef.current === error) return;
15461574
restoredErrorRef.current = error;
1575+
// The approval message never left the client — roll the optimistic
1576+
// "Building…" badge back so the Build-it button returns (#2627).
1577+
if (lastApprovedPlanIdRef.current) {
1578+
const failedId = lastApprovedPlanIdRef.current;
1579+
lastApprovedPlanIdRef.current = null;
1580+
setApprovedPlanIds((prev) => {
1581+
if (!prev.has(failedId)) return prev;
1582+
const next = new Set(prev);
1583+
next.delete(failedId);
1584+
return next;
1585+
});
1586+
}
15471587
const text = lastSubmittedRef.current;
15481588
if (!text) return;
15491589
const textarea = promptInputWrapRef.current?.querySelector('textarea');
@@ -2149,6 +2189,16 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
21492189
{planBuiltLabel}
21502190
</span>
21512191
</div>
2192+
) : approvedPlanIds.has(tool.toolCallId) ? (
2193+
<div className="flex flex-wrap items-center gap-1.5 pt-0.5" data-testid="proposed-plan-actions">
2194+
<span
2195+
className="inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
2196+
data-testid="proposed-plan-building"
2197+
>
2198+
<Loader2 className="size-3.5 animate-spin" />
2199+
{planBuildingLabel}
2200+
</span>
2201+
</div>
21522202
) : (
21532203
<div
21542204
className="flex flex-wrap items-center gap-1.5 pt-0.5"
@@ -2157,7 +2207,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
21572207
<button
21582208
type="button"
21592209
onClick={() =>
2160-
handlePlanApprove(tool.proposedPlan!.questions.length > 0)
2210+
handlePlanApprove(tool.proposedPlan!.questions.length > 0, tool.toolCallId)
21612211
}
21622212
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
21632213
data-testid="proposed-plan-approve"
@@ -2211,6 +2261,16 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22112261
{planBuiltLabel}
22122262
</span>
22132263
</div>
2264+
) : approvedPlanIds.has(tool.toolCallId) ? (
2265+
<div className="flex flex-wrap items-center gap-1.5 pt-0.5" data-testid="proposed-plan-actions">
2266+
<span
2267+
className="inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
2268+
data-testid="proposed-plan-building"
2269+
>
2270+
<Loader2 className="size-3.5 animate-spin" />
2271+
{planBuildingLabel}
2272+
</span>
2273+
</div>
22142274
) : (
22152275
<div
22162276
className="flex flex-wrap items-center gap-1.5 pt-0.5"
@@ -2219,7 +2279,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22192279
<button
22202280
type="button"
22212281
// No structured questions to default through → plain approve.
2222-
onClick={() => handlePlanApprove(false)}
2282+
onClick={() => handlePlanApprove(false, tool.toolCallId)}
22232283
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
22242284
data-testid="proposed-plan-approve"
22252285
>

packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,23 @@ describe('ChatbotEnhanced (AI Elements composition)', () => {
284284
expect(onSendMessage).toHaveBeenCalledWith('APPROVE_PLAIN');
285285
});
286286

287+
it('flips the clicked card to a "Building…" badge immediately (#2627)', () => {
288+
const onSendMessage = vi.fn();
289+
render(
290+
<ChatbotEnhanced
291+
messages={planMessage([])}
292+
onSendMessage={onSendMessage}
293+
planBuildingLabel="BUILDING_NOW"
294+
/>,
295+
);
296+
fireEvent.click(screen.getByTestId('proposed-plan-approve'));
297+
// The approval's chat-level effects land at the bottom of the thread —
298+
// the card ITSELF must show the state change or the click reads as lost.
299+
expect(screen.queryByTestId('proposed-plan-approve')).not.toBeInTheDocument();
300+
expect(screen.getByTestId('proposed-plan-building')).toHaveTextContent('BUILDING_NOW');
301+
expect(onSendMessage).toHaveBeenCalledTimes(1);
302+
});
303+
287304
it('falls back to the static hint (no action buttons) when message sending is not wired', () => {
288305
render(<ChatbotEnhanced messages={planMessage([])} planApproveHintLabel="HINT_TEXT" />);
289306
expect(screen.queryByTestId('proposed-plan-actions')).not.toBeInTheDocument();

0 commit comments

Comments
 (0)