diff --git a/.changeset/12725-copilot-draft-badge.md b/.changeset/12725-copilot-draft-badge.md new file mode 100644 index 0000000000..77dbcc2f64 --- /dev/null +++ b/.changeset/12725-copilot-draft-badge.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio's 变更 badge and the item header follow the copilot's drafts as they land (objectui#12725, item 5). + +- **The badge counts a copilot draft the moment the tool answers.** The copilot's chat host (`ChatPane`) announces each tool result that carries an authoring envelope (a `drafted` result, or a 确认修改 replay's verdict) on the assistant bus as soon as it arrives in the stream. Before, it waited for the whole turn to finish, so the badge kept the old count for as long as the agent went on writing after the tool had answered. Each result is announced once; a result already in the thread when the chat opens is history and is not announced again. No reload and no navigation is involved: the badge's ledger (`_drafts`) re-reads on the pulse, as it already did. +- **The item header shows 「草稿 · N 项未发布」** (en: "Draft · N unpublished") on the Interfaces, Data and Automations pillars while the open item is a pending draft, in place of the "Unpublished draft" chip. It reads the ledger the badge counts, so a draft the copilot writes to the open item shows as it lands, and the line goes when a publish or a discard takes the item off the ledger. N is the badge's number: publishing is package-level, so the line says this item is one of N unpublished changes, and its tooltip says so. Where the ledger could not be read, the header keeps the "Unpublished draft" chip, with no count. + +Clause-②: no. No spec key, export, prop or language-pack key changes; the new copy is two rows of the Studio designer table (en and zh). diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 45a4bfe4ce..ad1d0a6041 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2170,25 +2170,45 @@ export function ChatPane({ onPackageBound?.(builtPackageId); }, [isBuildSurface, isLoading, canBind, builtPackageId, onPackageBound]); - // objectui#5801 — when a turn that STAGED or PUBLISHED something finishes, - // announce it on the bus so every pending-drafts surface (Studio topbar, - // home banner, the bar above this pane) converges immediately — previously - // an agent-staged draft lit only the chat bar (its own idle refetch) while - // the Studio topbar count stayed dark until a manual draft save. Precise on - // purpose: only turns whose tools carried an authoring envelope emit, so an - // ordinary Q&A turn does not trigger an env-wide registry refetch. - const lastAuthoringEmitRef = useRef(undefined); + // objectui#5801 — when a tool STAGES or PUBLISHES something, announce it on + // the bus so every pending-drafts surface (Studio topbar, home banner, the + // bar above this pane) converges immediately — previously an agent-staged + // draft lit only the chat bar (its own idle refetch) while the Studio topbar + // count stayed dark until a manual draft save. Precise on purpose: only tool + // results that carry an authoring envelope emit (a drafted result's + // `draftReview`, or a confirm-replay's `replayOutcome`), so an ordinary Q&A + // turn does not trigger an env-wide registry refetch. + // + // objectui#12725 — announced the moment each result LANDS, not when its turn + // ends. The draft is written before the tool answers (the envelope's own + // `draftState` is read after that write), so the Studio 变更 badge can + // count it while the agent is still narrating; waiting for the stream to + // finish left the badge behind the chat card by the whole narration. Each + // result is announced once, keyed by its message and its position in that + // message: a streamed message is re-mapped on every chunk, and a tool part + // without a `toolCallId` is given a fresh random one each time. The results + // already in the thread when this pane mounts are history, not a landing. + const announcedAuthoringRef = useRef | null>(null); useEffect(() => { - if (isLoading) return; - const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistant || lastAssistant.id === lastAuthoringEmitRef.current) return; - const authored = (lastAssistant.toolInvocations ?? []).some( - (tool) => tool.draftReview || tool.replayOutcome, - ); - if (!authored) return; - lastAuthoringEmitRef.current = lastAssistant.id; - emitMetadataRefresh(); - }, [isLoading, messages]); + const authored: string[] = []; + for (const message of messages) { + (message.toolInvocations ?? []).forEach((tool, index) => { + if (tool.draftReview || tool.replayOutcome) authored.push(`${message.id}#${index}`); + }); + } + const announced = announcedAuthoringRef.current; + if (announced === null) { + announcedAuthoringRef.current = new Set(authored); + return; + } + let landed = false; + for (const key of authored) { + if (announced.has(key)) continue; + announced.add(key); + landed = true; + } + if (landed) emitMetadataRefresh(); + }, [messages]); // objectui#11666 — the launchers (console FAB, ChatDock edge launcher) show // a marker while a proposed plan awaits the user's approval, and they are on diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 92dda925cc..dd714f11f7 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2909,6 +2909,11 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.held.publish': 'Not published: {clause}, so that change is not saved yet. Finish it, then publish.', 'engine.studio.held.inputHint': 'Required. Changes are saved once this has a value.', 'engine.studio.unpublishedDraft': 'Unpublished draft', + // objectui#12725 — the item header's draft line: this item has a pending + // draft, and {count} is the package's pending count (the Changes badge's). + 'engine.studio.itemDraftPending': 'Draft · {count} unpublished', + 'engine.studio.itemDraftPendingTitle': + 'This item has an unpublished draft. The package has {count} unpublished change(s); Publish puts them live together.', 'engine.studio.unpublished': 'Unpublished', 'engine.studio.new': 'New', 'engine.studio.edit': 'Edit', @@ -6267,6 +6272,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.held.publish': '未发布:{clause},该更改尚未保存。请先补全,再发布。', 'engine.studio.held.inputHint': '必填。填写后才会保存更改。', 'engine.studio.unpublishedDraft': '未发布草稿', + 'engine.studio.itemDraftPending': '草稿 · {count} 项未发布', + 'engine.studio.itemDraftPendingTitle': '此项有未发布的草稿。本包共有 {count} 项未发布的更改,发布时一起生效。', 'engine.studio.unpublished': '未发布', 'engine.studio.new': '新建', 'engine.studio.edit': '编辑', diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDraftBadge-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDraftBadge-12725.test.tsx new file mode 100644 index 0000000000..570ecd403c --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDraftBadge-12725.test.tsx @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12725, phase 5 (Target item 5) — the top bar's 变更 badge counts the + * copilot's drafts the moment they land, with no reload and no navigation. + * + * The chain, end to end: + * - the copilot's tool stages the draft server-side and answers + * `status: 'drafted'` (objectstack-ai/cloud#2924; its envelope's own + * `draftState` is read through `listDrafts` AFTER the write); + * - the copilot's chat host, `ChatPane`, sees the result land in the stream + * and announces it on the assistant bus (`emitMetadataRefresh`); + * - the badge's source, `usePendingDrafts`, re-reads `_drafts` on that pulse. + * + * Before this phase the announcement waited for the turn to END: the badge + * stayed on the old count for as long as the agent kept narrating after the + * tool answered. These cases play the stream with the turn still running. + * + * Real: the surface, its top bar, `usePendingDrafts`, the right slot, the + * Studio copilot (`StudioChatDock` → `StudioCopilotConversation` → + * `ChatPane`), and the chat package's own mapping of a tool part + * (`uiMessagesToChatMessages`), fed the producer's envelope shapes. Stubbed: + * `globalThis.fetch` (the server), the chat transport (`useObjectChat` plays a + * scripted stream), the agent catalog, the conversation lookup, and the chat + * body (`ChatbotEnhanced`), which no assertion reads. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const PACKAGE_ID = 'app.hcpl'; +const DRAFTS = `/api/v1/meta/_drafts?packageId=${encodeURIComponent(PACKAGE_ID)}`; + +/** The scripted chat stream the mocked transport serves. */ +const chat = vi.hoisted(() => { + type Snapshot = { messages: unknown[]; isLoading: boolean }; + let snapshot: Snapshot = { messages: [], isLoading: false }; + const listeners = new Set<() => void>(); + return { + get: (): Snapshot => snapshot, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set(next: Snapshot): void { + snapshot = next; + for (const l of listeners) l(); + }, + }; +}); + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useAgents: () => ({ + agents: [{ name: 'metadata_assistant', label: 'Build', capabilities: ['build'] }], + isLoading: false, + loading: false, + error: undefined, + refetch: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + ChatbotEnhanced: () =>
, + useObjectChat: () => { + const s = React.useSyncExternalStore(chat.subscribe, chat.get, chat.get); + return { + messages: s.messages, + isLoading: s.isLoading, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }; + }, + }; +}); + +vi.mock('../../hooks/useChatConversation', async (importOriginal) => ({ + ...(await importOriginal>()), + useChatConversation: () => ({ conversationId: 'conv-hcpl', initialMessages: [] }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' } }), +})); +vi.mock('../../layout/AiUsageIndicator', () => ({ AiUsageIndicator: () => null })); + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn(), message: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); +vi.mock('./packages-io', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + fetchPackages: vi.fn(async () => [{ id: PACKAGE_ID, name: PACKAGE_ID, writable: true, namespace: 'hcpl' }]), + }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { uiMessagesToChatMessages } from '@object-ui/plugin-chatbot'; +import { StudioDesignSurface } from './StudioDesignSurface'; +import { subscribeMetadataRefresh } from '../../assistant/assistantBus'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +/** What `_drafts` answers: the package's pending drafts, server-side. */ +let serverDrafts: Array> = []; +/** How many `_drafts` reads the page sent. */ +let draftReads = 0; + +function json(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +} + +beforeEach(() => { + serverDrafts = []; + draftReads = 0; + chat.set({ messages: [], isLoading: false }); + window.sessionStorage.clear(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url === DRAFTS) { + draftReads += 1; + return json({ drafts: serverDrafts }); + } + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ── The producer's envelopes (objectstack-ai/cloud#2924, cloud `f9e45145`) ── + +const DRAFTED_ROWS = [ + { type: 'object', name: 'hcpl_borrowing', label: '借阅记录', packageId: PACKAGE_ID, updatedAt: null, updatedBy: 'u1' }, + { type: 'view', name: 'hcpl_borrowing.board', label: '借阅看板', packageId: PACKAGE_ID, updatedAt: null, updatedBy: 'u1' }, +]; + +/** `apply_edit` on a live app with records: saved as a draft, `kind: 'edit'`. */ +const APPLY_EDIT_DRAFTED = { + status: 'drafted', + kind: 'edit', + drafted: [ + { type: 'object', name: 'hcpl_borrowing' }, + { type: 'view', name: 'hcpl_borrowing.board' }, + ], + failed: [], + packageId: PACKAGE_ID, + summary: 'Add 续借次数 to 借阅记录 and show it on the board cards', + posture: { mode: 'draft', liveRecords: 12 }, + draftState: { pending: 2, items: [{ type: 'object', name: 'hcpl_borrowing' }, { type: 'view', name: 'hcpl_borrowing.board' }], others: 0 }, + note: 'SAVED AS A STUDIO DRAFT — NOT LIVE.', +}; + +/** A 确认修改 confirm-replay's result: the single-item draft envelope under a `replay_` id. */ +const REPLAY_DRAFTED = { + status: 'drafted', + type: 'object', + name: 'hcpl_borrowing', + packageId: PACKAGE_ID, + summary: 'Add 续借次数', + changedKeys: ['fields.renew_count'], + posture: { mode: 'draft', liveRecords: 12 }, + draftState: { pending: 1, items: [{ type: 'object', name: 'hcpl_borrowing' }], others: 0 }, +}; + +/** A read tool's answer: no authoring envelope. */ +const READ_RESULT = { name: 'hcpl_borrowing', label: '借阅记录', fields: { title: { type: 'text' } } }; + +const USER_TURN = { id: 'm-user-1', role: 'user', parts: [{ type: 'text', text: '给借阅记录加一个续借次数字段' }] }; + +function toolPart(toolName: string, toolCallId: string, output: unknown) { + return { type: `tool-${toolName}`, toolCallId, state: 'output-available', input: {}, output: JSON.stringify(output) }; +} + +/** Play the stream: the thread so far, mapped by the chat package's own reader. */ +function play(uiMessages: unknown[], isLoading: boolean): void { + act(() => { + chat.set({ + messages: uiMessagesToChatMessages(uiMessages as never, { isStreaming: isLoading }), + isLoading, + }); + }); +} + +function LocationProbe(): React.ReactElement { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderStudio() { + return render( + + + + } /> + + , + ); +} + +async function drain() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +/** Mount the surface with an empty ledger and the copilot's chat on screen. */ +async function mountQuiet() { + const view = renderStudio(); + await screen.findByTestId('copilot-chat-body'); + await act(drain); + expect(screen.queryByTestId('studio-changes-count')).toBeNull(); + return view; +} + +describe('the 变更 badge counts the copilot\'s drafts the moment they land (objectui#12725)', () => { + it('a drafted apply_edit result re-reads the badge while the agent is still answering, in place', async () => { + const view = await mountQuiet(); + const surfaceRoot = view.container.lastElementChild; + const location = screen.getByTestId('location').textContent; + + // The turn starts: the agent is thinking, nothing is staged yet. + play([USER_TURN, { id: 'm-asst-1', role: 'assistant', parts: [{ type: 'text', text: '好的,' }] }], true); + await act(drain); + const readsBeforeLanding = draftReads; + + // The tool stages both drafts server-side, then answers `drafted` — and the + // agent goes on narrating: the turn is still running. + serverDrafts = DRAFTED_ROWS; + play( + [ + USER_TURN, + { + id: 'm-asst-1', + role: 'assistant', + parts: [ + { type: 'text', text: '好的,' }, + toolPart('apply_edit', 'call_apply_edit_1', APPLY_EDIT_DRAFTED), + { type: 'text', text: '已存为草稿' }, + ], + }, + ], + true, + ); + + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('2')); + expect(draftReads).toBeGreaterThan(readsBeforeLanding); + // No reload: the same surface tree; no navigation: the same location. + expect(view.container.lastElementChild).toBe(surfaceRoot); + expect(screen.getByTestId('location').textContent).toBe(location); + }); + + it('a 确认修改 replay that saved the change as a draft re-reads the badge as it lands', async () => { + await mountQuiet(); + serverDrafts = DRAFTED_ROWS.slice(0, 1); + play( + [ + USER_TURN, + { + id: 'm-asst-2', + role: 'assistant', + parts: [toolPart('apply_edit', 'replay_t2_0', REPLAY_DRAFTED), { type: 'text', text: '已存为草稿。' }], + }, + ], + true, + ); + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('1')); + }); + + it('announces each result once: later chunks and the turn\'s end send no second pulse for it', async () => { + await mountQuiet(); + let pulses = 0; + const unsubscribe = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + serverDrafts = DRAFTED_ROWS; + const landed = { + id: 'm-asst-3', + role: 'assistant', + parts: [toolPart('apply_edit', 'call_apply_edit_3', APPLY_EDIT_DRAFTED)], + }; + play([USER_TURN, landed], true); + await act(drain); + expect(pulses).toBe(1); + + // More narration streams in, then the turn ends. + play([USER_TURN, { ...landed, parts: [...landed.parts, { type: 'text', text: '已存为草稿,' }] }], true); + play([USER_TURN, { ...landed, parts: [...landed.parts, { type: 'text', text: '已存为草稿,请在「变更」里发布。' }] }], false); + await act(drain); + expect(pulses).toBe(1); + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('2')); + } finally { + unsubscribe(); + } + }); + + it('CONTROL: a tool result with no authoring envelope sends no pulse', async () => { + await mountQuiet(); + let pulses = 0; + const unsubscribe = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + play( + [USER_TURN, { id: 'm-asst-4', role: 'assistant', parts: [toolPart('get_metadata', 'call_read_4', READ_RESULT)] }], + true, + ); + play( + [ + USER_TURN, + { + id: 'm-asst-4', + role: 'assistant', + parts: [toolPart('get_metadata', 'call_read_4', READ_RESULT), { type: 'text', text: '它有一个标题字段。' }], + }, + ], + false, + ); + await act(drain); + // (The chat's own pending-drafts bar re-reads on the turn's idle edge, as + // it always has; that read is its own, not an announcement.) + expect(pulses).toBe(0); + expect(screen.queryByTestId('studio-changes-count')).toBeNull(); + } finally { + unsubscribe(); + } + }); + + it('a drafted result already in the thread when the copilot mounts is history, not a landing', async () => { + chat.set({ + messages: uiMessagesToChatMessages( + [USER_TURN, { id: 'm-asst-old', role: 'assistant', parts: [toolPart('apply_edit', 'call_old', APPLY_EDIT_DRAFTED)] }] as never, + { isStreaming: false }, + ), + isLoading: false, + }); + let pulses = 0; + const unsubscribe = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + serverDrafts = DRAFTED_ROWS; + renderStudio(); + await screen.findByTestId('copilot-chat-body'); + await act(drain); + expect(pulses).toBe(0); + // The badge reads the ledger on its own mount, as it always has. + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('2')); + } finally { + unsubscribe(); + } + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemDraftLine-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemDraftLine-12725.test.tsx new file mode 100644 index 0000000000..d1179e996d --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemDraftLine-12725.test.tsx @@ -0,0 +1,221 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12725, phase 5 (Target item 5) — the item header shows + * 「草稿 · N 项未发布」 while the open item has a pending draft, and N is the + * number the 变更 badge shows. + * + * The case the line exists for: the copilot writes a draft to the object the + * author has open. The Data pillar read that object when it opened it (no + * draft then), and does not re-read it on the copilot's pulse; the ledger the + * badge counts does. So the line follows the ledger: it shows as the draft + * lands, and goes when a publish or a discard takes the item off the ledger. + * + * Real: the surface, its top bar, `usePendingDrafts`, the Data pillar and its + * header. Stubbed: the metadata client (the object, no draft of its own), + * `globalThis.fetch` (the `_drafts` ledger), the copilot (its side — the pulse + * as a drafted result lands — is pinned in + * `StudioDesignSurface.copilotDraftBadge-12725.test.tsx`; here the pulse is + * sent as `ChatPane` sends it), and the Changes panel, reduced to the button + * that runs the surface's own publish. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PACKAGE_ID = 'com.acme.app'; +const DRAFTS = `/api/v1/meta/_drafts?packageId=${encodeURIComponent(PACKAGE_ID)}`; +const OBJECT = { + name: 'acme_task', + label: 'Task', + fields: [{ name: 'title', label: 'Title', type: 'text' }], +}; + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), message: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +/** What `_drafts` answers: the package's pending drafts, server-side. */ +const server = vi.hoisted(() => ({ drafts: [] as Array> })); + +const mockClient = { + save: vi.fn(async () => ({})), + list: vi.fn(async (type: string) => + type === 'app' + ? [{ name: 'acme_app', label: 'Acme' }] + : type === 'object' + ? [{ name: OBJECT.name, label: OBJECT.label }] + : [], + ), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: [] } }; + if (type === 'object') return { effective: OBJECT, code: OBJECT }; + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), + references: vi.fn(async () => []), + publishPackageDrafts: vi.fn(async () => { + const published = server.drafts.map((d) => ({ type: d.type, name: d.name })); + server.drafts = []; + return { success: true, published }; + }), + withPreviewDrafts() { + return this; + }, +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => dataSource }; +}); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ + DraftChangesPanel: ({ onPublish }: { onPublish?: () => void | Promise }) => ( + + ), +})); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { t, tFormat } from '../metadata-admin/i18n'; +import { emitMetadataRefresh } from '../../assistant/assistantBus'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; + +const dataSource = createEmptyDataSource(); + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +beforeEach(() => { + server.drafts = []; + window.sessionStorage.clear(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url === '/api/v1/packages') { + return answer({ success: true, data: { packages: [{ manifest: { id: PACKAGE_ID, name: 'Acme' }, writable: true }] } }); + } + if (url === DRAFTS) return answer({ drafts: server.drafts }); + return answer([]); + }), + ); + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const SLOW = { timeout: 4000 }; +const header = () => screen.getByTestId('data-object-header'); +const headerStatus = () => within(header()).queryByTestId('studio-item-status'); +const draftLine = (count: number) => tFormat('engine.studio.itemDraftPending', 'en', { count }); + +/** The copilot's draft rows: the open object, and a view of it. */ +const COPILOT_DRAFTS = [ + { type: 'object', name: OBJECT.name, label: 'Task', packageId: PACKAGE_ID, updatedAt: null, updatedBy: 'u1' }, + { type: 'view', name: `${OBJECT.name}.board`, label: 'Board', packageId: PACKAGE_ID, updatedAt: null, updatedBy: 'u1' }, +]; + +/** Open the Data pillar on the Task object, with an empty ledger. */ +async function openTask() { + render( + + + } /> + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /Task/ }, SLOW)); + await waitFor(() => expect(within(header()).getByText('Task')).toBeInTheDocument(), SLOW); + expect(headerStatus()).toBeNull(); + expect(screen.queryByTestId('studio-changes-count')).toBeNull(); +} + +/** The copilot's tool wrote drafts server-side, and its result landed. */ +async function copilotDrafts(rows: Array>) { + server.drafts = rows; + await act(async () => { + emitMetadataRefresh(); + }); +} + +describe('the item header\'s draft line follows the badge\'s ledger (objectui#12725)', () => { + it('shows 「草稿 · N 项未发布」 on the open object as the copilot\'s draft lands, N the badge\'s number', async () => { + await openTask(); + await copilotDrafts(COPILOT_DRAFTS); + + await waitFor(() => expect(headerStatus()).toHaveTextContent(draftLine(2))); + expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('2'); + // The longer sentence says what N counts. + expect(headerStatus()).toHaveAccessibleDescription(tFormat('engine.studio.itemDraftPendingTitle', 'en', { count: 2 })); + // The pillar's own reading of the object was never asked again. + expect(screen.queryByText(t('engine.studio.unpublishedDraft', 'en'))).toBeNull(); + }); + + it('is gone after a publish', async () => { + await openTask(); + await copilotDrafts(COPILOT_DRAFTS); + await waitFor(() => expect(headerStatus()).toHaveTextContent(draftLine(2))); + + await act(async () => { + fireEvent.click(screen.getByTestId('changes-panel-publish')); + }); + + expect(mockClient.publishPackageDrafts).toHaveBeenCalledWith(PACKAGE_ID); + await waitFor(() => expect(headerStatus()).toBeNull()); + expect(screen.queryByTestId('studio-changes-count')).toBeNull(); + }); + + it('is gone after a discard takes the item off the ledger', async () => { + await openTask(); + await copilotDrafts(COPILOT_DRAFTS); + await waitFor(() => expect(headerStatus()).toHaveTextContent(draftLine(2))); + + // The object's draft is discarded (the view's stays), and the discarding + // door announces it. + await copilotDrafts(COPILOT_DRAFTS.slice(1)); + + await waitFor(() => expect(headerStatus()).toBeNull()); + expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('1'); + }); + + it('CONTROL: a draft of another item is counted by the badge and marks no header', async () => { + await openTask(); + await copilotDrafts([ + { type: 'object', name: 'acme_project', label: 'Project', packageId: PACKAGE_ID, updatedAt: null, updatedBy: 'u1' }, + ]); + + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('1')); + expect(headerStatus()).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 554ca5c594..adb0557ca0 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -63,6 +63,11 @@ import { useStudioTabRowFold, type StudioItemStatus, } from './StudioItemHeader.js'; +import { + StudioPendingDraftsContext, + useStudioItemDraftStatus, + type StudioPendingDrafts, +} from './studioItemDraftStatus.js'; import { overlayPath, packagedAutomationPath } from './BuilderLanding.js'; import { DATA_CONTENT_CONTAINER, DATA_TAB_FLAT_ONLY, DATA_TAB_FOLDED_ONLY } from './dataTabsFold.js'; import { StudioRail, StudioRailGroup, StudioRailRow } from './StudioRail.js'; @@ -1507,6 +1512,17 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React }, []); const shownPendingCount = behindSave ? Math.max(pendingCount ?? 0, 1) : pendingCount; const hasPending = (shownPendingCount ?? 0) > 0; + // objectui#12725 — the same ledger and number, for the pillars' item headers + // (「草稿 · N 项未发布」): the rows the count above counts, so the package-less + // scope offers its org-scope flow drafts only. + const pillarPendingDrafts = React.useMemo( + () => ({ + count: shownPendingCount, + entries: packageId !== null ? pendingEntries : pendingEntries.filter(isOrgScopeDraft), + behindSave, + }), + [shownPendingCount, packageId, pendingEntries, behindSave], + ); const publishNoneReasonId = React.useId(); const publishReadOnlyReasonId = React.useId(); @@ -2005,6 +2021,9 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React )}
+ {/* objectui#12725 — the pillars' item headers read the ledger the + badge counts (`studioItemDraftStatus.ts`). */} + {/* objectui#11272 — Data and Automations are keyed by package too, for the reason written at `InterfacesPillar` below. Unkeyed, each list effect kept the open item (it keeps the current one @@ -2088,6 +2107,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React /> )} +
@@ -3873,6 +3893,12 @@ export function InterfacesPillar({ // Only the open leaf's own buffer is held (objectui#11272). const leafHeld = draftLoaded ? leafIncomplete : null; useHeldEditReport(onHeldEditChange, leafHeld?.clause ?? null); + // objectui#12725 — the header's draft line reads the badge's ledger. + const leafDraftStatus = useStudioItemDraftStatus( + current ? { type: current.type, name: current.name } : null, + hasDraft && draftLoaded, + locale, + ); // objectui#11823 — an `object` leaf's list view: the third buffer, edited in // the Properties panel and shown on the canvas, autosaved to the package @@ -4463,7 +4489,7 @@ export function InterfacesPillar({ } // objectui#12524 — the draft status sits after the kind chip, as in // every pillar; the right slot holds actions only. - status={hasDraft && draftLoaded ? { label: t('engine.studio.unpublishedDraft', locale) } : undefined} + status={leafDraftStatus} actions={ <> {/* objectui#5813 — drafts auto-save; the spinner is the affordance. */} @@ -5903,6 +5929,12 @@ export function DataPillar({ // Only the open object's own buffer is held (objectui#11272). const objHeld = objLoaded ? objIncomplete : null; useHeldEditReport(onHeldEditChange, objHeld?.clause ?? null); + // objectui#12725 — the header's draft line reads the badge's ledger. + const objDraftStatus = useStudioItemDraftStatus( + current ? { type: 'object', name: current.name } : null, + hasDraft && objLoaded, + locale, + ); // "+ add field": append a field with the label and type the Add field // popover asked for (objectui#12516) and select it for editing in the panel. @@ -6195,7 +6227,7 @@ export function DataPillar({ titleProps={current ? undefined : { className: 'text-[13px] font-normal text-muted-foreground' }} apiName={current?.name} meta={current && objLoaded ? tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) : undefined} - status={hasDraft && objLoaded ? { label: t('engine.studio.unpublishedDraft', locale) } : undefined} + status={objDraftStatus} actions={ <> {/* objectui#5813 — drafts auto-save (see useDraftAutoSave above); the @@ -7404,6 +7436,12 @@ export function AutomationsPillar({ // Only the open flow's own buffer is held (objectui#11272). const flowHeld = flowLoaded ? flowIncomplete : null; useHeldEditReport(onHeldEditChange, flowHeld?.clause ?? null); + // objectui#12725 — the header's draft line reads the badge's ledger. + const flowDraftStatus = useStudioItemDraftStatus( + current ? { type: 'flow', name: current.name } : null, + hasDraft && flowLoaded, + locale, + ); // Enable/disable persists via the flow's deployment `status` (active = on, // obsolete = off) — the engine honors it on the next publish. The switch flips @@ -7870,7 +7908,7 @@ export function AutomationsPillar({ {t('engine.studio.auto.designersMissing', locale)} ) } - status={hasDraft && flowLoaded ? { label: t('engine.studio.unpublishedDraft', locale) } : undefined} + status={flowDraftStatus} actions={ <> {/* objectui#5813 — drafts auto-save; the spinner is the affordance. */} diff --git a/packages/app-shell/src/views/studio-design/__tests__/studioItemDraftStatus-12725.test.ts b/packages/app-shell/src/views/studio-design/__tests__/studioItemDraftStatus-12725.test.ts new file mode 100644 index 0000000000..befad2b6b6 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/__tests__/studioItemDraftStatus-12725.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12725 (Target item 5) — the item header's draft line reads the + * ledger the 变更 badge counts. The rules, one case each: the ledger decides + * whether the open item is a pending draft; N is the badge's number; the + * pillar's own reading counts only while the ledger is behind a Studio save; + * and with no ledger in hand the header keeps the chip it always had. + * End-to-end behaviour on a mounted pillar is pinned in + * `StudioDesignSurface.itemDraftLine-12725.test.tsx`. + */ + +import { describe, expect, it } from 'vitest'; +import { studioItemDraftStatus, type StudioPendingDrafts } from '../studioItemDraftStatus'; +import { t, tFormat } from '../../metadata-admin/i18n'; + +const ITEM = { type: 'object', name: 'hcpl_borrowing' }; + +function ledger(over: Partial = {}): StudioPendingDrafts { + return { count: 0, entries: [], behindSave: false, ...over }; +} + +const line = (count: number, locale = 'en') => ({ + label: tFormat('engine.studio.itemDraftPending', locale, { count }), + title: tFormat('engine.studio.itemDraftPendingTitle', locale, { count }), +}); + +describe('the item header draft line (objectui#12725)', () => { + it('shows while the ledger lists the item, with the badge\'s number', () => { + const pending = ledger({ + count: 2, + entries: [ITEM, { type: 'view', name: 'hcpl_borrowing.board' }], + }); + expect(studioItemDraftStatus(pending, ITEM, false, 'en')).toEqual(line(2)); + expect(studioItemDraftStatus(pending, ITEM, false, 'zh-CN')).toEqual(line(2, 'zh-CN')); + }); + + it('folds a stored type spelling as every /meta reader does', () => { + const pending = ledger({ count: 1, entries: [{ type: 'objects', name: 'hcpl_borrowing' }] }); + expect(studioItemDraftStatus(pending, ITEM, false, 'en')).toEqual(line(1)); + }); + + it('is absent when the ledger does not list the item, though the badge counts others', () => { + const pending = ledger({ count: 3, entries: [{ type: 'object', name: 'hcpl_book' }, { type: 'flow', name: 'hcpl_borrowing' }] }); + expect(studioItemDraftStatus(pending, ITEM, false, 'en')).toBeUndefined(); + }); + + it('a draft the pillar read at load but the ledger no longer lists (a discard elsewhere) shows nothing', () => { + expect(studioItemDraftStatus(ledger({ count: 0 }), ITEM, true, 'en')).toBeUndefined(); + }); + + it('the pillar\'s own save shows the line before the ledger read it asked for answers', () => { + // The badge says at least one from the render the save lands in (objectui#11787). + expect(studioItemDraftStatus(ledger({ count: 1, behindSave: true }), ITEM, true, 'en')).toEqual(line(1)); + // Never a zero beside a draft. + expect(studioItemDraftStatus(ledger({ count: 0, behindSave: true }), ITEM, true, 'en')).toEqual(line(1)); + // Behind someone's save, but this item has no draft of its own: nothing. + expect(studioItemDraftStatus(ledger({ count: 1, behindSave: true }), ITEM, false, 'en')).toBeUndefined(); + }); + + it('with no ledger in hand keeps the chip it always had, and no count it does not know', () => { + const legacy = { label: t('engine.studio.unpublishedDraft', 'en') }; + expect(studioItemDraftStatus(null, ITEM, true, 'en')).toEqual(legacy); + expect(studioItemDraftStatus(null, ITEM, false, 'en')).toBeUndefined(); + expect(studioItemDraftStatus(ledger({ count: null }), ITEM, true, 'en')).toEqual(legacy); + expect(studioItemDraftStatus(ledger({ count: null }), ITEM, false, 'en')).toBeUndefined(); + }); + + it('no open item, no line', () => { + expect(studioItemDraftStatus(ledger({ count: 2, entries: [ITEM] }), null, true, 'en')).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/studioItemDraftStatus.ts b/packages/app-shell/src/views/studio-design/studioItemDraftStatus.ts new file mode 100644 index 0000000000..6c981c2758 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/studioItemDraftStatus.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12725 (Target item 5) — the item header's draft line, + * 「草稿 · N 项未发布」: the open item has a pending draft, and N is the number + * the top bar's 变更 badge shows. + * + * ## One source with the badge + * + * The line reads the same `_drafts` ledger the badge counts + * (`usePendingDrafts` in `StudioDesignSurface`), provided to the pillars by + * {@link StudioPendingDraftsContext}. The ledger is re-read on the surface's + * mount, on a publish, on a Studio draft save, and on the assistant bus's + * pulse, which the copilot sends the moment a `drafted` tool result lands + * (`ChatPane`). So a draft the copilot wrote to the open item shows on its + * header as it lands, and the line goes when a publish or a discard takes the + * item off the ledger, whichever door did it. + * + * The pillar's own reading of its item (`hasDraft`, set when it loads or saves + * the item) is taken once at load and is not re-read on the pulse: on its own + * it would miss a draft the copilot wrote after the load, and keep a draft that + * a discard elsewhere removed. It counts here only while the ledger is behind + * a save this surface made (`behindSave`, objectui#11787), so the line shows + * from the render the save lands in. + * + * ## N + * + * The package's pending count: publishing is package-level (every pending + * draft of the package goes live together; there is no per-item publish), so + * the line tells the author this item is one of N unpublished changes, the N + * the badge and the copilot's 「已存为草稿 · N 项」 card both show. + * + * ## Where the ledger is not in hand + * + * A pillar mounted without the surface (no context), or a ledger that could + * not be read (`count: null`), keeps the chip it always had: the pillar's own + * reading, labelled "Unpublished draft", with no count it does not know. + */ + +import * as React from 'react'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { t, tFormat } from '../metadata-admin/i18n.js'; +import type { StudioItemStatus } from './StudioItemHeader.js'; + +/** The pending-drafts ledger as the surface holds it for its pillars. */ +export interface StudioPendingDrafts { + /** The 变更 badge's number; `null` while the ledger is unknown. */ + count: number | null; + /** The ledger's rows the badge counts: each row's stored type and name. */ + entries: ReadonlyArray<{ type: string; name: string }>; + /** A Studio draft save landed and no ledger read sent after it has answered. */ + behindSave: boolean; +} + +/** Provided by `StudioDesignSurface` around its pillars. */ +export const StudioPendingDraftsContext = React.createContext(null); + +/** The item a pillar's header names, as `/meta` addresses it. */ +export interface StudioDraftItem { + type: string; + name: string; +} + +/** + * The header's status chip for `item`, or `undefined` for none. `ownDraft` is + * the pillar's own reading of the item (a pending draft found at load or + * written by its save). + */ +export function studioItemDraftStatus( + pending: StudioPendingDrafts | null, + item: StudioDraftItem | null, + ownDraft: boolean, + locale: string | undefined, +): StudioItemStatus | undefined { + if (!item) return undefined; + if (pending === null || pending.count === null) { + return ownDraft ? { label: t('engine.studio.unpublishedDraft', locale) } : undefined; + } + // A stored type spelling is folded as every `/meta` reader folds it. + const listed = pending.entries.some( + (entry) => entry.name === item.name && canonicalMetaUrlType(entry.type) === item.type, + ); + if (!listed && !(ownDraft && pending.behindSave)) return undefined; + // The badge's number, which is at least one while a save is uncounted. + const count = Math.max(pending.count, 1); + return { + label: tFormat('engine.studio.itemDraftPending', locale, { count }), + title: tFormat('engine.studio.itemDraftPendingTitle', locale, { count }), + }; +} + +/** {@link studioItemDraftStatus} over the surface's ledger. */ +export function useStudioItemDraftStatus( + item: StudioDraftItem | null, + ownDraft: boolean, + locale: string | undefined, +): StudioItemStatus | undefined { + const pending = React.useContext(StudioPendingDraftsContext); + return studioItemDraftStatus(pending, item, ownDraft, locale); +}