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
19 changes: 19 additions & 0 deletions .changeset/25943-studio-slot-pending-count.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 <output data-testid="probe" data-count={count} data-pending={String(pending)} />;
}

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(<Probe />);
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(<Probe />);
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(<Probe />);
publish('conv-a', true);
expect(probe()).toHaveAttribute('data-count', '1');
});

it("another user's readings are not counted", () => {
render(<Probe />);
publish('conv-a', true, 3, 'someone-else');
expect(probe()).toHaveAttribute('data-count', '0');
expect(probe()).toHaveAttribute('data-pending', 'false');
});
});
50 changes: 40 additions & 10 deletions ui/packages/app-shell/src/assistant/assistantBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = new Map();
/** conversation id → the reading of its newest proposal, while one awaits approval. */
let planApprovalOwners: ReadonlyMap<string, PlanApprovalReading> = new Map();
const planApprovalListeners = new Set<() => void>();

function subscribePlanApproval(listener: () => void): () => void {
Expand All @@ -205,25 +216,34 @@ function subscribePlanApproval(listener: () => void): () => void {
};
}

function getPlanApprovalOwners(): ReadonlyMap<string, string> {
function getPlanApprovalOwners(): ReadonlyMap<string, PlanApprovalReading> {
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();
Expand All @@ -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. */
Expand Down
12 changes: 10 additions & 2 deletions ui/packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | undefined>(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
Expand Down Expand Up @@ -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"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <output data-testid="pending-count">{usePlanApprovalPendingCount()}</output>;
}

afterEach(() => {
Expand Down Expand Up @@ -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(<CountProbe />);
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');
});
});
18 changes: 10 additions & 8 deletions ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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 });
},
[],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="copilot-body">conversation</div>;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="copilot-body">conversation</div>;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <div data-testid="copilot-body">conversation</div> : null;
},
Expand Down
Loading
Loading