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(); + }); +});