diff --git a/.changeset/25943-studio-slot-pending-count.md b/.changeset/25943-studio-slot-pending-count.md
new file mode 100644
index 00000000000..33004af2400
--- /dev/null
+++ b/.changeset/25943-studio-slot-pending-count.md
@@ -0,0 +1,19 @@
+---
+'@object-ui/plugin-chatbot': patch
+'@object-ui/i18n': patch
+'@object-ui/app-shell': patch
+---
+
+feat(studio): the right slot counts the copilot's items awaiting confirmation, 「AI 副驾 · N 项待确认」
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+Studio's right slot used to say only that "a plan awaits your approval", and only for a build plan. The copilot's change plan (the 「修改方案 · N 项」 card it proposes for an app that already exists) did not light it at all. Now:
+
+- **The count.** The copilot tab of the slot's 属性 | AI 副驾 header carries a badge with the number of items awaiting confirmation, and while the properties show, the folded copilot strip at the slot's foot reads 「AI 副驾 · N 项待确认 · 回到对话 →」 (en: "AI copilot · N awaiting your approval"). The number is the rows of the newest change plan, or 1 for a build plan. The badge and the strip go away the moment the author clicks 存为草稿 / Confirm or Build it, or the change lands.
+- **A change plan is a proposal.** The chat's reading of "the newest proposal still waits for the user" now includes a change plan whose card still offers its confirm button, not only a build plan whose card offers "Build it". The console's chat launchers (the FAB and the ChatDock edge launcher) therefore also show their waiting marker while a change plan awaits confirmation.
+
+`@object-ui/plugin-chatbot`: the `onPlanApprovalPendingChange` prop on `ChatbotEnhancedProps` now receives a second argument, `count: number` (the items the newest proposal asks the user to confirm; 0 when nothing is pending), and it fires when either value changes. A host that reads only the first argument is unaffected. The boolean now also reads `true` for a pending change plan (see above).
+
+`@object-ui/i18n`: the `studio` namespace gains `engine.studio.slot.pendingCount` (en and zh, interpolates `count`). No key is removed or renamed; `engine.studio.slot.planPending` stays in the table.
diff --git a/ui/packages/app-shell/src/assistant/__tests__/planApprovalPendingCount-25943.test.tsx b/ui/packages/app-shell/src/assistant/__tests__/planApprovalPendingCount-25943.test.tsx
new file mode 100644
index 00000000000..65c6d45576f
--- /dev/null
+++ b/ui/packages/app-shell/src/assistant/__tests__/planApprovalPendingCount-25943.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectstack#25943 — the assistant bus keeps how many items each chat reads
+ * as awaiting the user's confirmation, so Studio's right slot can show
+ * 「N 项待确认」. `usePlanApprovalPendingCount` sums the signed-in user's
+ * conversations; `usePlanApprovalPending` (the launchers' marker) is that sum
+ * above 0. The publishing path from the chat is pinned in
+ * `AiChatPage.planApprovalReachesLaunchers-11666.test.tsx`.
+ */
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it } from 'vitest';
+import { act, cleanup, render, screen } from '@testing-library/react';
+import React from 'react';
+import {
+ publishPlanApprovalPending,
+ usePlanApprovalPending,
+ usePlanApprovalPendingCount,
+} from '../assistantBus';
+
+// No AuthProvider here: `useAuth` answers its signed-out default, whose user id
+// the bus reads as '' — the owner every reading below is published for.
+const ME = undefined;
+
+function Probe() {
+ const count = usePlanApprovalPendingCount();
+ const pending = usePlanApprovalPending();
+ return ;
+}
+
+function publish(conversationId: string, pending: boolean, count?: number, userId: string | undefined = ME) {
+ act(() => publishPlanApprovalPending({ userId, conversationId, pending, count }));
+}
+
+const probe = () => screen.getByTestId('probe');
+
+afterEach(() => {
+ // The bus is a module singleton: drop every reading a case left behind.
+ for (const id of ['conv-a', 'conv-b']) {
+ for (const userId of [ME, 'someone-else']) {
+ act(() => publishPlanApprovalPending({ userId, conversationId: id, pending: false }));
+ }
+ }
+ cleanup();
+});
+
+describe('usePlanApprovalPendingCount (objectstack#25943)', () => {
+ it('is 0, and not pending, while nothing awaits approval', () => {
+ render();
+ expect(probe()).toHaveAttribute('data-count', '0');
+ expect(probe()).toHaveAttribute('data-pending', 'false');
+ });
+
+ it('sums the items of every conversation of the signed-in user, and follows a count change', () => {
+ render();
+ publish('conv-a', true, 2);
+ expect(probe()).toHaveAttribute('data-count', '2');
+ expect(probe()).toHaveAttribute('data-pending', 'true');
+
+ publish('conv-b', true, 1);
+ expect(probe()).toHaveAttribute('data-count', '3');
+
+ publish('conv-a', true, 4);
+ expect(probe()).toHaveAttribute('data-count', '5');
+
+ publish('conv-a', false);
+ expect(probe()).toHaveAttribute('data-count', '1');
+ publish('conv-b', false);
+ expect(probe()).toHaveAttribute('data-count', '0');
+ expect(probe()).toHaveAttribute('data-pending', 'false');
+ });
+
+ it('a reading published without a count counts 1', () => {
+ render();
+ publish('conv-a', true);
+ expect(probe()).toHaveAttribute('data-count', '1');
+ });
+
+ it("another user's readings are not counted", () => {
+ render();
+ publish('conv-a', true, 3, 'someone-else');
+ expect(probe()).toHaveAttribute('data-count', '0');
+ expect(probe()).toHaveAttribute('data-pending', 'false');
+ });
+});
diff --git a/ui/packages/app-shell/src/assistant/assistantBus.ts b/ui/packages/app-shell/src/assistant/assistantBus.ts
index a12cdd0ce4e..a647394b9e6 100644
--- a/ui/packages/app-shell/src/assistant/assistantBus.ts
+++ b/ui/packages/app-shell/src/assistant/assistantBus.ts
@@ -193,9 +193,20 @@ export function subscribeMetadataRefresh(listener: () => void): () => void {
// the next session in the tab.
// Kept OFF the snapshot bus above, like the two event channels: a plan's state
// must not re-render every `useAssistant` consumer.
+//
+// objectstack#25943 — each reading also carries how many items the proposal
+// asks the user to confirm (a change plan's rows, 1 for a proposed plan), the
+// 「N 项待确认」 Studio's right slot shows beside the folded copilot.
+
+interface PlanApprovalReading {
+ /** The user whose chat read the proposal as awaiting approval. */
+ owner: string;
+ /** Items awaiting confirmation; at least 1. */
+ count: number;
+}
-/** conversation id → the user whose chat read its plan as awaiting approval. */
-let planApprovalOwners: ReadonlyMap = new Map();
+/** conversation id → the reading of its newest proposal, while one awaits approval. */
+let planApprovalOwners: ReadonlyMap = new Map();
const planApprovalListeners = new Set<() => void>();
function subscribePlanApproval(listener: () => void): () => void {
@@ -205,25 +216,34 @@ function subscribePlanApproval(listener: () => void): () => void {
};
}
-function getPlanApprovalOwners(): ReadonlyMap {
+function getPlanApprovalOwners(): ReadonlyMap {
return planApprovalOwners;
}
/**
- * Record a chat's reading of whether `conversationId`'s newest proposed plan
- * awaits the user's approval (chat hosts call this). No-op when unchanged.
+ * Record a chat's reading of whether `conversationId`'s newest proposal awaits
+ * the user's approval, and how many items it asks them to confirm (chat hosts
+ * call this; `count` defaults to 1 while pending). No-op when unchanged.
* `pending: false` also drops the reading of a conversation that was deleted.
*/
export function publishPlanApprovalPending(reading: {
userId: string | undefined;
conversationId: string;
pending: boolean;
+ count?: number;
}): void {
const owner = reading.userId ?? '';
+ const count = Math.max(1, reading.count ?? 1);
const current = planApprovalOwners.get(reading.conversationId);
- if (reading.pending ? current === owner : current === undefined) return;
+ if (
+ reading.pending
+ ? current?.owner === owner && current.count === count
+ : current === undefined
+ ) {
+ return;
+ }
const next = new Map(planApprovalOwners);
- if (reading.pending) next.set(reading.conversationId, owner);
+ if (reading.pending) next.set(reading.conversationId, { owner, count });
else next.delete(reading.conversationId);
planApprovalOwners = next;
for (const l of planApprovalListeners) l();
@@ -234,15 +254,25 @@ export function publishPlanApprovalPending(reading: {
* awaiting approval, as last read by a mounted chat. Read by the launchers.
*/
export function usePlanApprovalPending(): boolean {
- const owners = useSyncExternalStore(
+ return usePlanApprovalPendingCount() > 0;
+}
+
+/**
+ * objectstack#25943 — how many items the signed-in user's chats read as
+ * awaiting their confirmation, summed over conversations; 0 when none. Read by
+ * Studio's right slot for its 「N 项待确认」.
+ */
+export function usePlanApprovalPendingCount(): number {
+ const readings = useSyncExternalStore(
subscribePlanApproval,
getPlanApprovalOwners,
getPlanApprovalOwners,
);
const { user } = useAuth();
const owner = user?.id ?? '';
- for (const o of owners.values()) if (o === owner) return true;
- return false;
+ let count = 0;
+ for (const r of readings.values()) if (r.owner === owner) count += r.count;
+ return count;
}
/** Subscribe a component to the assistant bus snapshot. */
diff --git a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
index eab93be5a28..e7f7681cd6c 100644
--- a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
+++ b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
@@ -2299,15 +2299,23 @@ export function ChatPane({
// open: an open re-reads the same thread and reports the same state.
const { user: planApprovalUser } = useAuth();
const planApprovalUserId = planApprovalUser?.id;
+ // objectstack#25943 — the reading carries the count of items awaiting
+ // confirmation too (Studio's right slot shows 「N 项待确认」).
const [planApprovalPending, setPlanApprovalPending] = useState(undefined);
+ const [planApprovalCount, setPlanApprovalCount] = useState(0);
+ const onPlanApprovalPendingChange = useCallback((pending: boolean, count: number) => {
+ setPlanApprovalPending(pending);
+ setPlanApprovalCount(count);
+ }, []);
useEffect(() => {
if (!conversationId || planApprovalPending === undefined) return;
publishPlanApprovalPending({
userId: planApprovalUserId,
conversationId,
pending: planApprovalPending,
+ count: planApprovalCount,
});
- }, [planApprovalUserId, conversationId, planApprovalPending]);
+ }, [planApprovalUserId, conversationId, planApprovalPending, planApprovalCount]);
// A1.b switcher menu: every published app with a package identity, deduped
// by package (apps sharing a package share the build thread — the scope is
@@ -2912,7 +2920,7 @@ export function ChatPane({
onBuildMaterialized={handleBuildMaterialized}
onBuildResult={handleBuildResult}
// objectui#11666 — mirrored onto the assistant bus (effect above).
- onPlanApprovalPendingChange={setPlanApprovalPending}
+ onPlanApprovalPendingChange={onPlanApprovalPendingChange}
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
data-testid="ai-chat-panel"
/>
diff --git a/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx
index cb9199e5555..b9b3461c6df 100644
--- a/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx
+++ b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx
@@ -57,7 +57,7 @@ import type { AgentDescriptor } from '@object-ui/plugin-chatbot';
import { ChatPane } from '../AiChatPage';
import { ConsoleChatbotFab } from '../../../layout/ConsoleChatbotFab';
import { useConversationList } from '../../../hooks/useConversationList';
-import { publishPlanApprovalPending } from '../../../assistant/assistantBus';
+import { publishPlanApprovalPending, usePlanApprovalPendingCount } from '../../../assistant/assistantBus';
window.matchMedia = ((query: string) => ({
matches: false,
@@ -104,10 +104,15 @@ function renderFab() {
}
/** Play the chat's own reading, as `ChatbotEnhanced` reports it. */
-function chatReports(pending: boolean) {
- const report = captured.onPlanApprovalPendingChange as (p: boolean) => void;
+function chatReports(pending: boolean, count = pending ? 1 : 0) {
+ const report = captured.onPlanApprovalPendingChange as (p: boolean, c: number) => void;
expect(typeof report).toBe('function');
- act(() => report(pending));
+ act(() => report(pending, count));
+}
+
+/** objectstack#25943 — what Studio's right slot reads off the bus. */
+function CountProbe() {
+ return ;
}
afterEach(() => {
@@ -175,4 +180,20 @@ describe('ChatPane → assistant bus → FAB (objectui#11666)', () => {
});
expect(screen.queryByTestId(MARKER)).not.toBeInTheDocument();
});
+
+ // objectstack#25943 — the chat's count reaches the bus with its reading.
+ it("the chat's count of items awaiting confirmation reaches the bus, and its end clears it", () => {
+ render();
+ renderPane('conv-1');
+ expect(screen.getByTestId('pending-count')).toHaveTextContent('0');
+
+ chatReports(true, 2);
+ expect(screen.getByTestId('pending-count')).toHaveTextContent('2');
+
+ chatReports(true, 3);
+ expect(screen.getByTestId('pending-count')).toHaveTextContent('3');
+
+ chatReports(false);
+ expect(screen.getByTestId('pending-count')).toHaveTextContent('0');
+ });
});
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx b/ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
index 5fc81c256f2..f037a1fa4b6 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
@@ -13,7 +13,7 @@ import { t } from '../metadata-admin/i18n.js';
import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../../console/ai/AiChatPage.js';
import { AiUsageIndicator } from '../../layout/AiUsageIndicator.js';
import { rememberDockReturnLocation } from '../../layout/chatDockState.js';
-import { usePlanApprovalPending } from '../../assistant/assistantBus.js';
+import { usePlanApprovalPendingCount } from '../../assistant/assistantBus.js';
/**
* objectui#8219 — the display label of the artifact a pillar has open, tagged
@@ -147,7 +147,8 @@ export function StudioCopilotConversation({
/** What the copilot reports up to the slot that hosts it (objectui#12725). */
export interface StudioCopilotStatus {
available: boolean;
- planPending: boolean;
+ /** Items the copilot proposed that await the author's confirmation; 0 when none. */
+ planPendingCount: number;
}
export interface StudioChatDockProps {
@@ -162,8 +163,9 @@ export interface StudioChatDockProps {
* objectui#12725 — what the right slot draws of the copilot around it:
* `available`, whether the agent catalog serves one (true while it is still
* being read, as the dock always rendered then), so no segment, strip or
- * empty body stands for a copilot that is not there; and `planPending`, the
- * objectui#11666 reading that a plan it proposed awaits approval. Reported on
+ * empty body stands for a copilot that is not there; and `planPendingCount`,
+ * the objectui#11666 reading of a proposal awaiting approval with the number
+ * of items it asks the author to confirm (objectstack#25943). Reported on
* every change, and as unavailable on unmount.
*/
onStatusChange?: (status: StudioCopilotStatus) => void;
@@ -200,17 +202,17 @@ export function StudioChatDock({
const { agents, isLoading: agentsLoading } = useAgents({ apiBase });
const available = agentsLoading || agents.length > 0;
// objectui#11666 — read off the assistant bus the chat publishes to.
- const planPending = usePlanApprovalPending();
+ const planPendingCount = usePlanApprovalPendingCount();
const onStatusChangeRef = React.useRef(onStatusChange);
React.useEffect(() => {
onStatusChangeRef.current = onStatusChange;
});
React.useEffect(() => {
- onStatusChangeRef.current?.({ available, planPending: available && planPending });
- }, [available, planPending]);
+ onStatusChangeRef.current?.({ available, planPendingCount: available ? planPendingCount : 0 });
+ }, [available, planPendingCount]);
React.useEffect(
() => () => {
- onStatusChangeRef.current?.({ available: false, planPending: false });
+ onStatusChangeRef.current?.({ available: false, planPendingCount: 0 });
},
[],
);
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx
index 2c708f4b3cd..5d46241991f 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx
@@ -168,9 +168,9 @@ vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: ()
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
vi.mock('./StudioAiCopilot', () => ({
- StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
+ StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
React.useEffect(() => {
- onStatusChange?.({ available: true, planPending: false });
+ onStatusChange?.({ available: true, planPendingCount: 0 });
}, [onStatusChange]);
return conversation
;
},
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.leafColumnSelection-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.leafColumnSelection-12725.test.tsx
index e133b06dee2..8c9843fcf0b 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.leafColumnSelection-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.leafColumnSelection-12725.test.tsx
@@ -117,9 +117,9 @@ vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: ()
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
vi.mock('./StudioAiCopilot', () => ({
- StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
+ StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
React.useEffect(() => {
- onStatusChange?.({ available: true, planPending: false });
+ onStatusChange?.({ available: true, planPendingCount: 0 });
}, [onStatusChange]);
return conversation
;
},
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.pillarsClaimSlot-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.pillarsClaimSlot-12725.test.tsx
index 34ff0e4debb..b8fdc33e750 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.pillarsClaimSlot-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.pillarsClaimSlot-12725.test.tsx
@@ -105,9 +105,9 @@ vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => nul
/** What the copilot stub reports, per test. */
const copilot = vi.hoisted(() => ({ available: true }));
vi.mock('./StudioAiCopilot', () => ({
- StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
+ StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
React.useEffect(() => {
- onStatusChange?.({ available: copilot.available, planPending: false });
+ onStatusChange?.({ available: copilot.available, planPendingCount: 0 });
}, [onStatusChange]);
return copilot.available ? conversation
: null;
},
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx
index 932b3fb721f..fd84c7c69ff 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx
@@ -74,11 +74,11 @@ vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: ()
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
/** What the copilot stub reports, per test. */
-const copilot = vi.hoisted(() => ({ available: true, planPending: false }));
+const copilot = vi.hoisted(() => ({ available: true, planPendingCount: 0 }));
vi.mock('./StudioAiCopilot', () => ({
- StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
+ StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
React.useEffect(() => {
- onStatusChange?.({ available: copilot.available, planPending: copilot.planPending });
+ onStatusChange?.({ available: copilot.available, planPendingCount: copilot.planPendingCount });
}, [onStatusChange]);
return copilot.available ? conversation
: null;
},
@@ -114,7 +114,7 @@ const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => bo
beforeEach(() => {
copilot.available = true;
- copilot.planPending = false;
+ copilot.planPendingCount = 0;
window.sessionStorage.clear();
vi.stubGlobal(
'fetch',
@@ -280,17 +280,30 @@ describe('Studio right slot: properties OR the copilot (objectui#12725)', () =>
expect(within(slotProps()).getByTestId('stub-inspector')).toBeVisible();
});
- it('the copilot tab and strip mark a plan that awaits approval', async () => {
- copilot.planPending = true;
+ // objectstack#25943 — 「AI 副驾 · N 项待确认」: the count of items the
+ // copilot proposed, on the tab's badge and in the folded strip.
+ it('the copilot tab and strip count the items that await approval', async () => {
+ copilot.planPendingCount = 2;
await openDashboard();
- await waitFor(() => expect(screen.getByTestId('studio-slot-plan-pending')).toBeInTheDocument());
- expect(within(slot()).getAllByRole('tab')[1]).toHaveAccessibleDescription(t('engine.studio.slot.planPending', 'en'));
+ await waitFor(() => expect(screen.getByTestId('studio-slot-plan-pending')).toHaveTextContent('2'));
+ const pending = tFormat('engine.studio.slot.pendingCount', 'en', { count: 2 });
+ expect(within(slot()).getAllByRole('tab')[1]).toHaveAccessibleDescription(pending);
await selectBlock();
- expect(screen.getByTestId('studio-slot-strip-plan-pending')).toHaveTextContent(
- t('engine.studio.slot.planPending', 'en'),
+ expect(screen.getByTestId('studio-slot-strip-plan-pending')).toHaveTextContent(pending);
+ expect(screen.getByTestId('studio-slot-copilot-strip')).toHaveTextContent(
+ `${t('engine.studio.aiCopilot', 'en')} · ${pending}`,
);
});
+ it('with nothing awaiting approval, neither the tab nor the strip carries a count', async () => {
+ await openDashboard();
+ expect(screen.queryByTestId('studio-slot-plan-pending')).toBeNull();
+ expect(within(slot()).getAllByRole('tab')[1]).not.toHaveAttribute('aria-describedby');
+ await selectBlock();
+ expect(screen.getByTestId('studio-slot-copilot-strip')).toBeVisible();
+ expect(screen.queryByTestId('studio-slot-strip-plan-pending')).toBeNull();
+ });
+
it('the canvas has no 画布 / 源码 / 属性 tabs: one status line, and the definition opens from the ⋯ menu', async () => {
await openDashboard();
const itemHeader = screen.getByTestId('if-item-header');
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index a72dc91de47..e9e96220e73 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -2128,7 +2128,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
const [slotPropsClaims, setSlotPropsClaims] = React.useState(0);
const [copilotStatus, setCopilotStatus] = React.useState({
available: false,
- planPending: false,
+ planPendingCount: 0,
});
const [copilotCanvasOpen, setCopilotCanvasOpen] = React.useState(false);
const slotHasProps = slotPropsClaims > 0;
@@ -2568,7 +2568,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
onModeChange={setSlotMode}
hasProps={slotHasProps}
copilotAvailable={copilotAvailable}
- planPending={copilotStatus.planPending}
+ planPendingCount={copilotStatus.planPendingCount}
propsTargetRef={setSlotPropsTarget}
wideWidth={copilotCanvasOpen && !isMobile ? maximizedDockWidth(window.innerWidth) : undefined}
overlay={isMobile}
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioRightSlot.tsx b/ui/packages/app-shell/src/views/studio-design/StudioRightSlot.tsx
index b8075cc41c5..28cddbc871d 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioRightSlot.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioRightSlot.tsx
@@ -16,6 +16,8 @@
* slot shows the copilot alone (the header reads as a title, not a switch).
* - While the properties show, the copilot folds to a 40 px strip at the
* slot's foot that leads back to the conversation.
+ * - The copilot segment and the strip say how many items the copilot
+ * proposed await the author's confirmation (「N 项待确认」, objectstack#25943).
* - Both bodies stay MOUNTED and are hidden by mode: a turn the copilot is
* streaming survives a look at the properties, and a pillar's portal target
* never disappears under an open editor.
@@ -29,7 +31,7 @@
import * as React from 'react';
import { ArrowRight, SlidersHorizontal, Sparkles } from 'lucide-react';
import { cn } from '@object-ui/components';
-import { t } from '../metadata-admin/i18n.js';
+import { t, tFormat } from '../metadata-admin/i18n.js';
import { STUDIO_SLOT_WIDTH, type StudioSlotMode } from './studioRightSlotState.js';
export interface StudioRightSlotProps {
@@ -42,8 +44,11 @@ export interface StudioRightSlotProps {
hasProps: boolean;
/** The agent catalog serves a copilot (reported by `copilot`). */
copilotAvailable: boolean;
- /** objectui#11666 — a plan the copilot proposed awaits the author's approval. */
- planPending: boolean;
+ /**
+ * objectui#11666 / objectstack#25943 — how many items the copilot proposed
+ * await the author's confirmation; 0 when none.
+ */
+ planPendingCount: number;
/** The copilot body (`StudioChatDock`). Always mounted, hidden by mode. */
copilot: React.ReactNode;
/** Callback ref for the properties portal target. */
@@ -62,7 +67,7 @@ export function StudioRightSlot({
onModeChange,
hasProps,
copilotAvailable,
- planPending,
+ planPendingCount,
copilot,
propsTargetRef,
wideWidth,
@@ -73,13 +78,19 @@ export function StudioRightSlot({
const shown = open && (hasProps || copilotAvailable);
const showsProps = mode === 'props';
const switchable = hasProps && copilotAvailable;
+ const planPending = planPendingCount > 0;
const planMarkerId = React.useId();
+ const pendingText = planPending
+ ? tFormat('engine.studio.slot.pendingCount', locale, { count: planPendingCount })
+ : '';
const planMarker = planPending ? (
+ className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white tabular-nums"
+ >
+ {planPendingCount}
+
) : null;
const segment = (value: StudioSlotMode) =>
cn(
@@ -148,7 +159,7 @@ export function StudioRightSlot({
)}
{planPending && (
- {t('engine.studio.slot.planPending', locale)}
+ {pendingText}
)}
@@ -178,7 +189,7 @@ export function StudioRightSlot({
{planPending && (
<>
{' · '}
- {t('engine.studio.slot.planPending', locale)}
+ {pendingText}
>
)}
diff --git a/ui/packages/app-shell/src/views/studio-design/__tests__/studioCopilotSlotBody-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/__tests__/studioCopilotSlotBody-12725.test.tsx
index c46e8c464c9..40beb1041d2 100644
--- a/ui/packages/app-shell/src/views/studio-design/__tests__/studioCopilotSlotBody-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/__tests__/studioCopilotSlotBody-12725.test.tsx
@@ -94,10 +94,10 @@ describe('StudioChatDock as the right slot\'s copilot (#2477 item 2, objectui#12
it('reports itself available to the slot, and unavailable on unmount', () => {
const reports: StudioCopilotStatus[] = [];
const { unmount } = mountCopilot((s) => reports.push(s));
- expect(reports.at(-1)).toEqual({ available: true, planPending: false });
+ expect(reports.at(-1)).toEqual({ available: true, planPendingCount: 0 });
expect(screen.getByTestId('studio-copilot-maximize')).toBeInTheDocument();
unmount();
- expect(reports.at(-1)).toEqual({ available: false, planPending: false });
+ expect(reports.at(-1)).toEqual({ available: false, planPendingCount: 0 });
});
it('an empty catalog renders nothing and reports no copilot', () => {
@@ -105,6 +105,6 @@ describe('StudioChatDock as the right slot\'s copilot (#2477 item 2, objectui#12
const reports: StudioCopilotStatus[] = [];
const { container } = mountCopilot((s) => reports.push(s));
expect(container).toBeEmptyDOMElement();
- expect(reports.at(-1)).toEqual({ available: false, planPending: false });
+ expect(reports.at(-1)).toEqual({ available: false, planPendingCount: 0 });
});
});
diff --git a/ui/packages/app-shell/src/views/studio-design/__tests__/studioRightSlot-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/__tests__/studioRightSlot-12725.test.tsx
index e57d518d5c5..60ab69278ce 100644
--- a/ui/packages/app-shell/src/views/studio-design/__tests__/studioRightSlot-12725.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/__tests__/studioRightSlot-12725.test.tsx
@@ -15,6 +15,10 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import { StudioRightSlot } from '../StudioRightSlot';
import { STUDIO_SLOT_WIDTH, isSlotEscapeExempt, matchStudioSlotShortcut } from '../studioRightSlotState';
import { t } from '../../metadata-admin/i18n';
+import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
+
+// The zh studio catalogue loads on demand; the zh case below reads it.
+await loadStudioCatalogue('zh');
afterEach(cleanup);
@@ -57,7 +61,7 @@ function renderSlot(props: Partial>
onModeChange={onModeChange}
hasProps
copilotAvailable
- planPending={false}
+ planPendingCount={0}
copilot={}
propsTargetRef={() => {}}
locale="en"
@@ -121,4 +125,15 @@ describe('StudioRightSlot', () => {
fireEvent.click(slot.previousElementSibling as HTMLElement);
expect(onOverlayDismiss).toHaveBeenCalledTimes(1);
});
+
+ // objectstack#25943 — the StudioRailProps strip, 「AI 副驾 · N 项待确认 · 回到对话 →」.
+ it('zh: the folded strip reads 「AI 副驾 · 3 项待确认」 and the copilot tab carries the 3', () => {
+ renderSlot({ mode: 'props', planPendingCount: 3, locale: 'zh-CN' });
+ const strip = screen.getByTestId('studio-slot-copilot-strip');
+ expect(strip).toHaveTextContent('AI 副驾 · 3 项待确认');
+ expect(strip).toHaveTextContent(t('engine.studio.slot.backToChat', 'zh-CN'));
+ const copilotTab = screen.getAllByRole('tab')[1];
+ expect(within(copilotTab).getByTestId('studio-slot-plan-pending')).toHaveTextContent('3');
+ expect(copilotTab).toHaveAccessibleDescription('3 项待确认');
+ });
});
diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts
index 62de87573c4..218f943be1d 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -3053,6 +3053,7 @@ const STUDIO_EN: Record = {
'engine.studio.slot.expand': 'Show the right panel',
'engine.studio.slot.backToChat': 'Back to the conversation',
'engine.studio.slot.planPending': 'a plan awaits your approval',
+ 'engine.studio.slot.pendingCount': '{count} awaiting your approval',
'engine.studio.slot.openFullPage': 'Open full page',
'engine.studio.pillar.data': 'Data',
'engine.studio.pillar.automations': 'Automations',
diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts
index 2114a39dc68..0941ca7e74b 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -2712,6 +2712,7 @@ const STUDIO_ZH: Record = {
'engine.studio.slot.expand': '展开右栏',
'engine.studio.slot.backToChat': '回到对话',
'engine.studio.slot.planPending': '方案待确认',
+ 'engine.studio.slot.pendingCount': '{count} 项待确认',
'engine.studio.slot.openFullPage': '在完整页面中打开',
'engine.studio.pillar.data': '数据',
'engine.studio.pillar.automations': '自动化',
diff --git a/ui/packages/plugin-chatbot/README.md b/ui/packages/plugin-chatbot/README.md
index 26a7a9b1029..34efaff377c 100644
--- a/ui/packages/plugin-chatbot/README.md
+++ b/ui/packages/plugin-chatbot/README.md
@@ -203,12 +203,16 @@ FAB while open so the close button in the panel header is the only active
dismiss control.
A host that wants to signal a waiting approval outside the chat can pass
-`ChatbotEnhanced` the optional `onPlanApprovalPendingChange` callback. It is
-called with `true` while the thread's newest proposed plan still offers
-"Build it", and with `false` otherwise: no plan, the user approved it, or its
-build ran. It fires once on mount and again on every change. The console uses
-it to put a marker on its chat launchers while the chat is closed
-(objectui#11666).
+`ChatbotEnhanced` the optional `onPlanApprovalPendingChange(pending, count)`
+callback. `pending` is `true` while the thread's newest proposal still waits
+for the user: a proposed plan that offers "Build it", or a change plan whose
+card still offers its confirm button. It is `false` otherwise: no proposal,
+the user approved or confirmed it, or its build or confirm landed. `count` is
+how many items that proposal asks the user to confirm: the rows of a change
+plan, 1 for a proposed plan, 0 when nothing is pending. It fires once on mount
+and again on every change of either value. The console uses it to put a
+marker on its chat launchers while the chat is closed (objectui#11666), and
+Studio's right slot shows the count beside the folded copilot.
During a conversation, the chat surface renders an inline assistant responding
indicator while the backend is streaming, keeps message actions quiet until
diff --git a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
index eda6f473860..62f59b0a5e4 100644
--- a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
+++ b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
@@ -766,16 +766,22 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void;
/**
- * objectui#11666 — reports whether this thread's NEWEST proposed plan is
- * still waiting for the user's approval: its card offers "Build it", i.e.
- * {@link resolveProposalCardState} reads `pending` for it. Fires once on
- * mount and again on every change of that boolean. An earlier plan does not
- * count once a newer one exists (the newer card is the one awaiting); an
- * approval (the optimistic "Building…" flip) or a build that ran ends it.
- * Hosts mirror it outside the chat — the console's launchers show a marker
- * while the chat is closed.
+ * objectui#11666 — reports whether this thread's NEWEST proposal is still
+ * waiting for the user's approval: a proposed plan whose card offers
+ * "Build it", or a change plan (`proposedChanges`) whose card still offers
+ * its confirm / 存为草稿 button, i.e. {@link resolveProposalCardState} reads
+ * `pending` for it. Fires once on mount and again on every change of the
+ * reading. An earlier proposal does not count once a newer one exists (the
+ * newer card is the one awaiting); an approval (the optimistic "Building…" /
+ * applying flip), a build that ran or a confirm that landed ends it.
+ *
+ * `count` is how many items that proposal asks the user to confirm: the rows
+ * of a change plan (its 「修改方案 · N 项」), 1 for a proposed plan, and 0
+ * when nothing is pending. Hosts mirror it outside the chat — the console's
+ * launchers show a marker while the chat is closed, and Studio's right slot
+ * shows 「N 项待确认」 beside the folded copilot (objectstack#25943).
*/
- onPlanApprovalPendingChange?: (pending: boolean) => void;
+ onPlanApprovalPendingChange?: (pending: boolean, count: number) => void;
/**
* ADR-0057 P4 — invoked when the user clicks "Open in Builder →" on an `ask`
* agent's `suggest_builder` decline. The host opens the build surface seeded
@@ -3012,32 +3018,43 @@ const ChatbotEnhanced = React.forwardRef(
// newer proposal followed is superseded by it. Computed on every render
// rather than memoised: it is one pass over the tool calls, and its inputs
// are memo results whose identity this must not key on (Commandment #10).
- let newestPlanId: string | undefined;
+ //
+ // objectstack#25943 — a change plan (the copilot's 「修改方案 · N 项」 card)
+ // is a proposal too, and its rows are the count Studio's right slot shows.
+ // "Pending" for it is the card still offering its confirm button: no replay
+ // verdict, no later same-tool commit, no click in flight (`confirmPendingIds`)
+ // and no quota refusal (`quotaBlockedChanges`).
+ let newestProposal: ChatbotEnhancedToolInvocation | undefined;
for (const message of messages) {
for (const tool of message.toolInvocations ?? []) {
- if ((tool.proposedPlan || isUnstructuredBuildProposal(tool)) && tool.toolCallId) {
- newestPlanId = tool.toolCallId;
+ if ((tool.proposedPlan || tool.proposedChanges || isUnstructuredBuildProposal(tool)) && tool.toolCallId) {
+ newestProposal = tool;
}
}
}
+ const newestProposalId = newestProposal?.toolCallId;
const planApprovalPending =
- newestPlanId !== undefined &&
+ newestProposalId !== undefined &&
+ !quotaBlockedChanges.has(newestProposalId) &&
resolveProposalCardState({
- replayOutcome: replayOutcomeByProposalId.get(newestPlanId),
- built: builtPlanIds.has(newestPlanId),
- confirmed: confirmedChangeIds.has(newestPlanId),
- approved: approvedPlanIds.has(newestPlanId),
+ replayOutcome: replayOutcomeByProposalId.get(newestProposalId),
+ built: builtPlanIds.has(newestProposalId),
+ confirmed: confirmedChangeIds.has(newestProposalId),
+ approved: approvedPlanIds.has(newestProposalId) || confirmPendingIds.has(newestProposalId),
}) === 'pending';
+ const planApprovalPendingCount = planApprovalPending
+ ? (newestProposal?.proposedChanges?.changes.length ?? 1)
+ : 0;
// The host's callback is read through a ref so the effect keys on the
- // boolean alone — a host that passes a fresh function each render must not
+ // reading alone — a host that passes a fresh function each render must not
// re-announce an unchanged reading.
const onPlanApprovalPendingChangeRef = React.useRef(onPlanApprovalPendingChange);
React.useEffect(() => {
onPlanApprovalPendingChangeRef.current = onPlanApprovalPendingChange;
}, [onPlanApprovalPendingChange]);
React.useEffect(() => {
- onPlanApprovalPendingChangeRef.current?.(planApprovalPending);
- }, [planApprovalPending]);
+ onPlanApprovalPendingChangeRef.current?.(planApprovalPending, planApprovalPendingCount);
+ }, [planApprovalPending, planApprovalPendingCount]);
const renderToolDetail = (tool: ChatbotEnhancedToolInvocation) => {
const state =
diff --git a/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalCount-25943.test.tsx b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalCount-25943.test.tsx
new file mode 100644
index 00000000000..1bc47920695
--- /dev/null
+++ b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalCount-25943.test.tsx
@@ -0,0 +1,151 @@
+/**
+ * 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#25943 — `onPlanApprovalPendingChange(pending, count)` also says
+ * how many items the newest proposal asks the user to confirm, so Studio's
+ * right slot can show 「AI 副驾 · N 项待确认」 beside the folded copilot.
+ *
+ * - a change plan (the copilot's 「修改方案 · N 项」 card) is a proposal: it is
+ * pending while its card offers 存为草稿 / Confirm, and `count` is its rows;
+ * - a proposed plan counts 1, as before it read `true`;
+ * - nothing pending is `(false, 0)`.
+ *
+ * Every case asserts the reading AND the card it mirrors. The envelopes are the
+ * real wire shapes, fed through the real mapper.
+ */
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ChatbotEnhanced } from '../ChatbotEnhanced';
+import { uiMessageToChatMessage, uiMessagesToChatMessages } from '../mapMessages';
+import { changeRows, draftedEnvelope, proposedEnvelope } from './fixtures/changePosture.fixtures';
+
+/** The proposal turn: `apply_edit` answered with a `changes_proposed` envelope. */
+const proposalTurn = (envelope: unknown, id = 'a1', toolCallId = 'call_1') =>
+ uiMessageToChatMessage({
+ id,
+ role: 'assistant',
+ parts: [{ type: 'tool-apply_edit', toolCallId, state: 'output-available', input: {}, output: envelope }],
+ });
+
+/** The confirm replay of that proposal (`replay_…`), answered with the applied envelope. */
+const replayTurn = (envelope: unknown) =>
+ uiMessageToChatMessage({
+ id: 'a2',
+ role: 'assistant',
+ parts: [{ type: 'tool-apply_edit', toolCallId: 'replay_t2_0', state: 'output-available', input: {}, output: envelope }],
+ });
+
+/** A `propose_blueprint` turn: a proposed plan whose card offers "Build it". */
+const planTurn = (id = 'p1') =>
+ uiMessagesToChatMessages([
+ {
+ id,
+ role: 'assistant' as const,
+ parts: [
+ {
+ type: 'tool-propose_blueprint',
+ toolCallId: `plan-${id}`,
+ state: 'output-available',
+ output: {
+ type: 'text',
+ value: JSON.stringify({
+ status: 'blueprint_proposed',
+ summary: 'Helpdesk',
+ blueprint: { objects: [{ name: 'ticket', label: 'Ticket', fields: [{ name: 'subject' }] }] },
+ counts: { objects: 1, views: 1, dashboards: 0, seedData: 0 },
+ questions: [],
+ assumptions: [],
+ }),
+ },
+ },
+ ],
+ },
+ ])[0];
+
+/** The last reading the host was told, as `[pending, count]`. */
+const lastReading = (spy: ReturnType) => spy.mock.calls.at(-1);
+
+describe('ChatbotEnhanced — onPlanApprovalPendingChange carries the count (objectstack#25943)', () => {
+ it('a change plan awaiting 存为草稿 reads (true, its rows)', () => {
+ const spy = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByTestId('proposed-changes-confirm')).toBeInTheDocument();
+ expect(screen.getAllByTestId('proposed-change-row')).toHaveLength(2);
+ expect(lastReading(spy)).toEqual([true, 2]);
+ });
+
+ it('the click ends the wait at once (the applying flip), before any replay lands', () => {
+ const spy = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId('proposed-changes-confirm'));
+ expect(screen.getByTestId('proposed-changes-applying')).toBeInTheDocument();
+ expect(lastReading(spy)).toEqual([false, 0]);
+ });
+
+ it('a change the replay saved as a draft is no longer pending', () => {
+ const spy = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.queryByTestId('proposed-changes-confirm')).toBeNull();
+ expect(lastReading(spy)).toEqual([false, 0]);
+ });
+
+ it('a proposed plan counts 1, and a newer change plan supersedes it with its own rows', () => {
+ const spy = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument();
+ expect(lastReading(spy)).toEqual([true, 1]);
+
+ const single = { ...proposedEnvelope('en', 'draft'), changes: changeRows('en').slice(0, 1) };
+ rerender(
+ ,
+ );
+ expect(screen.getAllByTestId('proposed-change-row')).toHaveLength(1);
+ expect(lastReading(spy)).toEqual([true, 1]);
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ rerender(
+ ,
+ );
+ expect(lastReading(spy)).toEqual([true, 2]);
+ });
+
+ it('nothing proposed reads (false, 0)', () => {
+ const spy = vi.fn();
+ render();
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(lastReading(spy)).toEqual([false, 0]);
+ });
+});