Skip to content

Commit 7600209

Browse files
fix(plugin-chatbot): draft card Publish (N) refreshes after a publish it did not run (#26539)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9XwvMrbCPuSr6eHG5BxfkDf)_ Fixes #26471 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:AI 对话里草稿卡的「发布 (N)」现在会订阅控制台已有的元数据刷新脉冲,横幅或 Studio 发布之后卡片重新读待发布数,归零就显示「已发布」。 - 为什么改:从对话横幅发布后,卡片还显示「发布 (5)」,客户分不清发布到底成功没有。 - 风险与回滚:只加一个可选 prop 和两行接线,没有新的计数来源;不传该 prop 的宿主行为不变。回滚即撤销本 PR。 - 先看:`ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx` 新增的订阅 effect;`ui/packages/app-shell/src/console/ai/AiChatPage.tsx` 的两行接线;新测试文件。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx`,预期 2 条全绿。 - 需要你做的:无,席位落地。 ## What changes - `ChatbotEnhanced` (plugin-chatbot) takes a new optional prop `subscribeMetadataRefresh?: (listener) => unsubscribe`, the same signature as app-shell's assistant-bus `subscribeMetadataRefresh`. The draft card's pending-count block records every package a draft card is bound to; one effect subscribes to the pulse and, on each pulse, re-reads those packages through the existing `refreshPendingCount` / `fetchPendingDraftCount`. No second count source. The effect is keyed on the subscriber and the fetcher, so it subscribes once and unsubscribes on unmount; a re-render fetches nothing. - The 发布 (N) render is unchanged: a count of 0 already draws the Published badge, exactly as a first render with 0 pending does. - `AiChatPage.tsx` (app-shell), the only host that wires `fetchPendingDraftCount`, passes the bus's `subscribeMetadataRefresh` (2 lines: the existing assistantBus import and the prop at the `ChatbotEnhanced` mount). - Why a prop and not an import: the pulse lives in app-shell (`src/assistant/assistantBus.ts`), and app-shell depends on plugin-chatbot, so importing it from plugin-chatbot would be a dependency cycle. The seat widened the file surface to those 2 app-shell lines (option A). - Root changeset `26471-draft-card-pending-count-pulse.md`: patch for `@object-ui/plugin-chatbot` and `@object-ui/app-shell`. ## Tests - New `ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.pendingCountPulse-26471.test.tsx`: (1) a card shows 发布 (2); a re-render fetches nothing; the pulse fires, the fetch now answers 0, and the card no longer offers 发布 (Published shown); (2) unmount unsubscribes, and a later pulse fetches nothing. - No existing test was edited. - Ablation (one-off, not kept): the subscription effect's guard was replaced with an unconditional return through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob f6afd3b → 778b78f95853). Both new tests went red ("expected vi.fn() to be called 2 times, but got 1 times"; "expected +0 to be 1"). Restored: blob == HEAD and `git diff HEAD` empty. ## Acceptance notes - The prop declaration (near :910) and its destructure (near :2090) sit outside the pending-count block named in the claim; a new prop cannot land without them. Both are away from #26469's regions (:755 / :2039). - #26469 had no PR when this opened. Per the seat, this PR lands second; whichever lands second merges origin/main first. ## Verification All on head `ecc34812`. <details><summary>Local runs</summary> - `cd ui && pnpm exec vitest run` new test + `ChatbotEnhanced.changeCardV2-12894` + `ChatbotEnhanced.draftPreviewAny-12968`: Test Files 3 passed, Tests 27 passed (lock VERDICT command-exit 0). - `pnpm --filter @object-ui/plugin-chatbot test --maxWorkers=2`: Test Files 85 passed, Tests 807 passed (VERDICT command-exit 0). - app-shell, narrowed to the 21 `console/ai/__tests__/AiChatPage.*` files: Test Files 21 passed, Tests 98 passed (VERDICT command-exit 0). The full app-shell suite is left to CI `Test UI`. - `pnpm --filter @object-ui/plugin-chatbot type-check`: exit 0. `pnpm --filter @object-ui/app-shell type-check`: exit 0 (after building `@object-ui/app-shell^...`). - `node scripts/pm/dispatch-gates.mjs --commands`: 35 families, every one run with exit 0; `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, all exit codes recorded). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRirVxTgwfMT99xmtc)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66244ac commit 7600209

4 files changed

Lines changed: 166 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(plugin-chatbot): a draft card's Publish (N) refreshes after a publish it did not run
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
What to do: nothing. A custom host that wires `fetchPendingDraftCount` can pass its own pulse subscriber to get the same refresh.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ import {
118118
} from '../../chrome/advertisedShortcuts.js';
119119
import { fetchPendingDraftCount } from '../../preview/draftStatus.js';
120120
import { draftPreviewPath } from '../../preview/draftSurface.js';
121-
import { emitMetadataRefresh, publishPlanApprovalPending } from '../../assistant/assistantBus.js';
121+
import { emitMetadataRefresh, publishPlanApprovalPending, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
122122
import { getRuntimeConfig, isAiStudioEnabled } from '../../runtime-config.js';
123123
import { makerConvergedOnBuild, makerVisibleAgents } from '../../hooks/surfaceAgent.js';
124124
import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
@@ -2722,6 +2722,7 @@ export function ChatPane({
27222722
// Live lifecycle truth for draft cards: the server's pending count per
27232723
// package, so reloaded conversations show Published/Publish honestly.
27242724
fetchPendingDraftCount={fetchPendingDraftCount}
2725+
subscribeMetadataRefresh={subscribeMetadataRefresh}
27252726
onPublishDrafts={async (packageId) => {
27262727
// Promote the conversation's staged drafts to live (ADR-0033 gate —
27272728
// the human still clicks). Same call as the floating chat + PackagesPage.

‎ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,16 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
907907
* in-memory snapshot remains the fallback when the prop is absent.
908908
*/
909909
fetchPendingDraftCount?: (packageId: string) => Promise<number>;
910+
/**
911+
* The host's live-metadata pulse (app-shell's `subscribeMetadataRefresh`):
912+
* called once with a listener, returns the unsubscriber. On every pulse
913+
* each draft card re-reads its package's count through
914+
* `fetchPendingDraftCount`, so a publish this card did not run (the chat
915+
* banner, Studio) still turns its Publish (N) into Published. Ignored
916+
* without `fetchPendingDraftCount`; absent, the count refreshes only on the
917+
* card's own publish / discard, as before.
918+
*/
919+
subscribeMetadataRefresh?: (listener: () => void) => () => void;
910920
/**
911921
* Auto-fire `onPublishDrafts` the moment a turn finishes drafting an app —
912922
* the self-use "magic moment" where the user refreshes and the app is already
@@ -2077,6 +2087,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
20772087
surfaceContextLabel,
20782088
surfaceContextTitle,
20792089
fetchPendingDraftCount,
2090+
subscribeMetadataRefresh,
20802091
autoPublishDrafts = false,
20812092
processVisibility = 'summary',
20822093
surface = 'card',
@@ -2222,6 +2233,9 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22222233
ReadonlyMap<string, number>
22232234
>(() => new Map());
22242235
const pendingFetchedRef = React.useRef<Set<string>>(new Set());
2236+
// Every package a draft card on screen is bound to — what a metadata
2237+
// pulse re-reads.
2238+
const pendingPackagesRef = React.useRef<Set<string>>(new Set());
22252239
const refreshPendingCount = React.useCallback(
22262240
async (packageId: string) => {
22272241
if (!fetchPendingDraftCount) return;
@@ -2255,12 +2269,24 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22552269
const key = tool.toolCallId;
22562270
if (pkg && key && !pendingFetchedRef.current.has(key)) {
22572271
pendingFetchedRef.current.add(key);
2272+
pendingPackagesRef.current.add(pkg);
22582273
due.add(pkg);
22592274
}
22602275
}
22612276
}
22622277
for (const pkg of due) void refreshPendingCount(pkg);
22632278
}, [messages, isLoading, fetchPendingDraftCount, refreshPendingCount]);
2279+
// A publish this card did not run (the chat banner, Studio, another
2280+
// card's package) changes the server's count without touching this
2281+
// component; the host's metadata pulse is the one signal that it did.
2282+
// Subscribed once per host subscriber, not per render; the pulse re-reads
2283+
// the same source the first fetch used.
2284+
React.useEffect(() => {
2285+
if (!subscribeMetadataRefresh || !fetchPendingDraftCount) return;
2286+
return subscribeMetadataRefresh(() => {
2287+
for (const pkg of pendingPackagesRef.current) void refreshPendingCount(pkg);
2288+
});
2289+
}, [subscribeMetadataRefresh, fetchPendingDraftCount, refreshPendingCount]);
22642290
// Publish a package's drafts and reflect success on exactly the cards that
22652291
// were pending for it at publish time. The host's onPublishDrafts returns
22662292
// `false` / `{ok:false}` on failure (and surfaces its own error); any other
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26471 — a draft card's 发布 (N) follows a publish it did not
11+
* run. The chat banner (and Studio) publish the package, then fire the host's
12+
* live-metadata pulse; the card re-reads its package's pending count from the
13+
* same `fetchPendingDraftCount` its first read used, and a count of 0 draws
14+
* the Published badge instead of the button.
15+
*
16+
* Pinned:
17+
* - 发布 (2) → pulse → the fetch now answers 0 → no 发布, Published shown;
18+
* - a re-render alone fetches nothing (the count is re-read on a pulse, not
19+
* per render);
20+
* - unmount unsubscribes, and a later pulse fetches nothing.
21+
*/
22+
import '@testing-library/jest-dom/vitest';
23+
import { afterEach, describe, expect, it, vi } from 'vitest';
24+
import { act, cleanup, render, screen } from '@testing-library/react';
25+
import { ChatbotEnhanced, type ChatbotEnhancedMessage } from '../ChatbotEnhanced';
26+
import { detectDraftResult } from '../mapMessages';
27+
28+
afterEach(() => {
29+
cleanup();
30+
});
31+
32+
const PACKAGE_ID = 'app.crm';
33+
34+
/** One assistant message whose single tool part drafted a field into `app.crm`. */
35+
function draftedMessage(): ChatbotEnhancedMessage {
36+
const draftReview = detectDraftResult({
37+
status: 'drafted',
38+
type: 'object',
39+
name: 'crm_account',
40+
summary: 'Added field Priority',
41+
changedKeys: ['fields'],
42+
packageId: PACKAGE_ID,
43+
});
44+
if (!draftReview) throw new Error('fixture is not a drafted envelope');
45+
return {
46+
id: 'a1',
47+
role: 'assistant',
48+
content: '',
49+
toolInvocations: [{ toolCallId: 't1', toolName: 'add_field', state: 'output-available', draftReview }],
50+
};
51+
}
52+
53+
/** The host's pulse, shaped like app-shell's `subscribeMetadataRefresh` / `emitMetadataRefresh`. */
54+
function makePulse() {
55+
const listeners = new Set<() => void>();
56+
return {
57+
listeners,
58+
subscribe: (listener: () => void) => {
59+
listeners.add(listener);
60+
return () => {
61+
listeners.delete(listener);
62+
};
63+
},
64+
emit: () => {
65+
for (const l of listeners) l();
66+
},
67+
};
68+
}
69+
70+
function chat(pulse: ReturnType<typeof makePulse>, fetchPendingDraftCount: (pkg: string) => Promise<number>) {
71+
return (
72+
<ChatbotEnhanced
73+
messages={[draftedMessage()]}
74+
onSendMessage={vi.fn()}
75+
onPublishDrafts={vi.fn()}
76+
publishDraftsLabel="发布"
77+
publishedLabel="已发布"
78+
fetchPendingDraftCount={fetchPendingDraftCount}
79+
subscribeMetadataRefresh={pulse.subscribe}
80+
/>
81+
);
82+
}
83+
84+
describe('objectstack#26471 — the draft card re-reads its pending count on the metadata pulse', () => {
85+
it('发布 (2), then a publish elsewhere fires the pulse and the card stops offering 发布', async () => {
86+
const pulse = makePulse();
87+
let pending = 2;
88+
const fetchPendingDraftCount = vi.fn(async (_pkg: string) => pending);
89+
const { rerender } = render(chat(pulse, fetchPendingDraftCount));
90+
91+
expect(await screen.findByTestId('draft-publish')).toHaveTextContent('发布 (2)');
92+
expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1);
93+
expect(fetchPendingDraftCount).toHaveBeenCalledWith(PACKAGE_ID);
94+
95+
// A re-render with nothing new is not a reason to read again.
96+
rerender(chat(pulse, fetchPendingDraftCount));
97+
expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1);
98+
99+
// The banner published the package and pulsed; the server now has 0 pending.
100+
pending = 0;
101+
await act(async () => {
102+
pulse.emit();
103+
});
104+
105+
expect(fetchPendingDraftCount).toHaveBeenCalledTimes(2);
106+
expect(fetchPendingDraftCount).toHaveBeenLastCalledWith(PACKAGE_ID);
107+
expect(screen.queryByTestId('draft-publish')).not.toBeInTheDocument();
108+
expect(screen.getByText('已发布')).toBeInTheDocument();
109+
});
110+
111+
it('unmount unsubscribes: a later pulse reads nothing', async () => {
112+
const pulse = makePulse();
113+
const fetchPendingDraftCount = vi.fn(async (_pkg: string) => 2);
114+
const { unmount } = render(chat(pulse, fetchPendingDraftCount));
115+
116+
expect(await screen.findByTestId('draft-publish')).toHaveTextContent('发布 (2)');
117+
expect(pulse.listeners.size).toBe(1);
118+
119+
unmount();
120+
expect(pulse.listeners.size).toBe(0);
121+
122+
pulse.emit();
123+
expect(fetchPendingDraftCount).toHaveBeenCalledTimes(1);
124+
});
125+
});

0 commit comments

Comments
 (0)