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
12 changes: 12 additions & 0 deletions .changeset/12894-copilot-applied-card-doors.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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. |
| **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
70 changes: 68 additions & 2 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
detectRecordHandoff,
detectProposedChanges,
detectReplayOutcome,
detectAuthoringVerdict,
detectBuiltAppPackage,
detectPendingApproval,
buildProgressFromDraftReview,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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<File | null>(null);
const canvasTimerRef = useRef<number | null>(null);
useEffect(() => () => {
Expand Down Expand Up @@ -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<Set<string> | 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;
Expand Down Expand Up @@ -2447,12 +2500,20 @@ export function ChatPane({
<PendingDraftsBar packageId={boundPackageId} idle={!isLoading} />
</div>
) : 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. */}
<div
data-chat-column
className={
desktopSplit
? 'flex min-h-0 shrink-0 justify-center'
: 'flex min-h-0 flex-1 justify-center'
: 'flex min-h-0 min-w-0 flex-1 justify-center'
}
style={desktopSplit ? { width: split.width } : undefined}
>
Expand Down Expand Up @@ -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" })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
ChatbotEnhanced: (props: Record<string, unknown>) => {
captured = props;
return <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 }),
};
});

const discardDrafts = vi.fn();
let adapter: unknown = null;
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: () => 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 <div data-testid="location">{`${location.pathname}${location.search}`}</div>;
}

function renderPane(entry: string, editPackageId?: string) {
cleanup();
captured = {};
render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={[entry]}>
<LocationProbe />
<ChatPane
agents={AGENTS}
agentsLoading={false}
agentsError={undefined}
activeAgent="build"
chatApi="/api/v1/ai/agents/build/chat"
apiBase="/api/v1/ai"
conversationId="conv-1"
editPackageId={editPackageId}
initialMessages={[]}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
/>
</MemoryRouter>
</I18nProvider>,
);
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<unknown>;
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<unknown>)('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');
});
});
Loading
Loading