Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/26471-draft-card-pending-count-pulse.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion ui/packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,16 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
* in-memory snapshot remains the fallback when the prop is absent.
*/
fetchPendingDraftCount?: (packageId: string) => Promise<number>;
/**
* 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
Expand Down Expand Up @@ -2077,6 +2087,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
surfaceContextLabel,
surfaceContextTitle,
fetchPendingDraftCount,
subscribeMetadataRefresh,
autoPublishDrafts = false,
processVisibility = 'summary',
surface = 'card',
Expand Down Expand Up @@ -2222,6 +2233,9 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
ReadonlyMap<string, number>
>(() => new Map());
const pendingFetchedRef = React.useRef<Set<string>>(new Set());
// Every package a draft card on screen is bound to — what a metadata
// pulse re-reads.
const pendingPackagesRef = React.useRef<Set<string>>(new Set());
const refreshPendingCount = React.useCallback(
async (packageId: string) => {
if (!fetchPendingDraftCount) return;
Expand Down Expand Up @@ -2255,12 +2269,24 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof makePulse>, fetchPendingDraftCount: (pkg: string) => Promise<number>) {
return (
<ChatbotEnhanced
messages={[draftedMessage()]}
onSendMessage={vi.fn()}
onPublishDrafts={vi.fn()}
publishDraftsLabel="发布"
publishedLabel="已发布"
fetchPendingDraftCount={fetchPendingDraftCount}
subscribeMetadataRefresh={pulse.subscribe}
/>
);
}

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);
});
});
Loading