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
5 changes: 5 additions & 0 deletions .changeset/standing-pending-drafts-bar-5694.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -2056,6 +2057,16 @@ export function ChatPane({
</div>
</div>
) : 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 ? (
<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>
) : null}
<div
data-chat-column
className={
Expand Down
150 changes: 150 additions & 0 deletions packages/app-shell/src/console/ai/PendingDraftsBar.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
/**
* 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.
*/

/**
* Standing "unpublished changes" affordance for the AI build surface
* (objectui#5694).
*
* Before this bar, the ONLY way to publish a pending draft was the inline
* 「发布 (N)」 button on whichever tool card staged it — buried however far up
* the transcript the conversation had scrolled. In the 2026-08-22 staging E2E
* (cloud#1584) a dashboard sat `state='draft'` through three repair rounds
* with a working publish button off-screen the whole time; the user's actual
* experience was a live menu entry answering 「未找到仪表板」.
*
* This bar floats above the composer while the conversation's bound package
* has pending drafts: it survives scrolling, publishes through the SAME
* governed path as the inline button and the Home banner
* (`POST /packages/:id/publish-drafts` — the path that orders
* structure-before-seeds and runs the ADR-0038 L3 probes), narrates probe
* findings via the shared {@link publishHealthFromResponse} instead of a blind
* "Published!", and disappears when the count reaches zero.
*
* Count freshness: re-read when the package binding changes and whenever the
* turn goes idle (`idle` flips true) — tool results that stage or publish
* drafts land inside a turn, so idle edges are exactly when the count can
* have changed. Sibling surfaces: `preview/UnpublishedAppBar` (the ADR-0045
* app-level publish gate — a DIFFERENT axis: an app can be live while sibling
* artifacts are draft, which is exactly the case above) and the Home
* pending-drafts banner (environment-wide, not package-scoped).
*/

import { useCallback, useEffect, useRef, useState } from 'react';
import { CloudUpload, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { publishHealthFromResponse } from '@object-ui/plugin-chatbot';
import { useMetadataClient } from '../../views/metadata-admin/useMetadata.js';
import { useMetadata } from '../../providers/MetadataProvider.js';

export interface PendingDraftsBarProps {
/** The conversation's bound package (ADR-0057 A1.a); undefined = not bound yet. */
packageId: string | undefined;
/** True while no turn is streaming — the count refetch trigger. */
idle: boolean;
}

export function PendingDraftsBar({ packageId, idle }: PendingDraftsBarProps) {
const client = useMetadataClient();
const { refresh } = useMetadata();
const { t } = useObjectTranslation();
const [count, setCount] = useState(0);
const [publishing, setPublishing] = useState(false);
// `useMetadataClient` caches per baseUrl+env, but this bar must not depend
// on that: an unstable client identity in the effect deps would refetch on
// every render. Read it through a ref; the effect keys on the FACTS that
// change the answer (binding, idleness, an explicit post-publish bump).
const clientRef = useRef(client);
clientRef.current = client;
const [version, setVersion] = useState(0);

useEffect(() => {
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 (
<div
data-testid="pending-drafts-bar"
className="pointer-events-auto flex w-full max-w-3xl items-center justify-between gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<span className="min-w-0 truncate">
{t('ai.pendingDrafts.count', {
defaultValue: '{{count}} change(s) are not published yet — users cannot see them.',
count,
})}
</span>
<Button size="sm" onClick={() => void publish()} disabled={publishing} className="shrink-0">
{publishing ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <CloudUpload className="mr-1 h-3.5 w-3.5" />}
{t('ai.pendingDrafts.publish', { defaultValue: 'Publish' })}
</Button>
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -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(<PendingDraftsBar packageId="app.k9qk" idle />);
await waitFor(() => expect(listDrafts).toHaveBeenCalledWith({ packageId: 'app.k9qk' }));
expect(container.querySelector('[data-testid="pending-drafts-bar"]')).toBeNull();
rerender(<PendingDraftsBar packageId={undefined} idle />);
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(<PendingDraftsBar packageId="app.k9qk" idle />);
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();
});
});
Loading