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 {usePlanApprovalPendingCount()}; } 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 ? ( ) : null; const segment = (value: StudioSlotMode) => cn( @@ -148,7 +159,7 @@ export function StudioRightSlot({ )} {planPending && ( )} @@ -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]); + }); +});