From ac7edbc6e5be6eb882f1cb39a67733857f2f547e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:15:42 +0000 Subject: [PATCH] fix(app-shell): the Studio copilot draws no pending-drafts bar over its composer (objectui#12965, part of objectui#12725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the package's own Studio, ChatPane no longer mounts PendingDraftsBar. In the copilot's right slot the floating bar sat over the composer's input line (measured at 1010 and 1280 px with the real build agent and one pending draft) and hid the placeholder. There the top bar's 变更 and 发布 and the applied card's 查看变更并发布 review and publish, and the ruled StudioRailCopilot artboard draws no bar. The condition is the reading the applied card's review door already makes: studioChangesReviewTarget replaces the entry exactly when the location is on the bound package's Studio pillar. The full-page chat and the console dock keep the bar unchanged. Claude-Session: https://claude.ai/code/session_01Cbdek8SKSHxddGqTPZXdqX Co-authored-by: Claude --- .../12965-studio-copilot-no-drafts-bar.md | 9 ++ content/docs/guide/console.md | 2 +- .../app-shell/src/console/ai/AiChatPage.tsx | 19 ++- .../AiChatPage.studioDraftsBar-12965.test.tsx | 147 ++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 .changeset/12965-studio-copilot-no-drafts-bar.md create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.studioDraftsBar-12965.test.tsx diff --git a/.changeset/12965-studio-copilot-no-drafts-bar.md b/.changeset/12965-studio-copilot-no-drafts-bar.md new file mode 100644 index 0000000000..f83c467929 --- /dev/null +++ b/.changeset/12965-studio-copilot-no-drafts-bar.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +The Studio copilot no longer draws the floating pending-changes bar over its composer (objectui#12965, part of objectui#12725). + +With the build agent and a change pending, the chat's floating bar (「有 1 项改动尚未发布… 发布」) sat over the copilot composer's input line in Studio's right slot, at 1010 and at 1280 px wide, and hid the placeholder and the first typed line. In the package's own Studio the chat no longer draws it: the top bar's 变更 count and 发布, and the saved-draft card's 查看变更并发布, are the ways to review and publish there. The full-page chat and the console dock keep the bar as it was. + +Clause-②: no. No export, prop, spec key or language-pack key changes. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index 5baa50e9c5..4682b1708d 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -36,7 +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. | +| **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. The Studio copilot draws no floating pending-changes bar over its composer: there the top bar's **Changes** and **Publish** are the way to review and publish. The full-page chat and the console dock show that bar while the package has pending drafts. | | **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 cf440a8cc9..02504bbffb 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2115,6 +2115,15 @@ export function ChatPane({ () => deriveBoundPackageId(runtimeMessages, editPackageId), [runtimeMessages, editPackageId], ); + // objectui#12965 — is this pane on one of its bound package's own Studio + // pillars, i.e. hosted by that package's Studio copilot? The same reading the + // applied card's review door makes (`reviewDrafts` above): the target + // replaces the entry exactly when the location is already on that package's + // Studio pillar. The full-page chat (`/ai/...`) and the console dock + // (`/apps/...`) are never on one. + const onBoundPackageStudio = boundPackageId + ? studioChangesReviewTarget(boundPackageId, location).replace + : false; const boundPackageLabel = useMemo(() => { if (!boundPackageId) return undefined; const app = (metadataApps ?? []).find( @@ -2494,8 +2503,14 @@ export function ChatPane({ the composer while the bound package has pending drafts, so the publish entry point survives scrolling (the inline card button does not). Same float idiom as ExcelImportBar above; renders nothing when - the count is 0 or the conversation is unbound. */} - {isBuildSurface ? ( + the count is 0 or the conversation is unbound. + objectui#12965 — not drawn in the package's own Studio. In the + copilot's right slot it floated over the composer's input line and + hid the placeholder, and there the Studio top bar's 变更 and 发布 and + the applied card's 查看变更并发布 are the ways to review and publish + (objectui#12725's ruled artboard draws no bar). The full-page chat + and the console dock keep it as it is. */} + {isBuildSurface && !onBoundPackageStudio ? (
diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.studioDraftsBar-12965.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.studioDraftsBar-12965.test.tsx new file mode 100644 index 0000000000..1aa2b11558 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.studioDraftsBar-12965.test.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12965 (part of objectui#12725) — `ChatPane` does not draw the + * floating pending-drafts bar in the package's own Studio. + * + * In the Studio copilot's right slot the bar (`PendingDraftsBar`, objectui#5694) + * floated over the composer's input line and hid its placeholder. There the + * Studio top bar's 变更 and 发布 and the applied card's 查看变更并发布 are the + * ways to review and publish, and objectui#12725's ruled artboard draws no + * bar. Outside Studio — the full-page chat and the console dock — the bar + * stays as it is. + * + * Every case has a draft pending for the bound package, so the bar has + * something to draw. In Studio, the sibling probe reads the same ledger + * through the same hook the Studio top bar's 变更 badge uses; it answers 1 + * before the bar's absence is read, so the absence is not a read taken before + * the ledger answered. No layout runs here: the composer's unobstructed input + * line was measured in a real browser at 1010 and 1280 px, on this card's + * evidence branch. + * + * Same harness as `AiChatPage.appliedCardDoors-12894.test.tsx`: the real pane, + * with `ChatbotEnhanced` replaced by a stub, and the real `PendingDraftsBar`. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ChatbotEnhanced: () =>
, + 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 }), + }; +}); + +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: () => null }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; +import { usePendingDrafts } from '../../../preview/usePendingDrafts'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const PKG = 'app.hcpl'; +const AGENTS = [{ name: 'build', label: 'Build' }] as unknown as AgentDescriptor[]; +const DRAFT = { type: 'object', name: 'hcpl_borrowing', label: 'Borrowing', packageId: PKG }; + +/** Reads the package's pending drafts the way the Studio top bar's 变更 badge does. */ +function DraftsBadgeProbe({ packageId }: { packageId: string }): React.ReactElement { + const { count } = usePendingDrafts({ packageId }); + return
{count === null ? '' : String(count)}
; +} + +function renderPane(entry: string) { + return render( + + + + + + , + ); +} + +beforeEach(() => { + window.localStorage.clear(); + // One draft pending for the bound package; every other request answers empty. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => { + const body = String(url).includes('/meta/_drafts') ? { drafts: [DRAFT] } : { success: true, data: [] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('objectui#12965 — the pending-drafts bar in the Studio copilot', () => { + it('on one of the package\'s Studio pillars, with a draft pending, no pending-drafts bar is drawn', async () => { + renderPane('/studio/app.hcpl/interfaces?surface=object%3Ahcpl_borrowing'); + // The ledger has answered with the pending draft (the 变更 badge's reading)... + await waitFor(() => expect(screen.getByTestId('drafts-badge-probe').textContent).toBe('1')); + // ...and the chat draws no bar over its composer. + expect(screen.getByTestId('ai-chat-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('pending-drafts-bar')).toBeNull(); + }); + + it.each([ + ['the full-page chat', '/ai/build/conv-1?package=app.hcpl'], + ['the console dock, inside the running app', '/apps/hcpl/hcpl_borrowing'], + ])('CONTROL: %s, with a draft pending, still draws the bar', async (_host, entry) => { + renderPane(entry); + expect(await screen.findByTestId('pending-drafts-bar')).toBeInTheDocument(); + expect(screen.getByTestId('drafts-badge-probe').textContent).toBe('1'); + }); +});