Skip to content

Commit 3cc4563

Browse files
feat(studio): the right slot counts the copilot's items awaiting confirmation (#27789)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgUY8ckC6r2uhn1K73kkTS9f)_ ## 维护者速读 - 改了什么:Studio 右栏的「AI 副驾」分段上加一个数字徽标,看属性时底部折叠条读「AI 副驾 · N 项待确认 · 回到对话 →」;N 是副驾最新一张「修改方案 · N 项」卡的条数(搭建方案算 1 项)。点了「存为草稿 / 确认」或改动落地,徽标和文字立刻消失。 - 为什么改:#25943 Target 第 1、2 项要「N 项待确认」计数;main 上只有一个「方案待确认」小圆点,而且只认搭建方案,副驾给已上线应用提的「修改方案」卡根本不会点亮它。 - 风险与回滚:控制台的对话启动器(右下角 FAB、ChatDock 边条)现在在「修改方案」卡待确认时也会亮提示点,这是有意的行为扩展;plugin-chatbot 的回调多一个参数,只读第一个参数的宿主不受影响。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx`(`newestProposal` 一段)、`ui/packages/app-shell/src/assistant/assistantBus.ts`(按会话存条数)、`ui/packages/app-shell/src/views/studio-design/StudioRightSlot.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalCount-25943.test.tsx packages/app-shell/src/views/studio-design/__tests__/studioRightSlot-12725.test.tsx`,预期全部通过,其中 zh 用例断言折叠条读「AI 副驾 · 3 项待确认」。 - 需要你做的:无,席位落地。 Part of #25943 Clause-②: yes (widening) Metadata-protocol: none ## What changes The card's Target 1–6 were measured on `main` @ `481ade61` first: items 1–6 already hold (the right slot, select-to-properties, the copilot strip and canvas hint, no 源码 tab, the status line and the item ⋯ menu, change card v2, the 变更 badge and 「草稿 · N 项未发布」, pillar labels from `lg`), with one gap: the copilot segment and the folded strip carried a boolean 「方案待确认」 dot, not a count, and only for a build plan. This PR closes that gap. - **`@object-ui/plugin-chatbot`, `ChatbotEnhanced`.** The newest-proposal reading behind `onPlanApprovalPendingChange` now also counts a change plan (`proposedChanges`, the 「修改方案 · N 项」 card). A change plan is pending while its card still offers its confirm / 存为草稿 button: no replay verdict, no later same-tool commit, no click in flight (`confirmPendingIds`), no quota refusal (`quotaBlockedChanges`). The callback gains a second argument, `count`: the rows of a pending change plan, 1 for a pending build plan, 0 when nothing is pending. It fires when either value changes, and still keys on the reading alone, not the callback's identity. - **`@object-ui/app-shell`, assistant bus.** `publishPlanApprovalPending` takes an optional `count` (defaults to 1 while pending) and keeps it per conversation beside the owner. `usePlanApprovalPending()` is unchanged in meaning (any pending reading of the signed-in user). New `usePlanApprovalPendingCount()` sums the user's conversations. `ChatPane` publishes the chat's count with its boolean. - **Studio right slot.** `StudioCopilotStatus` carries `planPendingCount` in place of `planPending`. The copilot tab's dot becomes a count badge, and the strip reads `AI 副驾 · {count} 项待确认` (en `AI copilot · {count} awaiting your approval`) through the new studio key `engine.studio.slot.pendingCount`; the tab's accessible description is the same sentence. - **Docs.** `ui/packages/plugin-chatbot/README.md` describes the two-argument callback and the change-plan reading. `content/docs/frontend/` has no page that documents this prop, so none changed. ## Tests - New `ChatbotEnhanced.planApprovalCount-25943.test.tsx` (5 cases, real wire envelopes through the real mapper, each asserting the reading and the card it mirrors): a change plan awaiting 存为草稿 reads `[true, 2]`; the click reads `[false, 0]` at once (applying); a drafted replay reads `[false, 0]`; a build plan reads `[true, 1]` and a newer change plan supersedes it with its rows (an unchanged reading is not re-announced); nothing proposed reads `[false, 0]`. - New `planApprovalPendingCount-25943.test.tsx` (4 cases) for the bus: the per-conversation sum, a count change, a reading without a count counts 1, another user's readings are not counted. - `AiChatPage.planApprovalReachesLaunchers-11666.test.tsx` gains one case: the chat's count reaches the bus and its end clears it. - `StudioDesignSurface.rightSlot-12725.test.tsx`: the marker case now counts (badge `2`, description and strip text), plus a case with nothing pending. `studioRightSlot-12725.test.tsx` gains the zh strip case 「AI 副驾 · 3 项待确认」. - Five existing slot test doubles move from `planPending` to `planPendingCount`. Readings, all on the final head `413cff81` (the work merged with `origin/main`) under the verification lock unless a line says otherwise: <details><summary>Package suites: plugin-chatbot 92 files / 854 tests, i18n 90 files / 1380 passed + 13 skipped</summary> ``` pnpm --filter @objectstack/spec build :: exit 0 (spec moved in the merge) pnpm --filter @object-ui/plugin-chatbot test :: exit 0 · Test Files 92 passed (92) · Tests 854 passed (854) pnpm --filter @object-ui/i18n test :: exit 0 · Test Files 90 passed (90) · Tests 1380 passed | 13 skipped (1393) ``` </details> <details><summary>app-shell and console: the 287 test files that name a touched module, 2130 / 2130 passed</summary> Every `packages/app-shell/src/**` and `apps/console/src/**` test file that names `StudioDesignSurface`, `StudioRightSlot`, `StudioAiCopilot`, `assistantBus`, `AiChatPage`, `ChatDock`, `ConsoleChatbotFab` or `planApproval` (`git grep -l`), `--reporter=verbose`: `Test Files 287 passed (287) · Tests 2130 passed (2130)`, lock VERDICT command-exit 0. The whole `@object-ui/app-shell` suite (1,911 files) was started and stopped after 16 minutes with no output (non-TTY reporter); it is left to CI `Test UI`, which shards it. </details> <details><summary>Type-check and lint</summary> ``` pnpm --filter @object-ui/plugin-chatbot run type-check :: exit 0 (tsc --noEmit && tsc -p tsconfig.test.json) pnpm --filter @object-ui/i18n run type-check :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 eslint --no-inline-config on the 11 changed source and test files :: exit 0 (0 errors; warnings are pre-existing) pnpm --dir ui run check:new-line-citations :: exit 0 (0 new citations) node scripts/check-platform-checklist.mjs --changed-from origin/main :: exit 0 ``` </details> <details><summary>Ablations (four, each through scripts/ablation-replace.mjs, restored with blob == HEAD and git diff HEAD empty)</summary> - `ChatbotEnhanced.tsx`, the newest-proposal scan without `tool.proposedChanges` → `planApprovalCount-25943`: 2 failed / 5 (the change plan reads, and the newer change plan's rows). - `ChatbotEnhanced.tsx`, the count fixed to `1` → `planApprovalCount-25943`: 2 failed / 5. - `StudioRightSlot.tsx`, the strip text back to `engine.studio.slot.planPending` → `studioRightSlot-12725` zh case and `StudioDesignSurface.rightSlot-12725` count case: 2 failed / 23. - `assistantBus.ts`, the sum counting each reading as 1 → `planApprovalPendingCount-25943` and the ChatPane count case: 2 failed / 9. - app-shell and plugin-chatbot tests import `src`, so no dist build sits between the mutation and the run. </details> <details><summary>Derived gates (dispatch-gates --commands on this diff): 35 run, 35 exit 0, reconciled</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `pnpm --dir ui exec vitest run scripts/` first exited 1 with four 15 s timeouts (`check-hand-rolled-comment-mask`, `check-vi-mock-inherit`) while it ran beside the locked test run; re-run alone under the lock: `Test Files 169 passed | 2 skipped (171) · Tests 4902 passed | 2 skipped`. `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. </details> ## Acceptance notes - **Card Acceptance.** 1: the slot's own cases were already on `main` (`StudioDesignSurface.rightSlot-12725`, `pillarsClaimSlot-12725`, `leafColumnSelection-12725`, `kanbanCardSelection-12725`, `copilotDraftBadge-12725`, `itemDraftLine-12725`); this PR adds the count. 2: screenshots are **NOT MEASURED** — a local backend serves no AI agent catalog, so the copilot half never mounts and no proposal can be produced; the DOM is pinned by the jsdom cases above. 3: see the readings above; the whole `@object-ui/app-shell` suite (1,911 files) is left to CI `Test UI`. - **What stays on the card (B, not ui work).** The live 草稿 chip and 「由 AI 副驾在 N 分钟前作为草稿加入」 do not show on staging because the copilot's drafts are stamped `by: "api"`: the producer (cloud `service-ai-studio`, `stageDraft` / `applyDraft`) does not pass `surface: 'copilot'`, and this repo's HTTP doors refuse `copilot` from a client by design. The ui code for the marks is on `main`. Carrier: cloud. - **Measured, not a proven ui defect (C).** On staging the Interfaces inspector opened `hcpl_book.default` (404 on `GET /meta/view/...`) while the copilot drafted `hcpl_book.list`. Reading of `main`: `listViewTargetOf` falls back to `OBJECT.default` when no merged list view is flagged `isDefault`, and the runtime's `ObjectView` promotes the object's embedded `list` under that same identity (`defaultListViewId`) as its default tab, so for an object that declares `list` the inspector and the runtime agree, and the view the copilot edited is a secondary one. They would disagree only for an object with neither `list` nor a flagged view, where the runtime shows the first list view; whether `hcpl_book` is that case was not measurable from here. Not changed. - **Behaviour widened on purpose.** The console launchers' waiting marker (objectui#11666) now also lights for a pending change plan, since the reading counts one. - **The count is the user's, not the package's.** Like the boolean before it, the bus sums every conversation of the signed-in user, so a plan awaiting approval in another app's conversation also counts in this Studio's strip. - `engine.studio.slot.planPending` is no longer read but stays in the studio table, so the i18n change only widens; retiring it is a narrowing for a later sweep. - The harness's after-PR assignee / review request was skipped on the maintainer's rule (the seat reviews first). - **An empty change plan.** `ChatbotEnhanced` would report `(true, 0)` for a pending `proposedChanges` with no rows; the assistant bus clamps a pending reading to at least 1. No such card reaches the chat today: the mapper lifts `proposedChanges` only when the envelope carries at least one parseable change row (objectui#8343), so there is no visible defect in this repository. (Added by the seat from contract review 6510852862.) ## Verification See the readings under Tests. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZkqrAJBqDcZ2PKjos)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2efd903 commit 3cc4563

19 files changed

Lines changed: 459 additions & 80 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
feat(studio): the right slot counts the copilot's items awaiting confirmation, 「AI 副驾 · N 项待确认」
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
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:
13+
14+
- **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.
15+
- **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.
16+
17+
`@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).
18+
19+
`@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.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#25943 — the assistant bus keeps how many items each chat reads
6+
* as awaiting the user's confirmation, so Studio's right slot can show
7+
* 「N 项待确认」. `usePlanApprovalPendingCount` sums the signed-in user's
8+
* conversations; `usePlanApprovalPending` (the launchers' marker) is that sum
9+
* above 0. The publishing path from the chat is pinned in
10+
* `AiChatPage.planApprovalReachesLaunchers-11666.test.tsx`.
11+
*/
12+
import '@testing-library/jest-dom/vitest';
13+
import { afterEach, describe, expect, it } from 'vitest';
14+
import { act, cleanup, render, screen } from '@testing-library/react';
15+
import React from 'react';
16+
import {
17+
publishPlanApprovalPending,
18+
usePlanApprovalPending,
19+
usePlanApprovalPendingCount,
20+
} from '../assistantBus';
21+
22+
// No AuthProvider here: `useAuth` answers its signed-out default, whose user id
23+
// the bus reads as '' — the owner every reading below is published for.
24+
const ME = undefined;
25+
26+
function Probe() {
27+
const count = usePlanApprovalPendingCount();
28+
const pending = usePlanApprovalPending();
29+
return <output data-testid="probe" data-count={count} data-pending={String(pending)} />;
30+
}
31+
32+
function publish(conversationId: string, pending: boolean, count?: number, userId: string | undefined = ME) {
33+
act(() => publishPlanApprovalPending({ userId, conversationId, pending, count }));
34+
}
35+
36+
const probe = () => screen.getByTestId('probe');
37+
38+
afterEach(() => {
39+
// The bus is a module singleton: drop every reading a case left behind.
40+
for (const id of ['conv-a', 'conv-b']) {
41+
for (const userId of [ME, 'someone-else']) {
42+
act(() => publishPlanApprovalPending({ userId, conversationId: id, pending: false }));
43+
}
44+
}
45+
cleanup();
46+
});
47+
48+
describe('usePlanApprovalPendingCount (objectstack#25943)', () => {
49+
it('is 0, and not pending, while nothing awaits approval', () => {
50+
render(<Probe />);
51+
expect(probe()).toHaveAttribute('data-count', '0');
52+
expect(probe()).toHaveAttribute('data-pending', 'false');
53+
});
54+
55+
it('sums the items of every conversation of the signed-in user, and follows a count change', () => {
56+
render(<Probe />);
57+
publish('conv-a', true, 2);
58+
expect(probe()).toHaveAttribute('data-count', '2');
59+
expect(probe()).toHaveAttribute('data-pending', 'true');
60+
61+
publish('conv-b', true, 1);
62+
expect(probe()).toHaveAttribute('data-count', '3');
63+
64+
publish('conv-a', true, 4);
65+
expect(probe()).toHaveAttribute('data-count', '5');
66+
67+
publish('conv-a', false);
68+
expect(probe()).toHaveAttribute('data-count', '1');
69+
publish('conv-b', false);
70+
expect(probe()).toHaveAttribute('data-count', '0');
71+
expect(probe()).toHaveAttribute('data-pending', 'false');
72+
});
73+
74+
it('a reading published without a count counts 1', () => {
75+
render(<Probe />);
76+
publish('conv-a', true);
77+
expect(probe()).toHaveAttribute('data-count', '1');
78+
});
79+
80+
it("another user's readings are not counted", () => {
81+
render(<Probe />);
82+
publish('conv-a', true, 3, 'someone-else');
83+
expect(probe()).toHaveAttribute('data-count', '0');
84+
expect(probe()).toHaveAttribute('data-pending', 'false');
85+
});
86+
});

‎ui/packages/app-shell/src/assistant/assistantBus.ts‎

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,20 @@ export function subscribeMetadataRefresh(listener: () => void): () => void {
193193
// the next session in the tab.
194194
// Kept OFF the snapshot bus above, like the two event channels: a plan's state
195195
// must not re-render every `useAssistant` consumer.
196+
//
197+
// objectstack#25943 — each reading also carries how many items the proposal
198+
// asks the user to confirm (a change plan's rows, 1 for a proposed plan), the
199+
// 「N 项待确认」 Studio's right slot shows beside the folded copilot.
200+
201+
interface PlanApprovalReading {
202+
/** The user whose chat read the proposal as awaiting approval. */
203+
owner: string;
204+
/** Items awaiting confirmation; at least 1. */
205+
count: number;
206+
}
196207

197-
/** conversation id → the user whose chat read its plan as awaiting approval. */
198-
let planApprovalOwners: ReadonlyMap<string, string> = new Map();
208+
/** conversation id → the reading of its newest proposal, while one awaits approval. */
209+
let planApprovalOwners: ReadonlyMap<string, PlanApprovalReading> = new Map();
199210
const planApprovalListeners = new Set<() => void>();
200211

201212
function subscribePlanApproval(listener: () => void): () => void {
@@ -205,25 +216,34 @@ function subscribePlanApproval(listener: () => void): () => void {
205216
};
206217
}
207218

208-
function getPlanApprovalOwners(): ReadonlyMap<string, string> {
219+
function getPlanApprovalOwners(): ReadonlyMap<string, PlanApprovalReading> {
209220
return planApprovalOwners;
210221
}
211222

212223
/**
213-
* Record a chat's reading of whether `conversationId`'s newest proposed plan
214-
* awaits the user's approval (chat hosts call this). No-op when unchanged.
224+
* Record a chat's reading of whether `conversationId`'s newest proposal awaits
225+
* the user's approval, and how many items it asks them to confirm (chat hosts
226+
* call this; `count` defaults to 1 while pending). No-op when unchanged.
215227
* `pending: false` also drops the reading of a conversation that was deleted.
216228
*/
217229
export function publishPlanApprovalPending(reading: {
218230
userId: string | undefined;
219231
conversationId: string;
220232
pending: boolean;
233+
count?: number;
221234
}): void {
222235
const owner = reading.userId ?? '';
236+
const count = Math.max(1, reading.count ?? 1);
223237
const current = planApprovalOwners.get(reading.conversationId);
224-
if (reading.pending ? current === owner : current === undefined) return;
238+
if (
239+
reading.pending
240+
? current?.owner === owner && current.count === count
241+
: current === undefined
242+
) {
243+
return;
244+
}
225245
const next = new Map(planApprovalOwners);
226-
if (reading.pending) next.set(reading.conversationId, owner);
246+
if (reading.pending) next.set(reading.conversationId, { owner, count });
227247
else next.delete(reading.conversationId);
228248
planApprovalOwners = next;
229249
for (const l of planApprovalListeners) l();
@@ -234,15 +254,25 @@ export function publishPlanApprovalPending(reading: {
234254
* awaiting approval, as last read by a mounted chat. Read by the launchers.
235255
*/
236256
export function usePlanApprovalPending(): boolean {
237-
const owners = useSyncExternalStore(
257+
return usePlanApprovalPendingCount() > 0;
258+
}
259+
260+
/**
261+
* objectstack#25943 — how many items the signed-in user's chats read as
262+
* awaiting their confirmation, summed over conversations; 0 when none. Read by
263+
* Studio's right slot for its 「N 项待确认」.
264+
*/
265+
export function usePlanApprovalPendingCount(): number {
266+
const readings = useSyncExternalStore(
238267
subscribePlanApproval,
239268
getPlanApprovalOwners,
240269
getPlanApprovalOwners,
241270
);
242271
const { user } = useAuth();
243272
const owner = user?.id ?? '';
244-
for (const o of owners.values()) if (o === owner) return true;
245-
return false;
273+
let count = 0;
274+
for (const r of readings.values()) if (r.owner === owner) count += r.count;
275+
return count;
246276
}
247277

248278
/** Subscribe a component to the assistant bus snapshot. */

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,15 +2299,23 @@ export function ChatPane({
22992299
// open: an open re-reads the same thread and reports the same state.
23002300
const { user: planApprovalUser } = useAuth();
23012301
const planApprovalUserId = planApprovalUser?.id;
2302+
// objectstack#25943 — the reading carries the count of items awaiting
2303+
// confirmation too (Studio's right slot shows 「N 项待确认」).
23022304
const [planApprovalPending, setPlanApprovalPending] = useState<boolean | undefined>(undefined);
2305+
const [planApprovalCount, setPlanApprovalCount] = useState(0);
2306+
const onPlanApprovalPendingChange = useCallback((pending: boolean, count: number) => {
2307+
setPlanApprovalPending(pending);
2308+
setPlanApprovalCount(count);
2309+
}, []);
23032310
useEffect(() => {
23042311
if (!conversationId || planApprovalPending === undefined) return;
23052312
publishPlanApprovalPending({
23062313
userId: planApprovalUserId,
23072314
conversationId,
23082315
pending: planApprovalPending,
2316+
count: planApprovalCount,
23092317
});
2310-
}, [planApprovalUserId, conversationId, planApprovalPending]);
2318+
}, [planApprovalUserId, conversationId, planApprovalPending, planApprovalCount]);
23112319

23122320
// A1.b switcher menu: every published app with a package identity, deduped
23132321
// by package (apps sharing a package share the build thread — the scope is
@@ -2912,7 +2920,7 @@ export function ChatPane({
29122920
onBuildMaterialized={handleBuildMaterialized}
29132921
onBuildResult={handleBuildResult}
29142922
// objectui#11666 — mirrored onto the assistant bus (effect above).
2915-
onPlanApprovalPendingChange={setPlanApprovalPending}
2923+
onPlanApprovalPendingChange={onPlanApprovalPendingChange}
29162924
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
29172925
data-testid="ai-chat-panel"
29182926
/>

‎ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import type { AgentDescriptor } from '@object-ui/plugin-chatbot';
5757
import { ChatPane } from '../AiChatPage';
5858
import { ConsoleChatbotFab } from '../../../layout/ConsoleChatbotFab';
5959
import { useConversationList } from '../../../hooks/useConversationList';
60-
import { publishPlanApprovalPending } from '../../../assistant/assistantBus';
60+
import { publishPlanApprovalPending, usePlanApprovalPendingCount } from '../../../assistant/assistantBus';
6161

6262
window.matchMedia = ((query: string) => ({
6363
matches: false,
@@ -104,10 +104,15 @@ function renderFab() {
104104
}
105105

106106
/** Play the chat's own reading, as `ChatbotEnhanced` reports it. */
107-
function chatReports(pending: boolean) {
108-
const report = captured.onPlanApprovalPendingChange as (p: boolean) => void;
107+
function chatReports(pending: boolean, count = pending ? 1 : 0) {
108+
const report = captured.onPlanApprovalPendingChange as (p: boolean, c: number) => void;
109109
expect(typeof report).toBe('function');
110-
act(() => report(pending));
110+
act(() => report(pending, count));
111+
}
112+
113+
/** objectstack#25943 — what Studio's right slot reads off the bus. */
114+
function CountProbe() {
115+
return <output data-testid="pending-count">{usePlanApprovalPendingCount()}</output>;
111116
}
112117

113118
afterEach(() => {
@@ -175,4 +180,20 @@ describe('ChatPane → assistant bus → FAB (objectui#11666)', () => {
175180
});
176181
expect(screen.queryByTestId(MARKER)).not.toBeInTheDocument();
177182
});
183+
184+
// objectstack#25943 — the chat's count reaches the bus with its reading.
185+
it("the chat's count of items awaiting confirmation reaches the bus, and its end clears it", () => {
186+
render(<CountProbe />);
187+
renderPane('conv-1');
188+
expect(screen.getByTestId('pending-count')).toHaveTextContent('0');
189+
190+
chatReports(true, 2);
191+
expect(screen.getByTestId('pending-count')).toHaveTextContent('2');
192+
193+
chatReports(true, 3);
194+
expect(screen.getByTestId('pending-count')).toHaveTextContent('3');
195+
196+
chatReports(false);
197+
expect(screen.getByTestId('pending-count')).toHaveTextContent('0');
198+
});
178199
});

‎ui/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { t } from '../metadata-admin/i18n.js';
1313
import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../../console/ai/AiChatPage.js';
1414
import { AiUsageIndicator } from '../../layout/AiUsageIndicator.js';
1515
import { rememberDockReturnLocation } from '../../layout/chatDockState.js';
16-
import { usePlanApprovalPending } from '../../assistant/assistantBus.js';
16+
import { usePlanApprovalPendingCount } from '../../assistant/assistantBus.js';
1717

1818
/**
1919
* objectui#8219 — the display label of the artifact a pillar has open, tagged
@@ -147,7 +147,8 @@ export function StudioCopilotConversation({
147147
/** What the copilot reports up to the slot that hosts it (objectui#12725). */
148148
export interface StudioCopilotStatus {
149149
available: boolean;
150-
planPending: boolean;
150+
/** Items the copilot proposed that await the author's confirmation; 0 when none. */
151+
planPendingCount: number;
151152
}
152153

153154
export interface StudioChatDockProps {
@@ -162,8 +163,9 @@ export interface StudioChatDockProps {
162163
* objectui#12725 — what the right slot draws of the copilot around it:
163164
* `available`, whether the agent catalog serves one (true while it is still
164165
* being read, as the dock always rendered then), so no segment, strip or
165-
* empty body stands for a copilot that is not there; and `planPending`, the
166-
* objectui#11666 reading that a plan it proposed awaits approval. Reported on
166+
* empty body stands for a copilot that is not there; and `planPendingCount`,
167+
* the objectui#11666 reading of a proposal awaiting approval with the number
168+
* of items it asks the author to confirm (objectstack#25943). Reported on
167169
* every change, and as unavailable on unmount.
168170
*/
169171
onStatusChange?: (status: StudioCopilotStatus) => void;
@@ -200,17 +202,17 @@ export function StudioChatDock({
200202
const { agents, isLoading: agentsLoading } = useAgents({ apiBase });
201203
const available = agentsLoading || agents.length > 0;
202204
// objectui#11666 — read off the assistant bus the chat publishes to.
203-
const planPending = usePlanApprovalPending();
205+
const planPendingCount = usePlanApprovalPendingCount();
204206
const onStatusChangeRef = React.useRef(onStatusChange);
205207
React.useEffect(() => {
206208
onStatusChangeRef.current = onStatusChange;
207209
});
208210
React.useEffect(() => {
209-
onStatusChangeRef.current?.({ available, planPending: available && planPending });
210-
}, [available, planPending]);
211+
onStatusChangeRef.current?.({ available, planPendingCount: available ? planPendingCount : 0 });
212+
}, [available, planPendingCount]);
211213
React.useEffect(
212214
() => () => {
213-
onStatusChangeRef.current?.({ available: false, planPending: false });
215+
onStatusChangeRef.current?.({ available: false, planPendingCount: 0 });
214216
},
215217
[],
216218
);

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: ()
168168
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
169169

170170
vi.mock('./StudioAiCopilot', () => ({
171-
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
171+
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
172172
React.useEffect(() => {
173-
onStatusChange?.({ available: true, planPending: false });
173+
onStatusChange?.({ available: true, planPendingCount: 0 });
174174
}, [onStatusChange]);
175175
return <div data-testid="copilot-body">conversation</div>;
176176
},

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.leafColumnSelection-12725.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: ()
117117
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
118118

119119
vi.mock('./StudioAiCopilot', () => ({
120-
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
120+
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
121121
React.useEffect(() => {
122-
onStatusChange?.({ available: true, planPending: false });
122+
onStatusChange?.({ available: true, planPendingCount: 0 });
123123
}, [onStatusChange]);
124124
return <div data-testid="copilot-body">conversation</div>;
125125
},

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.pillarsClaimSlot-12725.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => nul
105105
/** What the copilot stub reports, per test. */
106106
const copilot = vi.hoisted(() => ({ available: true }));
107107
vi.mock('./StudioAiCopilot', () => ({
108-
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => {
108+
StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPendingCount: number }) => void }) => {
109109
React.useEffect(() => {
110-
onStatusChange?.({ available: copilot.available, planPending: false });
110+
onStatusChange?.({ available: copilot.available, planPendingCount: 0 });
111111
}, [onStatusChange]);
112112
return copilot.available ? <div data-testid="copilot-body">conversation</div> : null;
113113
},

0 commit comments

Comments
 (0)