diff --git a/.changeset/standing-pending-drafts-bar-5694.md b/.changeset/standing-pending-drafts-bar-5694.md
new file mode 100644
index 0000000000..f1d669f659
--- /dev/null
+++ b/.changeset/standing-pending-drafts-bar-5694.md
@@ -0,0 +1,5 @@
+---
+'@object-ui/app-shell': minor
+---
+
+AI build surface gains a standing 「未发布改动」 bar (#5694): while the conversation's bound package has pending drafts, a bar floats above the composer — surviving scrolling — counting the unpublished changes and publishing them through the same governed `publish-drafts` route as the inline card button, with probe findings surfaced instead of a blind success toast. Renders nothing when the count is zero or the conversation is unbound.
diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx
index 4763669391..2c10a67ddf 100644
--- a/packages/app-shell/src/console/ai/AiChatPage.tsx
+++ b/packages/app-shell/src/console/ai/AiChatPage.tsx
@@ -25,6 +25,7 @@ import { formatPublishFailures, type PublishFailure } from '../../views/studio-d
import { resolveKeyedI18nLabel } from '../../utils/index.js';
import { resolvePublicShareBase } from '../organizations/resolveHomeUrl.js';
import { ExcelImportBar } from './ExcelImportBar.js';
+import { PendingDraftsBar } from './PendingDraftsBar.js';
import {
Select,
SelectContent,
@@ -2056,6 +2057,16 @@ export function ChatPane({
) : null}
+ {/* objectui#5694 — standing unpublished-changes affordance: floats above
+ 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 ? (
+
+
+
+ ) : null}
{
+ if (!packageId) {
+ setCount(0);
+ return;
+ }
+ if (!idle) return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const drafts = ((await clientRef.current.listDrafts?.({ packageId })) as unknown[]) || [];
+ if (!cancelled) setCount(Array.isArray(drafts) ? drafts.length : 0);
+ } catch {
+ // An older server without the drafts surface: no signal, no bar.
+ if (!cancelled) setCount(0);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [idle, packageId, version]);
+
+ const publish = useCallback(async () => {
+ if (!packageId || publishing) return;
+ setPublishing(true);
+ try {
+ const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: '{}',
+ });
+ const body = await res.json().catch(() => undefined);
+ if (!res.ok) {
+ const message =
+ (body as { error?: { message?: string } } | undefined)?.error?.message ??
+ t('ai.pendingDrafts.failed', { defaultValue: 'Publish failed.' });
+ toast.error(message);
+ return;
+ }
+ const health = publishHealthFromResponse(body);
+ const problems = (health?.issues ?? []).filter((i) => i.severity === 'error');
+ if (health?.seedError || problems.length > 0) {
+ toast.warning(
+ t('ai.pendingDrafts.publishedWithFindings', {
+ defaultValue: 'Published, but the runtime probes reported problems: {{detail}}',
+ detail: [health?.seedError, ...problems.map((p) => p.message)].filter(Boolean).join('; '),
+ }),
+ );
+ } else {
+ toast.success(t('ai.pendingDrafts.published', { defaultValue: 'All pending changes are live.' }));
+ }
+ // The launcher/nav may have just gained entries — refresh the shared
+ // metadata so the user's next click finds them.
+ try {
+ await refresh?.();
+ } catch {
+ /* metadata refresh is best-effort */
+ }
+ } finally {
+ setPublishing(false);
+ setVersion((v) => v + 1);
+ }
+ }, [packageId, publishing, refresh, t]);
+
+ if (!packageId || count <= 0) return null;
+
+ return (
+
+
+ {t('ai.pendingDrafts.count', {
+ defaultValue: '{{count}} change(s) are not published yet — users cannot see them.',
+ count,
+ })}
+
+
+
+ );
+}
diff --git a/packages/app-shell/src/console/ai/__tests__/PendingDraftsBar.test.tsx b/packages/app-shell/src/console/ai/__tests__/PendingDraftsBar.test.tsx
new file mode 100644
index 0000000000..07a695ff5d
--- /dev/null
+++ b/packages/app-shell/src/console/ai/__tests__/PendingDraftsBar.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#5694 — the standing unpublished-changes bar. The publish entry
+ * point must not depend on where the transcript happens to be scrolled:
+ * while the bound package has pending drafts the bar renders, its Publish
+ * goes through the governed `publish-drafts` route, and it disappears when
+ * the pending count reaches zero.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, waitFor, fireEvent, cleanup } from '@testing-library/react';
+import { PendingDraftsBar } from '../PendingDraftsBar.js';
+
+const listDrafts = vi.fn();
+const refresh = vi.fn();
+
+vi.mock('../../../views/metadata-admin/useMetadata.js', () => ({
+ useMetadataClient: () => ({ listDrafts }),
+}));
+vi.mock('../../../providers/MetadataProvider.js', () => ({
+ useMetadata: () => ({ refresh }),
+}));
+vi.mock('@object-ui/plugin-chatbot', () => ({
+ publishHealthFromResponse: () => undefined,
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) })));
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('PendingDraftsBar (objectui#5694)', () => {
+ it('renders nothing when the package has no pending drafts, and nothing when unbound', async () => {
+ listDrafts.mockResolvedValue([]);
+ const { container, rerender } = render();
+ await waitFor(() => expect(listDrafts).toHaveBeenCalledWith({ packageId: 'app.k9qk' }));
+ expect(container.querySelector('[data-testid="pending-drafts-bar"]')).toBeNull();
+ rerender();
+ expect(container.querySelector('[data-testid="pending-drafts-bar"]')).toBeNull();
+ });
+
+ it('shows the count while drafts are pending, publishes through publish-drafts, then hides', async () => {
+ // One pending dashboard draft (the cloud#1584 shape) until published.
+ listDrafts.mockResolvedValue([{ type: 'dashboard', name: 'task_dashboard', packageId: 'app.k9qk' }]);
+ const { container } = render();
+ await waitFor(() =>
+ expect(container.querySelector('[data-testid="pending-drafts-bar"]')).toBeTruthy(),
+ );
+
+ // Publishing empties the pending set on the post-publish refetch.
+ listDrafts.mockResolvedValue([]);
+ const bar = container.querySelector('[data-testid="pending-drafts-bar"]') as HTMLElement;
+ const button = bar.querySelector('button');
+ if (!button) throw new Error('no button. bar html: ' + bar.outerHTML.slice(0, 500));
+ fireEvent.click(button);
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/v1/packages/app.k9qk/publish-drafts',
+ expect.objectContaining({ method: 'POST', credentials: 'include' }),
+ );
+ });
+ await waitFor(() =>
+ expect(container.querySelector('[data-testid="pending-drafts-bar"]')).toBeNull(),
+ );
+ expect(refresh).toHaveBeenCalled();
+ });
+});