Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12965-studio-copilot-no-drafts-bar.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<type>:<name>`, 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. |
Expand Down
19 changes: 17 additions & 2 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 ? (
<div className="pointer-events-none absolute bottom-20 left-0 right-0 z-20 flex justify-center px-4">
<PendingDraftsBar packageId={boundPackageId} idle={!isLoading} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>();
return {
...actual,
ChatbotEnhanced: () => <div data-testid="ai-chat-panel" />,
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<Record<string, unknown>>();
return { ...actual, useMetadata: () => ({ apps: [] }) };
});
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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 <div data-testid="drafts-badge-probe">{count === null ? '' : String(count)}</div>;
}

function renderPane(entry: string) {
return render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={[entry]}>
<ChatPane
agents={AGENTS}
agentsLoading={false}
agentsError={undefined}
activeAgent="build"
chatApi="/api/v1/ai/agents/build/chat"
apiBase="/api/v1/ai"
conversationId="conv-1"
editPackageId={PKG}
initialMessages={[]}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
/>
<DraftsBadgeProbe packageId={PKG} />
</MemoryRouter>
</I18nProvider>,
);
}

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');
});
});
Loading