From a1babb11eb1eaa0110eeb0b8c396a81af1ea908b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:07:18 +0000 Subject: [PATCH] feat(app-shell): the copilot's applied card opens the change review and undoes the draft; the badge follows an in-turn publish; the chat fits the 1010 px slot (objectui#12894, objectui#12725) - ChatPane passes onReviewDrafts / onDiscardDrafts to ChatbotEnhanced. Review opens Studio's Changes drawer through its one declared way in, `?changes=review` (studioChangesLink.ts): in place inside the package's Studio, a navigation to the package's Studio from anywhere else. Undo is the SDK client's packages.discardDrafts, then the bus pulse. - StudioDesignSurface reads `?changes=review`, opens the drawer to review and drops the param. - ChatPane's authoring pulse also fires on a non-replay `published` result (detectAuthoringVerdict): cloud's in-turn auto-publish. - The chat column is `min-w-0`, so a tool card's nowrap summary no longer widens the chat past the Studio copilot's slot. Claude-Session: https://claude.ai/code/session_01Cbdek8SKSHxddGqTPZXdqX Co-authored-by: Claude --- .../12894-copilot-applied-card-doors.md | 12 + content/docs/guide/console.md | 1 + .../app-shell/src/console/ai/AiChatPage.tsx | 70 +++- ...AiChatPage.appliedCardDoors-12894.test.tsx | 191 +++++++++ ...oDesignSurface.copilotDoors-12894.test.tsx | 367 ++++++++++++++++++ ...gnSurface.copilotDraftBadge-12725.test.tsx | 41 ++ .../studio-design/StudioDesignSurface.tsx | 28 +- .../views/studio-design/studioChangesLink.ts | 61 +++ 8 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 .changeset/12894-copilot-applied-card-doors.md create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.appliedCardDoors-12894.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDoors-12894.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/studioChangesLink.ts diff --git a/.changeset/12894-copilot-applied-card-doors.md b/.changeset/12894-copilot-applied-card-doors.md new file mode 100644 index 0000000000..e41d9d8936 --- /dev/null +++ b/.changeset/12894-copilot-applied-card-doors.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': minor +--- + +The Studio copilot's applied card can open the change review and undo the draft; the 变更 badge follows an in-turn publish; the chat fits the copilot's slot at a 1010 px window (objectui#12894, objectui#12725). + +- **查看变更并发布 opens Studio's 变更 drawer for the package.** The card the copilot draws after it saves a change as a draft now offers the review in place of its inline Publish button. In the Studio copilot the drawer opens where the user is: same pillar, same selection, nothing reloads. From the full-page chat or the console dock, the button opens the package's Studio with the drawer open. The way in is a Studio URL parameter, `?changes=review`: the surface opens the drawer to review the pending drafts and drops the parameter, so a refresh or Back does not reopen it. +- **撤销 discards the drafts through `POST /packages/:id/discard-drafts`.** The card asks first when the package holds other drafts, as it already did. A draft the server could not drop is shown on the card with the server's reason. Either way the 变更 badge and the other pending-drafts views re-read. +- **The 变更 badge re-reads when a tool publishes in the turn.** Where the environment publishes AI changes immediately, the tool's answer says `published` and every pending draft of the package went live with it. That answer now updates the badge as it lands, as a saved draft already did. +- **The chat fits the Studio copilot's slot.** In the 300 px slot at a 1010 px window, a tool card's one-line summary used to widen the chat beyond the slot, so the share button, the ends of the messages and the send button were cut off. The chat now follows the slot's width, and the summary is truncated as designed. + +Clause-②: no. No export, prop, spec key or language-pack key changes. The Studio route reads one new page-scoped URL parameter, `changes`. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index 4563ec282f..5baa50e9c5 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -36,6 +36,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso | **Create App in Studio** | In a package that has no app yet, the Studio header's **Create app** saves the new app as a draft and then opens the **Interfaces** pillar, where that app is designed. Leaving a pillar that holds an unsent edit asks first. | | **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an environment overlay is decided per metadata type (`allowEnvironmentOverlay`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". | | **Edit app (Studio)** | Workspace admins get an **Edit app** button in the top bar inside a running app (the pencil alone on narrow screens), and an **Edit APP in Studio** row at the end of the app switcher, both opening the app's owning package on the Studio design surface. On an interface route — a dashboard, page, or report — they deep-link straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) they open the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | +| **Studio Copilot Drafts** | When the AI copilot saves a change to a live app as a draft, its card offers **Review changes and publish** and **Undo**. Review opens Studio's **Changes** drawer for the package: in the Studio copilot it opens in place (same pillar, same selection), and from the full-page chat it opens the package's Studio with the drawer open. The drawer's link is `/studio/:packageId/:tab?changes=review`; Studio opens the drawer to review the pending drafts and drops the parameter. Undo discards the package's pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other drafts are pending. The top bar's **Changes** count follows the copilot as its tools answer, including a change the environment publishes immediately. | | **Dashboard Refresh** | A dashboard page shows a **Refresh All** button at the right of its page header, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted, and a dataset-bound single-value (KPI) tile re-reads too. `0` or no value means no automatic refresh. | | **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. | | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index ad1d0a6041..cf440a8cc9 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -78,6 +78,7 @@ import { detectRecordHandoff, detectProposedChanges, detectReplayOutcome, + detectAuthoringVerdict, detectBuiltAppPackage, detectPendingApproval, buildProgressFromDraftReview, @@ -132,6 +133,7 @@ import { chatConversationScope, chatProductOfAgent } from '../../hooks/chatScope import { ConversationsSidebar } from './ConversationsSidebar.js'; import { LiveCanvas } from './LiveCanvas.js'; import { artifactStudioPath } from './artifactStudioPath.js'; +import { studioChangesReviewTarget } from '../../views/studio-design/studioChangesLink.js'; import { BuildDebugDrawer } from './BuildDebugDrawer.js'; import { isConversationZh } from './conversationLanguage.js'; import { resolveOutboundAgentText, type OutboundAgentTextKey } from './outboundAgentText.js'; @@ -1726,6 +1728,21 @@ export function ChatPane({ [navigate], ); + // objectui#12894 — 查看变更并发布 on the applied card of a change the copilot + // saved as a draft (cloud#2924): open Studio's 变更 drawer for the package + // through its one declared way in, `?changes=review` (`studioChangesLink.ts`). + // Inside that package's Studio (the copilot) the param goes onto the current + // location, so the surface stays mounted; anywhere else (this full page, the + // console dock) the link opens the package's Studio with the drawer open. + const location = useLocation(); + const reviewDrafts = useCallback( + (packageId: string) => { + const target = studioChangesReviewTarget(packageId, location); + navigate(target.to, { replace: target.replace }); + }, + [navigate, location], + ); + // ── ADR-0037 Live Canvas ──────────────────────────────────────────────── // When a build session drafts an `app`, open the split-view canvas: the // drafted app rendered as-if-published (`?preview=draft`) beside the chat. @@ -1749,6 +1766,32 @@ export function ChatPane({ // from (into a built object) via ExcelImportBar. Set when a sheet is sent, // cleared on import/dismiss. `dataSource` drives the wizard's schema + import. const dataSource = useAdapter(); + // objectui#12894 — 撤销 on the same applied card: `POST + // /packages/:id/discard-drafts`, which drops EVERY pending draft of the + // package (the card asks first when others are pending). Through the SDK + // client the console's adapter holds — `packages.discardDrafts`, typed by the + // spec's `DiscardPackageDraftsResponse` — rather than a second bare fetch of + // the route. A draft the server could not drop is a failure the card shows, + // with the server's reason. Either way every pending-drafts surface re-reads + // on the assistant bus's pulse, Studio's 变更 badge among them. + const discardDrafts = useCallback( + async (packageId: string): Promise<{ ok: boolean; error?: string }> => { + if (!dataSource) return { ok: false }; + try { + const result = await dataSource.getClient().packages.discardDrafts(packageId); + if (!result.success || result.failedCount > 0) { + const reasons = result.failed.map((f) => `${f.type}/${f.name}: ${f.error}`).join('\n'); + return { ok: false, ...(reasons ? { error: reasons } : {}) }; + } + return { ok: true }; + } catch (e) { + return { ok: false, ...(e instanceof Error && e.message ? { error: e.message } : {}) }; + } finally { + emitMetadataRefresh(); + } + }, + [dataSource], + ); const [pendingSheet, setPendingSheet] = useState(null); const canvasTimerRef = useRef(null); useEffect(() => () => { @@ -2188,12 +2231,22 @@ export function ChatPane({ // 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. + // + // A `status: 'published'` result that is not a confirm-replay carries + // neither: the chat lifts `draftReview` for `drafted` only and + // `replayOutcome` for `replay_` ids only. Cloud's `maybeAutoPublish` + // publishes in the turn and rewrites the tool's envelope to it, promoting + // EVERY pending draft of the package, so the badge must re-read on it too + // (objectui#12725). `detectAuthoringVerdict` is the chat package's one + // reader of the authoring envelope, whatever its posture. const announcedAuthoringRef = useRef | null>(null); useEffect(() => { const authored: string[] = []; for (const message of messages) { (message.toolInvocations ?? []).forEach((tool, index) => { - if (tool.draftReview || tool.replayOutcome) authored.push(`${message.id}#${index}`); + if (tool.draftReview || tool.replayOutcome || detectAuthoringVerdict(tool.result)) { + authored.push(`${message.id}#${index}`); + } }); } const announced = announcedAuthoringRef.current; @@ -2447,12 +2500,20 @@ export function ChatPane({ ) : null} + {/* objectui#12725 — `min-w-0`: the column is a flex item, and a flex + item's automatic minimum width is its content's min-content width. + A tool card's one-line summary (`truncate`, nowrap) contributes its + whole text to that, so the column outgrew the Studio copilot's + 300 px slot at a 1010 px window and the share button, the bubbles' + ends and the send button were cut (a one-off reading, on + objectui#12725). Bounded by its parent, the column follows the slot + and the summary truncates as it was written to. */}
@@ -2688,6 +2749,11 @@ export function ChatPane({ return false; } }} + // objectui#12894 — the applied card's two doors (handlers above). With + // the review door wired, the card offers 查看变更并发布 in place of its + // inline Publish; 撤销 is drawn only while the adapter is up. + onReviewDrafts={reviewDrafts} + onDiscardDrafts={dataSource ? discardDrafts : undefined} publishDraftsLabel={t('console.ai.publishDrafts', { defaultValue: 'Publish' })} publishedLabel={t('console.ai.published', { defaultValue: 'Published' })} nextStepsLabel={t('console.ai.nextSteps', { defaultValue: "What's next" })} diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.appliedCardDoors-12894.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.appliedCardDoors-12894.test.tsx new file mode 100644 index 0000000000..2317e366fa --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.appliedCardDoors-12894.test.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12894 (the console wiring) and objectui#12725 phase 6 — what + * `ChatPane` hands `ChatbotEnhanced` for the copilot's applied card, and the + * column it hosts the chat in. + * + * - 查看变更并发布 (`onReviewDrafts`) opens Studio's 变更 drawer through its one + * declared way in, `?changes=review`: on one of the package's Studio pillars + * the param goes onto the current location (replace, nothing remounts; the + * in-place half is pinned end to end in + * `StudioDesignSurface.copilotDoors-12894.test.tsx`); anywhere else — this + * full-page chat, the console dock — the link opens the package's Studio. + * - 撤销 (`onDiscardDrafts`) is the SDK client's `packages.discardDrafts`, and + * is offered only while the console's adapter is up. + * - The chat column is bounded by its parent (`min-w-0`), so the chat follows + * the Studio copilot's slot instead of the min-content width of a one-line + * tool summary. No layout runs here; the measured widths, before and after, + * are on objectui#12725 phase 6's evidence branch. + * + * Same harness as `AiChatPage.appContextComposer-11658.test.tsx`: the real pane, + * with `ChatbotEnhanced` replaced by a prop recorder. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import React from 'react'; + +let captured: Record = {}; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ChatbotEnhanced: (props: Record) => { + captured = props; + return
; + }, + useObjectChat: () => ({ + messages: [], + isLoading: false, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + }; +}); + +const discardDrafts = vi.fn(); +let adapter: unknown = null; +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useMetadata: () => ({ apps: [] }) }; +}); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => adapter }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const AGENTS = [{ name: 'build', label: 'Build' }] as unknown as AgentDescriptor[]; + +function LocationProbe(): React.ReactElement { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderPane(entry: string, editPackageId?: string) { + cleanup(); + captured = {}; + render( + + + + + + , + ); + return captured; +} + +beforeEach(() => { + window.localStorage.clear(); + discardDrafts.mockReset(); + adapter = { getClient: () => ({ packages: { discardDrafts } }) }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ success: true, data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('objectui#12894 — 查看变更并发布 opens Studio\'s 变更 drawer', () => { + it('from the full-page chat: the package\'s Studio, with the drawer requested', () => { + const props = renderPane('/ai/build/conv-1?package=app.hcpl', 'app.hcpl'); + const onReviewDrafts = props.onReviewDrafts as (packageId: string) => void; + expect(typeof onReviewDrafts).toBe('function'); + act(() => onReviewDrafts('app.hcpl')); + expect(screen.getByTestId('location').textContent).toBe('/studio/app.hcpl/interfaces?changes=review'); + }); + + it('on one of the package\'s Studio pillars: the same pillar and selection, with the param added', () => { + const props = renderPane('/studio/app.hcpl/data?surface=object%3Ahcpl_borrowing', 'app.hcpl'); + act(() => (props.onReviewDrafts as (packageId: string) => void)('app.hcpl')); + expect(screen.getByTestId('location').textContent).toBe( + '/studio/app.hcpl/data?surface=object%3Ahcpl_borrowing&changes=review', + ); + }); + + it('CONTROL: on ANOTHER package\'s Studio, the card\'s own package opens', () => { + const props = renderPane('/studio/app.other/data', 'app.other'); + act(() => (props.onReviewDrafts as (packageId: string) => void)('app.hcpl')); + expect(screen.getByTestId('location').textContent).toBe('/studio/app.hcpl/interfaces?changes=review'); + }); +}); + +describe('objectui#12894 — 撤销 is the SDK client\'s discard-drafts', () => { + it('calls `packages.discardDrafts` with the card\'s package and reports success', async () => { + discardDrafts.mockResolvedValue({ success: true, discardedCount: 2, failedCount: 0, discarded: [], failed: [] }); + const props = renderPane('/ai/build/conv-1?package=app.hcpl', 'app.hcpl'); + const onDiscardDrafts = props.onDiscardDrafts as (packageId: string) => Promise; + await expect(onDiscardDrafts('app.hcpl')).resolves.toEqual({ ok: true }); + expect(discardDrafts).toHaveBeenCalledWith('app.hcpl'); + }); + + it('a thrown request is a failure carrying its message', async () => { + discardDrafts.mockRejectedValue(new Error('Forbidden')); + const props = renderPane('/ai/build/conv-1?package=app.hcpl', 'app.hcpl'); + await expect((props.onDiscardDrafts as (p: string) => Promise)('app.hcpl')).resolves.toEqual({ + ok: false, + error: 'Forbidden', + }); + }); + + it('CONTROL: with no adapter yet, no 撤销 is offered', () => { + adapter = null; + const props = renderPane('/ai/build/conv-1?package=app.hcpl', 'app.hcpl'); + expect(props.onDiscardDrafts).toBeUndefined(); + expect(typeof props.onReviewDrafts).toBe('function'); + }); +}); + +describe('objectui#12725 — the chat column follows its slot', () => { + it('the column that hosts the chat is bounded by its parent (`min-w-0`), not by its content', () => { + renderPane('/studio/app.hcpl/interfaces', 'app.hcpl'); + const column = screen.getByTestId('ai-chat-panel').closest('[data-chat-column]'); + expect(column).not.toBeNull(); + expect(column!.className.split(/\s+/)).toContain('min-w-0'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDoors-12894.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDoors-12894.test.tsx new file mode 100644 index 0000000000..b99025457e --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.copilotDoors-12894.test.tsx @@ -0,0 +1,367 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12894 (the console wiring), carried by objectui#12725 phase 6 — the + * two doors on the Studio copilot's applied card, end to end. + * + * A change the copilot saved as a draft (objectstack-ai/cloud#2924) ends in the + * applied card 「已存为草稿 · N 项」 with 查看变更并发布 and 撤销. The card is + * `plugin-chatbot`'s; the doors are the host's, and `ChatPane` is the host the + * Studio copilot mounts: + * + * - 撤销 → `POST /packages/:id/discard-drafts` for the card's package (through + * the SDK client the console's adapter holds), then the assistant bus's + * pulse, so the top bar's 变更 badge re-reads its ledger; + * - 查看变更并发布 → the surface's 变更 drawer, through its one declared way in + * (`?changes=review`, `studioChangesLink.ts`). The copilot is already inside + * Studio, so the surface stays mounted: same tree, same pillar, same + * selection, and the param is dropped once read. + * + * Real: the surface, its top bar and `usePendingDrafts`, the right slot, the + * Studio copilot (`StudioChatDock` → `StudioCopilotConversation` → `ChatPane`), + * and the chat itself (`ChatbotEnhanced`, with the chat package's own mapping + * of each tool part), fed the producer's envelope shapes. Stubbed: + * `globalThis.fetch` (the server), the SDK client's `packages.discardDrafts`, + * the chat transport (`useObjectChat` serves a scripted thread), the agent + * catalog, the conversation lookup, and the drawer's body (`DraftChangesPanel`, + * reduced to its `open` and `posture`). + */ + +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, useLocation } from 'react-router-dom'; + +const PACKAGE_ID = 'app.hcpl'; +const DRAFTS = `/api/v1/meta/_drafts?packageId=${encodeURIComponent(PACKAGE_ID)}`; + +/** The scripted thread 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(); + }, + }; +}); + +/** The SDK client's discard door (`packages.discardDrafts`). */ +const sdk = vi.hoisted(() => ({ discardDrafts: vi.fn() })); + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useAgents: () => ({ + agents: [{ name: 'build', label: 'Builder', capabilities: ['build'] }], + isLoading: false, + loading: false, + error: undefined, + refetch: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + // `ChatbotEnhanced` stays REAL: its applied card and the two buttons on it + // are what the user clicks. + 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>(); + const adapter = { getClient: () => ({ packages: { discardDrafts: sdk.discardDrafts } }) }; + return { ...actual, useAdapter: () => adapter }; +}); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ + DraftChangesPanel: ({ open, posture }: { open: boolean; posture?: string }) => ( +
+ ), +})); + +import { uiMessagesToChatMessages } from '@object-ui/plugin-chatbot'; +import { I18nProvider } from '@object-ui/i18n'; +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' } }); +} + +// ── The producer's envelopes (objectstack-ai/cloud#2924) ────────────────── + +const BORROWING_ROW = { + type: 'object', + name: 'hcpl_borrowing', + label: '借阅记录', + packageId: PACKAGE_ID, + updatedAt: null, + updatedBy: 'u1', +}; + +/** The 确认修改 card: `apply_edit` answered `changes_proposed`, draft posture. */ +const PROPOSED = { + status: 'changes_proposed', + applied: false, + changes: [{ verb: 'add_field', object: 'hcpl_borrowing', field: 'renew_count', type: 'number', details: '续借次数 · 默认 0 · 不必填' }], + summary: '这个应用已上线,有 12 条记录在用。改动先存为草稿;画布和右侧属性里能看到效果,你在「变更」里核对后再发布。', + posture: { mode: 'draft', liveRecords: 12 }, + primaryAction: 'save_draft', +}; + +/** Its confirm replay: one draft staged, the applied card's title and lines. */ +const DRAFTED = { + status: 'drafted', + kind: 'edit', + drafted: [{ type: 'object', name: 'hcpl_borrowing' }], + packageId: PACKAGE_ID, + summary: '已起草 1 处改动', + posture: { mode: 'draft', liveRecords: 12 }, + draftState: { pending: 1, items: [{ type: 'object', name: 'hcpl_borrowing' }], others: 0 }, + existingRows: [{ object: 'hcpl_borrowing', field: 'renew_count', label: '续借次数', rows: 12 }], + appliedCard: { title: '已存为草稿 · 1 项未发布', lines: ['已有 12 条记录的续借次数为空', '核对:结构 ✓'] }, +}; + +const THREAD = [ + { id: 'm-user-1', role: 'user', parts: [{ type: 'text', text: '给借阅记录加一个续借次数字段' }] }, + { + id: 'm-asst-1', + role: 'assistant', + parts: [{ type: 'tool-apply_edit', toolCallId: 'call_1', state: 'output-available', input: {}, output: JSON.stringify(PROPOSED) }], + }, + { id: 'm-user-2', role: 'user', parts: [{ type: 'text', text: '存为草稿' }] }, + { + id: 'm-asst-2', + role: 'assistant', + parts: [ + { type: 'tool-apply_edit', toolCallId: 'replay_t2_0', state: 'output-available', input: {}, output: JSON.stringify(DRAFTED) }, + { type: 'text', text: '已存为草稿。' }, + ], + }, +]; + +beforeEach(() => { + serverDrafts = [BORROWING_ROW]; + draftReads = 0; + sdk.discardDrafts.mockReset(); + chat.set({ messages: uiMessagesToChatMessages(THREAD as never, { isStreaming: false }), 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(); +}); + +function LocationProbe(): React.ReactElement { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderStudio(entry = `/studio/${PACKAGE_ID}/interfaces?surface=object%3Ahcpl_borrowing`) { + return render( + + + + } /> + + , + ); +} + +async function drain() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +/** Mount the surface with one pending draft and the applied card on screen. */ +async function mountWithAppliedCard() { + const view = renderStudio(); + const card = await screen.findByTestId('applied-card'); + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('1')); + await act(drain); + return { view, card }; +} + +describe('the Studio copilot\'s applied card opens the 变更 drawer in place (objectui#12894)', () => { + it('查看变更并发布 opens the drawer to review, with no remount and no change of pillar', async () => { + const { view, card } = await mountWithAppliedCard(); + const surfaceRoot = view.container.lastElementChild; + const location = screen.getByTestId('location').textContent; + expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-open', 'false'); + + fireEvent.click(within(card).getByTestId('applied-card-review')); + + await waitFor(() => expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-open', 'true')); + expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-posture', 'review'); + // The param was read and dropped: the URL is the one the user was on. + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe(location)); + // In place: the same surface tree, and the conversation is still there. + expect(view.container.lastElementChild).toBe(surfaceRoot); + expect(screen.getByTestId('applied-card')).toBe(card); + }); + + it('arriving with ?changes=review (the full-page chat\'s link) opens the drawer, then drops the param', async () => { + renderStudio(`/studio/${PACKAGE_ID}/interfaces?changes=review`); + await waitFor(() => expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-open', 'true')); + expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-posture', 'review'); + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe(`/studio/${PACKAGE_ID}/interfaces`)); + }); + + it('CONTROL: a value the surface does not know opens nothing, and is dropped all the same', async () => { + renderStudio(`/studio/${PACKAGE_ID}/interfaces?changes=publish`); + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe(`/studio/${PACKAGE_ID}/interfaces`)); + await act(drain); + expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-open', 'false'); + }); +}); + +describe('the Studio copilot\'s composer (objectui#12725 Target item 4)', () => { + it('zh: the placeholder reads「问问数据,或让我改改这个应用…」, and the chip names the item in view', async () => { + render( + + + + } /> + + + , + ); + const slot = await screen.findByTestId('studio-slot-copilot'); + const composer = await within(slot).findByRole('textbox'); + expect(composer).toHaveAttribute('placeholder', '问问数据,或让我改改这个应用…'); + // The chip `plugin-chatbot` declares (`surfaceContextLabel`), naming the + // item the URL has open. + const chip = within(slot).getByTestId('surface-context-chip'); + expect(chip).toHaveTextContent('正在讨论:'); + expect(chip).toHaveTextContent('hcpl_borrowing'); + }); +}); + +describe('the Studio copilot\'s applied card undoes through discard-drafts (objectui#12894)', () => { + it('撤销 discards the card\'s package and the 变更 badge re-reads its ledger', async () => { + sdk.discardDrafts.mockImplementation(async () => { + serverDrafts = []; + return { + success: true, + discardedCount: 1, + failedCount: 0, + discarded: [{ type: 'object', name: 'hcpl_borrowing' }], + failed: [], + }; + }); + const { card } = await mountWithAppliedCard(); + const readsBefore = draftReads; + + fireEvent.click(within(card).getByTestId('applied-card-discard')); + + await waitFor(() => expect(within(card).getByTestId('applied-card-discarded')).toBeInTheDocument()); + expect(sdk.discardDrafts).toHaveBeenCalledTimes(1); + expect(sdk.discardDrafts).toHaveBeenCalledWith(PACKAGE_ID); + await waitFor(() => expect(screen.queryByTestId('studio-changes-count')).toBeNull()); + expect(draftReads).toBeGreaterThan(readsBefore); + }); + + it('a draft the server could not drop is a failure the card shows with the server\'s reason; the pulse still goes out', async () => { + sdk.discardDrafts.mockImplementation(async () => ({ + success: false, + discardedCount: 0, + failedCount: 1, + discarded: [], + failed: [{ type: 'object', name: 'hcpl_borrowing', error: 'draft is locked by a publish in progress' }], + })); + const { card } = await mountWithAppliedCard(); + let pulses = 0; + const unsubscribe = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + fireEvent.click(within(card).getByTestId('applied-card-discard')); + + const failed = await within(card).findByTestId('applied-card-discard-failed'); + expect(failed).toHaveTextContent('object/hcpl_borrowing: draft is locked by a publish in progress'); + expect(within(card).queryByTestId('applied-card-discarded')).toBeNull(); + expect(pulses).toBe(1); + expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('1'); + } finally { + unsubscribe(); + } + }); +}); 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 index 570ecd403c..31c76197b4 100644 --- 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 @@ -16,6 +16,11 @@ * 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. * + * Phase 6 adds the result phase 5 left out: a `status: 'published'` answer that + * is not a confirm-replay (cloud's in-turn auto-publish, which promotes every + * pending draft of the package) carries neither lifted field, and is announced + * through the chat package's own reader of the envelope. + * * 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 @@ -201,6 +206,18 @@ const REPLAY_DRAFTED = { draftState: { pending: 1, items: [{ type: 'object', name: 'hcpl_borrowing' }], others: 0 }, }; +/** + * The same `apply_edit` where the environment publishes AI changes in the turn + * (cloud `maybeAutoPublish`): the package's drafts are promoted, ALL of them, + * and the envelope is rewritten to `status: 'published'`. Not a replay, so the + * chat lifts neither `draftReview` nor `replayOutcome` from it. + */ +const APPLY_EDIT_PUBLISHED = { + ...APPLY_EDIT_DRAFTED, + status: 'published', + note: 'LIVE. Published in-turn — the table/field/view now exists and is visible to users; no separate Publish step is needed. You may tell the user it is created and live.', +}; + /** A read tool's answer: no authoring envelope. */ const READ_RESULT = { name: 'hcpl_borrowing', label: '借阅记录', fields: { title: { type: 'text' } } }; @@ -331,6 +348,30 @@ describe('the 变更 badge counts the copilot\'s drafts the moment they land (ob } }); + it('a published result that is not a replay (in-turn auto-publish) re-reads the badge as it lands', async () => { + // Two drafts are pending when the turn starts. + serverDrafts = DRAFTED_ROWS; + renderStudio(); + await screen.findByTestId('copilot-chat-body'); + await waitFor(() => expect(screen.getByTestId('studio-changes-count')).toHaveTextContent('2')); + let pulses = 0; + const unsubscribe = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + // The tool publishes in the turn, which promotes every pending draft. + serverDrafts = []; + play( + [USER_TURN, { id: 'm-asst-5', role: 'assistant', parts: [toolPart('apply_edit', 'call_apply_edit_5', APPLY_EDIT_PUBLISHED)] }], + true, + ); + await waitFor(() => expect(screen.queryByTestId('studio-changes-count')).toBeNull()); + expect(pulses).toBe(1); + } finally { + unsubscribe(); + } + }); + it('CONTROL: a tool result with no authoring envelope sends no pulse', async () => { await mountQuiet(); let pulses = 0; diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index adb0557ca0..30523c1a60 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { useParams, useNavigate, Link, Navigate } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams, Link, Navigate } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider, useMetadata } from '@object-ui/react'; // The ONE draft-envelope reader (objectui#8181): unwrap AND strip the // framework's read decorations in one place. This file used to carry its own @@ -226,6 +226,7 @@ import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js import { SourcePageProperties, type SourcePageNavEntry } from '../metadata-admin/previews/SourcePageProperties.js'; import { fetchPendingDrafts, usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; +import { STUDIO_CHANGES_PARAM, STUDIO_CHANGES_REVIEW } from './studioChangesLink.js'; import { dashboardHeldEdit, flowHeldEdit, @@ -1323,6 +1324,31 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // objectui#12517 — which trigger opened the sheet: Publish opens it to // confirm (rows expanded, focus on Publish all), Changes opens it to review. const [changesPosture, setChangesPosture] = React.useState('review'); + // objectui#12894 — the drawer's one way in from outside the top bar: + // `?changes=review` (`studioChangesLink.ts`), written by the copilot's + // applied card (查看变更并发布). Opened to review, then the param is dropped + // (replace) so a refresh or a Back does not reopen it. Read on every change, + // not only at mount: the Studio copilot writes it while the surface is up. + const [searchParams, setSearchParams] = useSearchParams(); + const changesRequest = searchParams.get(STUDIO_CHANGES_PARAM); + React.useEffect(() => { + if (changesRequest === null) return; + if (changesRequest === STUDIO_CHANGES_REVIEW) { + setChangesPosture('review'); + setChangesOpen(true); + } + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete(STUDIO_CHANGES_PARAM); + return next; + }, + { replace: true }, + ); + // Keyed on the param's value alone: `setSearchParams` is re-minted with + // every search change (objectui#8640 — never key on a hook's identity). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changesRequest]); const [publishing, setPublishing] = React.useState(false); const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → a draft save landed (`onDraftSaved`) diff --git a/packages/app-shell/src/views/studio-design/studioChangesLink.ts b/packages/app-shell/src/views/studio-design/studioChangesLink.ts new file mode 100644 index 0000000000..dd43ee6c98 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/studioChangesLink.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one declared way into Studio's 变更 (Changes) drawer from outside its + * top bar (objectui#12894, objectui#12725): `?changes=review` on a Studio + * pillar URL. + * + * `StudioDesignSurface` reads it, opens the drawer in its review posture and + * drops the param (replace), the way the metadata designer consumes + * `?review=1`: a refresh or a Back does not reopen the drawer. It is a request + * to OPEN, not a mirror of the drawer's state; the drawer's own buttons still + * open and close it. + * + * Page-scoped, like `?surface=`: the Studio route's own contract, so it is not + * in `urlParams.ts`'s reserved registry, and no reserved param is named + * `changes`. + * + * The writer is the copilot's applied card (查看变更并发布), through + * `ChatPane`: inside the package's Studio the param goes onto the current + * location, so the surface stays mounted and keeps its selection; from + * anywhere else (the full-page chat, the console dock) the link opens the + * package's Interfaces pillar with it. + */ + +/** The query key the surface reads. */ +export const STUDIO_CHANGES_PARAM = 'changes'; + +/** The one value it acts on: open the drawer to review the pending drafts. */ +export const STUDIO_CHANGES_REVIEW = 'review'; + +/** Where to go, and whether the move replaces the current history entry. */ +export interface StudioChangesTarget { + to: string; + replace: boolean; +} + +/** + * The navigation that opens `packageId`'s 变更 drawer from `current`. + * + * - Already on one of that package's Studio pillars: the same path, search + * and hash with the param set, replacing the entry. No route changes, so + * nothing remounts. + * - Anywhere else: the package's Interfaces pillar with the param, as a new + * entry (Back returns to where the user was). + */ +export function studioChangesReviewTarget( + packageId: string, + current: { pathname: string; search: string; hash?: string }, +): StudioChangesTarget { + const base = `/studio/${encodeURIComponent(packageId)}`; + const onPillar = current.pathname.startsWith(`${base}/`) && current.pathname.length > base.length + 1; + if (onPillar) { + const params = new URLSearchParams(current.search); + params.set(STUDIO_CHANGES_PARAM, STUDIO_CHANGES_REVIEW); + return { to: `${current.pathname}?${params.toString()}${current.hash ?? ''}`, replace: true }; + } + return { + to: `${base}/interfaces?${STUDIO_CHANGES_PARAM}=${STUDIO_CHANGES_REVIEW}`, + replace: false, + }; +}