From ecc34812c6727f508d5b3e702caa63f2872fbff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:24:03 +0000 Subject: [PATCH] fix(plugin-chatbot): draft card re-reads its pending count on the metadata pulse The draft tool card's Publish (N) count was fetched once per toolCallId and only refreshed by the card's own publish/discard, so a publish from the chat banner left a stale Publish (N). ChatbotEnhanced takes an optional subscribeMetadataRefresh prop and re-reads every on-screen draft package's count through fetchPendingDraftCount on each pulse; AiChatPage passes the assistant bus's subscriber. Claude-Session: https://claude.ai/code/session_01ADHkmRirVxTgwfMT99xmtc Co-authored-by: Claude --- .../26471-draft-card-pending-count-pulse.md | 13 ++ .../app-shell/src/console/ai/AiChatPage.tsx | 3 +- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 26 ++++ ...tEnhanced.pendingCountPulse-26471.test.tsx | 125 ++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 .changeset/26471-draft-card-pending-count-pulse.md create mode 100644 ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx diff --git a/.changeset/26471-draft-card-pending-count-pulse.md b/.changeset/26471-draft-card-pending-count-pulse.md new file mode 100644 index 00000000000..a38ef0a21fd --- /dev/null +++ b/.changeset/26471-draft-card-pending-count-pulse.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/app-shell': patch +--- + +fix(plugin-chatbot): a draft card's Publish (N) refreshes after a publish it did not run + +Clause-②: no +Metadata-protocol: none + +The assistant's draft tool card read its package's pending-draft count once per tool call and re-read it only after its own publish or discard. A publish from the chat banner (or Studio) left the card showing 发布 (N) with the old count. `ChatbotEnhanced` takes a new optional `subscribeMetadataRefresh` prop, the host's live-metadata pulse: on every pulse each draft card re-reads its package's count through `fetchPendingDraftCount`, and a count of 0 draws Published. The Console's AI chat page passes the assistant bus's `subscribeMetadataRefresh`. Hosts that pass nothing keep today's behaviour. + +What to do: nothing. A custom host that wires `fetchPendingDraftCount` can pass its own pulse subscriber to get the same refresh. diff --git a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx index a0d8fd74206..8ddaaaa089f 100644 --- a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -118,7 +118,7 @@ import { } from '../../chrome/advertisedShortcuts.js'; import { fetchPendingDraftCount } from '../../preview/draftStatus.js'; import { draftPreviewPath } from '../../preview/draftSurface.js'; -import { emitMetadataRefresh, publishPlanApprovalPending } from '../../assistant/assistantBus.js'; +import { emitMetadataRefresh, publishPlanApprovalPending, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; import { getRuntimeConfig, isAiStudioEnabled } from '../../runtime-config.js'; import { makerConvergedOnBuild, makerVisibleAgents } from '../../hooks/surfaceAgent.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; @@ -2722,6 +2722,7 @@ export function ChatPane({ // Live lifecycle truth for draft cards: the server's pending count per // package, so reloaded conversations show Published/Publish honestly. fetchPendingDraftCount={fetchPendingDraftCount} + subscribeMetadataRefresh={subscribeMetadataRefresh} onPublishDrafts={async (packageId) => { // Promote the conversation's staged drafts to live (ADR-0033 gate — // the human still clicks). Same call as the floating chat + PackagesPage. diff --git a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 54fc319b300..f6afd3b0aaa 100644 --- a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -907,6 +907,16 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes Promise; + /** + * The host's live-metadata pulse (app-shell's `subscribeMetadataRefresh`): + * called once with a listener, returns the unsubscriber. On every pulse + * each draft card re-reads its package's count through + * `fetchPendingDraftCount`, so a publish this card did not run (the chat + * banner, Studio) still turns its Publish (N) into Published. Ignored + * without `fetchPendingDraftCount`; absent, the count refreshes only on the + * card's own publish / discard, as before. + */ + subscribeMetadataRefresh?: (listener: () => void) => () => void; /** * Auto-fire `onPublishDrafts` the moment a turn finishes drafting an app — * the self-use "magic moment" where the user refreshes and the app is already @@ -2077,6 +2087,7 @@ const ChatbotEnhanced = React.forwardRef( surfaceContextLabel, surfaceContextTitle, fetchPendingDraftCount, + subscribeMetadataRefresh, autoPublishDrafts = false, processVisibility = 'summary', surface = 'card', @@ -2222,6 +2233,9 @@ const ChatbotEnhanced = React.forwardRef( ReadonlyMap >(() => new Map()); const pendingFetchedRef = React.useRef>(new Set()); + // Every package a draft card on screen is bound to — what a metadata + // pulse re-reads. + const pendingPackagesRef = React.useRef>(new Set()); const refreshPendingCount = React.useCallback( async (packageId: string) => { if (!fetchPendingDraftCount) return; @@ -2255,12 +2269,24 @@ const ChatbotEnhanced = React.forwardRef( const key = tool.toolCallId; if (pkg && key && !pendingFetchedRef.current.has(key)) { pendingFetchedRef.current.add(key); + pendingPackagesRef.current.add(pkg); due.add(pkg); } } } for (const pkg of due) void refreshPendingCount(pkg); }, [messages, isLoading, fetchPendingDraftCount, refreshPendingCount]); + // A publish this card did not run (the chat banner, Studio, another + // card's package) changes the server's count without touching this + // component; the host's metadata pulse is the one signal that it did. + // Subscribed once per host subscriber, not per render; the pulse re-reads + // the same source the first fetch used. + React.useEffect(() => { + if (!subscribeMetadataRefresh || !fetchPendingDraftCount) return; + return subscribeMetadataRefresh(() => { + for (const pkg of pendingPackagesRef.current) void refreshPendingCount(pkg); + }); + }, [subscribeMetadataRefresh, fetchPendingDraftCount, refreshPendingCount]); // Publish a package's drafts and reflect success on exactly the cards that // were pending for it at publish time. The host's onPublishDrafts returns // `false` / `{ok:false}` on failure (and surfaces its own error); any other diff --git a/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx new file mode 100644 index 00000000000..f52ec419a1a --- /dev/null +++ b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26471 — a draft card's 发布 (N) follows a publish it did not + * run. The chat banner (and Studio) publish the package, then fire the host's + * live-metadata pulse; the card re-reads its package's pending count from the + * same `fetchPendingDraftCount` its first read used, and a count of 0 draws + * the Published badge instead of the button. + * + * Pinned: + * - 发布 (2) → pulse → the fetch now answers 0 → no 发布, Published shown; + * - a re-render alone fetches nothing (the count is re-read on a pulse, not + * per render); + * - unmount unsubscribes, and a later pulse fetches nothing. + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { ChatbotEnhanced, type ChatbotEnhancedMessage } from '../ChatbotEnhanced'; +import { detectDraftResult } from '../mapMessages'; + +afterEach(() => { + cleanup(); +}); + +const PACKAGE_ID = 'app.crm'; + +/** One assistant message whose single tool part drafted a field into `app.crm`. */ +function draftedMessage(): ChatbotEnhancedMessage { + const draftReview = detectDraftResult({ + status: 'drafted', + type: 'object', + name: 'crm_account', + summary: 'Added field Priority', + changedKeys: ['fields'], + packageId: PACKAGE_ID, + }); + if (!draftReview) throw new Error('fixture is not a drafted envelope'); + return { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [{ toolCallId: 't1', toolName: 'add_field', state: 'output-available', draftReview }], + }; +} + +/** The host's pulse, shaped like app-shell's `subscribeMetadataRefresh` / `emitMetadataRefresh`. */ +function makePulse() { + const listeners = new Set<() => void>(); + return { + listeners, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + emit: () => { + for (const l of listeners) l(); + }, + }; +} + +function chat(pulse: ReturnType, fetchPendingDraftCount: (pkg: string) => Promise) { + return ( + + ); +} + +describe('objectstack#26471 — the draft card re-reads its pending count on the metadata pulse', () => { + it('发布 (2), then a publish elsewhere fires the pulse and the card stops offering 发布', async () => { + const pulse = makePulse(); + let pending = 2; + const fetchPendingDraftCount = vi.fn(async (_pkg: string) => pending); + const { rerender } = render(chat(pulse, fetchPendingDraftCount)); + + expect(await screen.findByTestId('draft-publish')).toHaveTextContent('发布 (2)'); + expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1); + expect(fetchPendingDraftCount).toHaveBeenCalledWith(PACKAGE_ID); + + // A re-render with nothing new is not a reason to read again. + rerender(chat(pulse, fetchPendingDraftCount)); + expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1); + + // The banner published the package and pulsed; the server now has 0 pending. + pending = 0; + await act(async () => { + pulse.emit(); + }); + + expect(fetchPendingDraftCount).toHaveBeenCalledTimes(2); + expect(fetchPendingDraftCount).toHaveBeenLastCalledWith(PACKAGE_ID); + expect(screen.queryByTestId('draft-publish')).not.toBeInTheDocument(); + expect(screen.getByText('已发布')).toBeInTheDocument(); + }); + + it('unmount unsubscribes: a later pulse reads nothing', async () => { + const pulse = makePulse(); + const fetchPendingDraftCount = vi.fn(async (_pkg: string) => 2); + const { unmount } = render(chat(pulse, fetchPendingDraftCount)); + + expect(await screen.findByTestId('draft-publish')).toHaveTextContent('发布 (2)'); + expect(pulse.listeners.size).toBe(1); + + unmount(); + expect(pulse.listeners.size).toBe(0); + + pulse.emit(); + expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1); + }); +});