Skip to content

fix(plugin-chatbot): plan card actions wait until no turn is in flight (objectui#10925) - #10952

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-10925-plan-card-wait-stream
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-10925-plan-card-wait-stream

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #10925

Clause-②: no

Dispatched by the domain:ui seat 4 PM. Session https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1, branch claude/issue-10925-plan-card-wait-stream, base 5c94589.

What changed

The proposed-plan card's actions are disabled while isLoading is true. That covers Build it, Adjust and the one-click answer chips, on the structured plan card and on the fallback confirm card (a propose_blueprint whose result did not parse into a plan). The buttons carry the native disabled attribute and the disabled:cursor-not-allowed disabled:opacity-50 styling the builder-handoff button already uses. No new copy.

isLoading is the signal that already turns the composer's send into a stop button. The isLoading prop's JSDoc now says that it also holds the plan card.

The diff is one source file (packages/plugin-chatbot/src/ChatbotEnhanced.tsx), one new test and one patch changeset. AiChatPage.tsx is not touched, because it already passes isLoading from useObjectChat.

The signal, measured

The dispatch assumed the owning message's streaming flag is the "turn still streaming" signal, to be verified against persistence of the turn's tail.

  • The flag holds for the proposing turn. useObjectChat sets isLoading from the AI SDK status (submitted or streaming). uiMessagesToChatMessages sets streaming from that same value, on the trailing assistant message only. In the installed ai@7.0.65, makeRequest sets the status back to ready only after consumeStream has read the response to its end.
  • The server keeps the stream open until the tail is stored. On cloud origin/main 2611cdd, AIService.streamChatWithTools awaits the store of each tool result before the next round. It awaits the store of the final reply before yield finishPart(result). ObjectQLConversationService.addMessage awaits engine.insert, and the agent chat route streams that generator. So neither signal drops before the proposing turn's tail is persisted, and no timer is involved.
  • Route change from the suggested route: the gate reads isLoading, not message.streaming. The per-message flag is a subset of isLoading, so it covers the proposing turn the same way. It misses two windows that overlap turns in the same way. One is the submitted phase before the first chunk, when no message carries the flag. The other is a newer turn streaming below an older plan card. Case 5 below pins both windows.

Tests

All runs below are on HEAD 847cb69, with a clean tree.

New pin ChatbotEnhanced.planActionsWaitForTurn-10925.test.tsx beside the component has five cases. Each feeds the wire shape through the real mapper, with the flags useObjectChat derives from a status.

  1. streaming: Build it and Adjust are disabled. A click sends nothing and does not flip the card to Building.
  2. streaming, then ready: both are enabled, and Build it sends planApproveMessage exactly once.
  3. Answer chips: disabled while streaming, and a click sends nothing. Enabled after, and a click sends the answer.
  4. The fallback confirm card: the same two states.
  5. An older plan card while a newer turn is submitted and then streaming (its own message is not streaming): disabled. After ready: enabled.
  • Ablation: node ../objectstack/scripts/ablation-replace.mjs replaced const planActionsLocked = isLoading; with const planActionsLocked = false; (anchor 1 to 0, blob 1aa9b4bd0f9e to ae9939a163a9). Result: Tests 5 failed (5), each at toBeDisabled() ("Received element is not disabled"). Restore: blob equal to HEAD 1aa9b4bd0f9e, and git diff HEAD empty. The test imports the component from source by a relative path, so the ablation needs no build and no dist/ leg.
  • Package suite: pnpm exec vitest run --maxWorkers=2 packages/plugin-chatbot/ gave Test Files 52 passed (52), Tests 553 passed (553). git ls-files packages/plugin-chatbot lists 52 test files.
  • Type-check: pnpm --filter @object-ui/plugin-chatbot type-check exited 0. It ran after the dependency closure was built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-chatbot^...' build, with 9 projects in scope. tsc -p tsconfig.test.json --listFiles lists the new test file.
  • Lint, narrowed to this diff: eslint --no-inline-config --format json over the 2 touched .ts/.tsx files. The JSON output lists 2 files. ChatbotEnhanced.tsx has 0 errors and 10 warnings at HEAD, and 0 errors and 10 warnings at base 5c94589, from the same three rules. The new test has 0 and 0. Population: the files: ['**/*.{ts,tsx}'] blocks of eslint.config.js cover both files. Invariance: eslint.config.js enables no type-aware linting (no project, projectService or type-checked preset), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint belongs to CI.
  • Gates, all exit 0: check-changeset-presence (1 changeset for 1 released package), check-changeset-no-major, check-changeset-overwrite, check-changeset-claims, check-changeset-fixed, check-pending-changeset-literals, check-new-cross-file-line-citations (0 new), check-control-bytes, check-test-path-roots, check-vi-mock-specifiers, check-vi-mock-inherit, check-vi-mock-override-shape, check-lint-coverage, check-i18n-call-site-keys, check-phantom-dependencies, check-package-self-import, check-unreferenced-sources, check-handler-key-read-sites, check-type-check-coverage. check-governed-queue-guard --test on the three paths reports NOT GOVERNED.
  • Consumers: not run. The exported types are unchanged apart from the isLoading JSDoc. The one plan-card consumer test in app-shell (AiChatPage.planCardLocale.test.tsx) renders with isLoading: false.

Acceptance notes

  • NOT MEASURED: an in-browser or live-E2E check. The unit cases drive the real mapper with the statuses useObjectChat maps from.
  • Same class, out of scope, not filed: the confirm-change card (proposed-changes) sends changesConfirmMessage from a button that stays live while its proposing turn streams. A one-off probe, not kept, rendered an update_metadata changes_proposed result on the streaming tail with isLoading true: proposed-changes-confirm was enabled, and a click sent the message. The same guard would close it. It was not observed through a public door, so it is not filed. Carrier: none.
  • check-changeset-claims (report-only) names .changeset/6687-chatbot-surface-authorable.md as describing ChatbotEnhanced.tsx. I read it: its paragraph is about the surface prop and isPlainSurface, which this diff does not touch, so it stays true.
  • The producer half, ordering replayed history by turn in the cloud conversation store, is the separate cloud card the Direction names. It is not touched here.

Generated by Claude Code

The proposed-plan card renders as soon as propose_blueprint returns, but
the proposing turn keeps streaming (a todo_write, the closing prose) while
the server stores each step. Build it clicked in that window sent the next
turn before the previous one was stored, and the stored history
interleaved the two turns.

Build it, Adjust and the one-click answer chips, on the structured and the
fallback plan card, are now disabled while isLoading is true: the signal
the composer already uses, which falls only once the response stream has
been read to its end. A message's own streaming flag is derived from it
for the trailing assistant message only, so it would miss the submitted
phase and a newer turn streaming below an older card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6687-chatbot-surface-authorable.md

  • names ChatbotEnhanced.tsx → packages/plugin-chatbot/src/ChatbotEnhanced.tsx — edited by this change

    content/docs/plugins/plugin-chatbot.mdx's Properties table listed surface ('card' | 'plain', "bordered panel or a frameless full-page workspace"), but the key had zero read points: none of the three ComponentRegistry.register('chatbot*', ...) sites in renderer.tsx forwarded it, and ChatbotSchema did not declare it. surface was real only as a prop of the React component — ChatbotEnhanced.tsx defines ChatbotSurface, defaults it to 'card', and branches six layout decisions off isPlainSurface — so it was reachable by a hand-written React host and by nobody writing metadata. An author who wrote surface: 'plain' got the 'card' default, with no error and no signal.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 5c94589f0 (merge-base with origin/main): 2 file(s) changed outside .changeset/, read against 1684 pending declaration(s) that publish a body (2284 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3102.7 KB 3104.5 KB
Main entry chunk (gzip) 149.4 KB 350 KB
Entry file index-D91L3UoC.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.58KB 6.17KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.70KB 2.23KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 557.59KB 133.60KB
core (index.js) 9.93KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.61KB 63.16KB
fields (index.js) 261.01KB 66.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.96KB 14.83KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.08KB 46.94KB
plugin-dashboard (index.js) 137.82KB 36.70KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 233.51KB 61.80KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 161.21KB 41.41KB
plugin-gantt (index.js) 170.35KB 42.19KB
plugin-grid (index.js) 228.33KB 62.59KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.86KB 28.64KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.17KB 12.20KB
plugin-timeline (index.js) 31.15KB 9.14KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 89.15KB 22.35KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.22KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.27KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 847cb69341989204ad7418b33f5048d280f6a1db
Local-runs: none

Inputs: card objectui#10925 (body and its 3 comments: serial note 5866915376, claim 5867755959, os-dev-report 5868137217); PR #10952 body, file list and git diff origin/main...refs/review/pr-10952 (merge-base 5c94589; main has since moved to dd0d78f by one unrelated changeset, no overlap with the touched files); packages/plugin-chatbot/src/ at objectui origin/main (ChatbotEnhanced.tsx, mapMessages.ts, useObjectChat.ts) plus the isLoading hand-off in app-shell/src/console/ai/AiChatPage.tsx; cloud origin/main 986892d (packages/service-ai/src/ai-service.ts, conversation/objectql-conversation-service.ts, routes/agent-routes.ts; git diff --stat 2611cdd origin/main on those three files is empty, so the dev's cited 2611cdd reads the same); the 43 check-runs on the head, read last at 2026-09-28T10:45Z. Line numbers below are at objectui origin/main unless a file is named otherwise.

① Derived judgments

The diff: ChatbotEnhanced.tsx +36/-6 (one const planActionsLocked = isLoading;, disabled={planActionsLocked} plus disabled:cursor-not-allowed disabled:opacity-50 on five buttons, one JSDoc edit), one new test file with five cases, one patch changeset. Nothing else.

  1. Gate signal: component-wide isLoading instead of the suggested per-message streaming — right, and a faithful reading of acceptance 1 and 2. The per-message flag is derived from the same value: uiMessagesToChatMessages sets streaming only when isStreaming && idx === lastIdx && role === 'assistant' (mapMessages.ts L997-999), and useObjectChat calls it with isStreaming: isLoading (L1015) where isLoading = status === 'submitted' || status === 'streaming' (L999). So every state in which "their turn streams" has isLoading true, and the actions are disabled (acceptance 1); the moment the status returns to ready the gate opens (acceptance 2).

    • Cost, named: an older plan card is also disabled while a newer turn is in flight (submitted or streaming). The cost is bounded, because in that same window the composer is already held by the same signal: the textarea is disabled={disabled || promptStatus === 'streaming'} (L3402) and the send button is a stop button (L3449-3453, with promptStatus = isLoading ? 'streaming' : 'ready' at L1449). The plan card is aligned with the composer, not made stricter than it, and a click on the older card in that window would send a turn on top of the in-flight one, which is the card's defect. The submitted phase is a window the per-message flag leaves open: no assistant message is trailing, so nothing carries the flag while a request is already out (case 5 asserts exactly that).
    • Adjust: handlePlanAdjust sends nothing; it focuses the composer textarea (L1891-1897), which is disabled during streaming, so disabling Adjust removes a no-op, not a capability. Right.
  2. Coverage of the send paths — complete for the plan card. At main the card-originated onSendMessage callers are exactly three: handlePlanApprove (L1847; the structured approve at L2698 and the fallback approve at L2770), the one-click chips (L2650), and handleChangesConfirm (L1883, the proposed-changes card). The diff gates the structured approve and adjust, the fallback approve and adjust, and the chips: five buttons, all of the plan card's. The one sender left ungated is the confirm-change card, see ③. The other two callers are the composer (handleSubmit, L1799, already held) and handleSuggestionClick (L1811), which renders only in the empty state (L3037-3044) and so cannot coincide with a plan card.

  3. Untouched branches — right. The builtPlanIds and approvedPlanIds badges are not affected. Hosts that pass no onSendMessage (readOnly) still render no buttons (L2669, L2741). Hosts that pass no isLoading keep the default false (L1361) and today's behaviour. planActionsLocked is a plain const, not a hook, so the hook order is unchanged. A disabled button fires no React onClick, which is why case 1's "no optimistic Building flip" follows.

  4. Acceptance 3 — a test pins both states. ChatbotEnhanced.planActionsWaitForTurn-10925.test.tsx, five cases through the real mapper (uiMessagesToChatMessages(wire, { isStreaming: isLoading }), the same call useObjectChat makes). Cases 1, 3 and 4 pin disabled while the proposing turn streams (structured card, chips, fallback card); cases 2, 3, 4 and 5 pin enabled at ready; case 5 pins the two extra windows. Every toBeDisabled in it fails against main, where none of these buttons has disabled; the dev's ablation reports the same (5 of 5 fail with planActionsLocked = false), not re-run here. Check-runs Test and Test (shard 1/8) through (shard 8/8) on the head: success.

  5. Shipped prose, sentence by sentence.
    Changeset .changeset/10925-plan-card-wait-stream.md:

    • "renders as soon as propose_blueprint returns" — true; the card keys off the parsed proposedPlan (or isUnstructuredBuildProposal) of an output-available tool part.
    • "the turn that proposed it keeps streaming after that, and the server stores each of its remaining steps as it goes" — true: streamChatWithTools persists the assistant tool-call turn (ai-service.ts L1767-1770) and each tool result (await this.persistMessage(conversationId, toolTurn, undefined, turnId), L1838-1840) inside the loop, before the next round.
    • "Build it, Adjust and the one-click answer chips were live the whole time" — true at main (no disabled at L2694-2712, L2766-2784, L2647-2653).
    • "In a cloud E2E run the user clicked Build it inside that window. The next turn was sent while the previous one was still being stored, and the stored history interleaved the two turns" — matches the card: 「确认,开始搭建」 was sent while the previous turn was still persisting its tail.
    • "disabled while isLoading is true, on the structured plan card and on the fallback confirm card alike" — the diff.
    • "isLoading is the signal that already turns the composer's send into a stop button" — L1449, L3449-3453.
    • "The actions come back once the response stream has been read to its end" — on the plugin side, isLoading falls when the SDK status leaves submitted | streaming (L999); that the SDK flips to ready only after the response body is consumed is the dev's measurement in ai@7.0.65 (makeRequest), a package outside this review's inputs, so it is taken as reported. On the server side, true: the agent route returns encodeVercelDataStream(events) as the SSE body and nothing follows it (agent-routes.ts L832-846), so the body closes when the generator returns, and the generator yields finish only after the final reply's store is awaited (L1729-1744, and the forced-final twin L1871-1887).
    • "the buttons carry the native disabled attribute while they wait" — true.
    • "two windows that a message's own streaming flag misses: a request that is out but has no reply yet, and a newer turn streaming below an older plan card" — true from mapMessages.ts L997-999; case 5 pins both.
    • "Ordering replayed history by turn in the conversation store is a separate server-side fix" — the card's Direction.
      JSDoc on isLoading: "While it is true the proposed-plan card's actions are disabled as well (objectui#10925), so a click cannot send the next turn while this one is still streaming" — true; "as well" is the composer hold above.
      PR body claims read against the sources: "the disabled:cursor-not-allowed disabled:opacity-50 styling the builder-handoff button already uses" — true (L2491, L2523). "ObjectQLConversationService.addMessage awaits engine.insert" — true (objectql-conversation-service.ts L265; it also awaits the updated_at update at L302 before returning). "AiChatPage.tsx already passes isLoading from useObjectChat" — true (L1714, L2419).
      Two nuances the prose does not state and that do not break it: persistMessage swallows a failed store (returns false, ai-service.ts L559-577) and the tail then carries a persist-warning part before finish, so "awaits the store" is right and a failed store leaves no tail to interleave with; and void this.summarizeConversation(...) (L1737, L1880) is fire-and-forget before finish, but it writes a conversation title, not a message row.
  6. File surface and premise. The claim's surface (ChatbotEnhanced.tsx plan-card buttons, one test under packages/plugin-chatbot/src/__tests__/, one .changeset/10925-*.md; AiChatPage.tsx only if needed) is held exactly. The cloud code the premise leans on is unchanged between the dev's 2611cdd and today's tip.

② Semver level

.changeset/10925-plan-card-wait-stream.md declares '@object-ui/plugin-chatbot': patch. The diff publishes no new or removed export, prop or type; ChatbotEnhancedProps.isLoading changes JSDoc only; the behaviour change is the fix itself (buttons disabled during an in-flight turn where they were live). Patch matches what the diff publishes. The newest claim on the card (comment 5867755959) says Clause-②: no; the PR body says Clause-②: no; the dev report says it stays no. Consistent. Check-runs Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report: success.

③ Boundary flags

  • open_questions: []. None to answer.
  • Dev flag, NOT MEASURED in-browser or live E2E: accepted as stated. The unit pins drive the real mapper with the statuses useObjectChat maps from. Build & E2E and Live E2E (informational) on the head are success, but nothing read here says they drive this scenario. Not escalated.
  • Dev flag, consumer app-shell suites not run: answered by the head's check-runs (Test shards 1-8, Type Check, Lint: success). The exported types are unchanged apart from JSDoc.
  • Dev flag, check-changeset-claims naming .changeset/6687-chatbot-surface-authorable.md: the dev read it and reports its surface paragraph still true; Changeset Claim Re-read on the head is success. Not re-read here (outside this review's inputs).
  • Dev flag, the route change from the dispatch's suggested streaming signal to isLoading: flagged in the report and the PR body, judged right in ①.1 with its cost named.
  • Out-of-scope finding, the proposed-changes confirm card. Same defect class: yes. handleChangesConfirm (L1883) calls onSendMessage(changesConfirmMessage) from proposed-changes-confirm (L2947-2953), which carries no disabled; the card renders on an update_metadata changes_proposed result that lands mid-turn through the same streamChatWithTools loop, whose remaining steps are stored the same way. The dev's one-off probe (isLoading true, confirm enabled, a click sent the message) used the same harness as the pins accepted above. Where it belongs is the maintainer's call; the three shapes are: in this PR (the same planActionsLocked on proposed-changes-confirm and proposed-changes-adjust plus one pin, but outside the card's stated scope and the claim's file surface, and the confirm card has its own state machine, confirmPendingIds, the quota block and the replay outcome, that a disabled state would sit beside); a new card under epic cloud#2440 (the dev withheld filing for want of a public-door measurement, though the probe is measurement of the same kind as the pins accepted here); or nowhere (defensible only if the confirm card never renders while its turn is still streaming, and nothing read here says that). Not decided here.
  • Check-runs on the head, read at 2026-09-28T10:45Z: 43 total, 40 success, 3 skipped (Test (coverage), Test (coverage shard ${{ matrix.shard }}/4), dependabot), 0 failed, 0 still running. The check that was still in progress when the brief was written has completed.

Implemented-by: claude/issue-10925-plan-card-wait-stream
Reviewed-by: session_015AUunPkX7UTkCH9e7AdZo1

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 10:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 7d82957 Sep 28, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10925-plan-card-wait-stream branch September 28, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposed-plan card actions are clickable while the proposing turn is still streaming, which overlaps turns

2 participants