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
23 changes: 19 additions & 4 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,18 +425,28 @@ function ChatPane({
// drafted app rendered as-if-published (`?preview=draft`) beside the chat.
// Per-artifact signals coalesce (800 ms) into one pane refresh so a
// whole-app build doesn't trigger an invalidation storm.
const [canvasApp, setCanvasApp] = useState<string | null>(null);
const [canvasApp, setCanvasApp] = useState<{ name: string; materialized: boolean } | null>(null);
const [canvasRefreshKey, setCanvasRefreshKey] = useState(0);
const canvasTimerRef = useRef<number | null>(null);
useEffect(() => () => {
if (canvasTimerRef.current) window.clearTimeout(canvasTimerRef.current);
}, []);
const handleDraftArtifacts = useCallback((artifacts: Array<{ type: string; name: string }>) => {
const app = artifacts.find((a) => a.type === 'app');
if (app) setCanvasApp((prev) => prev ?? app.name);
if (app) setCanvasApp((prev) => prev ?? { name: app.name, materialized: false });
if (canvasTimerRef.current) window.clearTimeout(canvasTimerRef.current);
canvasTimerRef.current = window.setTimeout(() => setCanvasRefreshKey((k) => k + 1), 800);
}, []);
// ADR-0045: the build finished and was materialized (real tables + data,
// app unlisted). Switch the open canvas from the draft overlay to the REAL
// app URL — the reload that follows shows live rows in every list.
const handleBuildMaterialized = useCallback((appName: string) => {
setCanvasApp((prev) =>
prev && prev.name === appName && !prev.materialized
? { name: appName, materialized: true }
: prev ?? { name: appName, materialized: true },
);
}, []);
// A different conversation is a different build session — close the pane.
useEffect(() => {
setCanvasApp(null);
Expand Down Expand Up @@ -678,14 +688,19 @@ function ChatPane({
// ADR-0037 Live Canvas: open/refresh the draft-preview pane as the
// agent's artifacts land; Preview buttons deep-link the same route.
onDraftArtifacts={handleDraftArtifacts}
onPreviewDraftApp={(appName) => setCanvasApp(appName)}
onPreviewDraftApp={(appName, opts) =>
setCanvasApp({ name: appName, materialized: opts?.materialized === true })}
// ADR-0045: build materialized → canvas leaves the draft overlay for
// the real (unlisted) app; the reload shows live seed rows.
onBuildMaterialized={handleBuildMaterialized}
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
data-testid="ai-chat-panel"
/>
</div>
{canvasApp ? (
<LiveCanvas
appName={canvasApp}
appName={canvasApp.name}
materialized={canvasApp.materialized}
refreshKey={canvasRefreshKey}
onClose={() => setCanvasApp(null)}
/>
Expand Down
41 changes: 35 additions & 6 deletions packages/app-shell/src/console/ai/LiveCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import { useObjectTranslation } from '@object-ui/i18n';
export interface LiveCanvasProps {
/** The drafted app to render (its `app` metadata name). */
appName: string;
/**
* ADR-0045: the build was materialized — the app is live (real tables and
* seed rows) but unlisted. The canvas then renders the REAL app URL: full
* data, full interaction; the in-app UnpublishedAppBar narrates the state.
* False (default) keeps the ADR-0037 draft-overlay preview for mutations.
*/
materialized?: boolean;
/** Bump to reload the pane (the host coalesces invalidation storms). */
refreshKey: number;
onClose: () => void;
Expand All @@ -42,9 +49,26 @@ function spaBase(): string {
return window.location.pathname.startsWith('/_console') ? '/_console' : '';
}

export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
/** Canvas iframe target: the REAL app once materialized, the draft overlay otherwise. */
function canvasSrc(appName: string, materialized: boolean): string {
const base = `${spaBase()}/apps/${encodeURIComponent(appName)}`;
return materialized ? base : `${base}?preview=draft`;
}

export function LiveCanvas({ appName, materialized = false, refreshKey, onClose }: LiveCanvasProps) {
const { t } = useObjectTranslation();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
// Materialized world swap: changing src on the SAME iframe element
// navigates it in place (no white-flash remount).
useEffect(() => {
if (!iframeRef.current) return;
const next = canvasSrc(appName, materialized);
try {
if (iframeRef.current.getAttribute('src') !== next) iframeRef.current.setAttribute('src', next);
} catch {
/* not ready — the mount src covers it */
}
}, [appName, materialized]);

// Refresh in place (src reload) instead of remounting the iframe — keeps
// the pane from flashing white on every invalidation.
Expand All @@ -63,10 +87,15 @@ export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
<Eye className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">
{t('console.ai.liveCanvas', {
app: appName,
defaultValue: 'Live preview — {{app}} (draft)',
})}
{materialized
? t('console.ai.liveCanvasUnlisted', {
app: appName,
defaultValue: 'Live app — {{app}} (unlisted until published)',
})
: t('console.ai.liveCanvas', {
app: appName,
defaultValue: 'Live preview — {{app}} (draft)',
})}
</span>
<Button size="sm" variant="ghost" onClick={onClose} data-testid="live-canvas-close">
<X className="h-3.5 w-3.5" />
Expand All @@ -75,7 +104,7 @@ export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
<iframe
ref={iframeRef}
title={`Draft preview: ${appName}`}
src={`${spaBase()}/apps/${encodeURIComponent(appName)}?preview=draft`}
src={canvasSrc(appName, materialized)}
className="h-full w-full flex-1 border-0 bg-background"
data-testid="live-canvas-frame"
/>
Expand Down
9 changes: 8 additions & 1 deletion packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,14 @@ function ChatbotInner({
openBuiltAppLabel={locale.openBuiltApp}
// ADR-0037: see the drafted app as-if-published before Publish — same
// route, draft overlay, watermark bar on top.
onPreviewDraftApp={(appName) => navigate(`/apps/${encodeURIComponent(appName)}?preview=draft`)}
onPreviewDraftApp={(appName, opts) =>
navigate(
// ADR-0045: a materialized build is a REAL (unlisted) app — open it
// directly; the UnpublishedAppBar narrates. Drafts keep the overlay.
opts?.materialized
? `/apps/${encodeURIComponent(appName)}`
: `/apps/${encodeURIComponent(appName)}?preview=draft`,
)}
// ADR-0037 P2.5: announce drafted artifacts so an open ?preview=draft
// page (same document) drops its cache and refetches the new draft.
onDraftArtifacts={(artifacts) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/layout/ConsoleLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useDiscovery } from '@object-ui/react';
// hover/click. See ConsoleChatbotFab.tsx.
import { ConsoleChatbotFab } from './ConsoleChatbotFab';
import { DraftPreviewBar } from '../preview/DraftPreviewBar';
import { UnpublishedAppBar } from '../preview/UnpublishedAppBar';
import { UnifiedSidebar } from './UnifiedSidebar';
import { AppHeader } from './AppHeader';
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext';
Expand Down Expand Up @@ -125,6 +126,9 @@ export function ConsoleLayout({
{/* ADR-0037: unmistakable watermark while rendering the draft overlay
(?preview=draft) — with one-click exit and one-click Publish. */}
<DraftPreviewBar />
{/* ADR-0045: materialized-but-unlisted app — real and interactive,
invisible to end users until the Publish visibility flip. */}
<UnpublishedAppBar />
{children}
</ConsoleLayoutInner>

Expand Down
97 changes: 97 additions & 0 deletions packages/app-shell/src/preview/UnpublishedAppBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* 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.
*/

/**
* ADR-0045 — the "unpublished app" banner. Renders while the CURRENT app is
* materialized-but-unlisted (`app.hidden === true`): the app is fully real —
* tables, data, interactions — but end users can't see it (launchers exclude
* it; the REST gate strips it for non-builders). The banner narrates that
* state and offers the one action that matters: Publish, which simply flips
* visibility (`hidden: false`) — instant and reversible, per ADR-0045.
*
* Sibling of DraftPreviewBar (the ADR-0037 draft-overlay watermark): that bar
* owns mutation preview (`?preview=draft`); this one owns the materialize
* regime. In preview mode this bar yields — the draft bar already narrates.
*/

import { useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { EyeOff, Rocket } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { useMetadata } from '../providers/MetadataProvider';
import { usePreviewDrafts } from './PreviewModeContext';

export function UnpublishedAppBar() {
const preview = usePreviewDrafts();
const { appName } = useParams();
const location = useLocation();
const { apps, refresh } = useMetadata();
const { t } = useObjectTranslation();
const [publishing, setPublishing] = useState(false);

// The draft-preview watermark owns the preview tree; never stack both bars.
if (preview) return null;
const routeApp = appName ?? location.pathname.match(/\/apps\/([^/?#]+)/)?.[1];
if (!routeApp) return null;
const app = (apps ?? []).find((a: any) => a?.name === routeApp);
if (!app || (app as any).hidden !== true) return null;

const publish = async () => {
setPublishing(true);
try {
// Publish = the ADR-0045 visibility flip: one metadata write, no
// lifecycle machinery. Body is the full current app with hidden:false
// (the meta save endpoint replaces the overlay row).
const res = await fetch(`/api/v1/meta/app/${encodeURIComponent(routeApp)}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ ...(app as Record<string, unknown>), hidden: false }),
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error((payload as any)?.error?.message ?? (payload as any)?.error ?? `HTTP ${res.status}`);
}
toast.success(
t('preview.unpublishedBar.published', {
defaultValue: 'Published! The app is now visible to your users.',
}),
);
refresh?.();
} catch (e) {
toast.error(
`${t('preview.unpublishedBar.publishFailed', { defaultValue: 'Publish failed' })}: ${(e as Error).message}`,
);
} finally {
setPublishing(false);
}
};

return (
<div
className="sticky top-0 z-40 flex items-center gap-3 border-b border-amber-300/70 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-200"
data-testid="unpublished-app-bar"
>
<EyeOff className="h-4 w-4 shrink-0" />
<p className="min-w-0 flex-1 truncate">
{t('preview.unpublishedBar.message', {
defaultValue:
'Unpublished app — fully functional, but only builders can see it. Publish to make it visible to your users.',
})}
</p>
<Button size="sm" onClick={publish} disabled={publishing} data-testid="unpublished-app-publish">
<Rocket className="mr-1 h-3.5 w-3.5" />
{publishing
? t('preview.unpublishedBar.publishing', { defaultValue: 'Publishing…' })
: t('preview.unpublishedBar.publish', { defaultValue: 'Publish' })}
</Button>
</div>
);
}
39 changes: 37 additions & 2 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export interface ChatToolInvocation {
autoPublishable?: boolean;
/** Count of artifacts that failed in a partial build, surfaced not hidden. */
failedCount?: number;
/**
* ADR-0045: the build was MATERIALIZED in-turn — real tables and seed
* rows exist; the app is live but `hidden` (unlisted). Preview should
* open the REAL app URL, not the draft overlay.
*/
materialized?: boolean;
};
}

Expand Down Expand Up @@ -363,8 +369,11 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
* Rendered next to the build tree's Open-app action and on draft chips
* whose items include an `app`. The host wires this to its router with the
* preview flag (e.g. `navigate('/apps/<name>?preview=draft')`).
* ADR-0045: when the build reports `materialized`, `opts.materialized` is
* true and the host should open the REAL app URL (no preview flag) — the
* app is live-but-unlisted, with actual tables and seed data.
*/
onPreviewDraftApp?: (appName: string) => void;
onPreviewDraftApp?: (appName: string, opts?: { materialized?: boolean }) => void;
/** Label for the preview-draft action (default "Preview"). */
previewDraftLabel?: string;
/**
Expand All @@ -374,6 +383,12 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
* refresh the live draft-preview pane while the agent builds.
*/
onDraftArtifacts?: (artifacts: Array<{ type: string; name: string }>) => void;
/**
* ADR-0045: fires once per build whose tool result reports `materialized`
* with an `app` in the draft set — the app is live (tables + seed data)
* but unlisted. Hosts switch the canvas to the real app URL.
*/
onBuildMaterialized?: (appName: string) => void;
/** Label for the publish-drafts button (default "Publish"). */
publishDraftsLabel?: string;
/** Label for the published-state badge that replaces the button (default "Published"). */
Expand Down Expand Up @@ -700,6 +715,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
onPreviewDraftApp,
previewDraftLabel = 'Preview',
onDraftArtifacts,
onBuildMaterialized,
publishDraftsLabel = 'Publish',
publishedLabel = 'Published',
autoPublishDrafts = false,
Expand Down Expand Up @@ -871,6 +887,25 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
if (grew) onDraftArtifacts([...artifacts.values()]);
}, [messages, onDraftArtifacts]);

// ADR-0045: announce materialized builds (real app live, unlisted) so the
// host flips its canvas from the draft overlay to the real app URL. Once
// per build (keyed by toolCallId), including on conversation reload —
// a reopened materialized build should still preview the real app.
const materializedKeysRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
if (!onBuildMaterialized) return;
for (const message of messages) {
for (const tool of message.toolInvocations ?? []) {
const dr = tool.draftReview;
if (!dr?.materialized || !tool.toolCallId) continue;
const app = dr.items.find((i) => i.type === 'app');
if (!app || materializedKeysRef.current.has(tool.toolCallId)) continue;
materializedKeysRef.current.add(tool.toolCallId);
onBuildMaterialized(app.name);
}
}
}, [messages, onBuildMaterialized]);

const handleSubmit = React.useCallback(
(payload: PromptInputMessage) => {
const hasText = Boolean(payload.text?.trim());
Expand Down Expand Up @@ -1060,7 +1095,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
return app ? (
<button
type="button"
onClick={() => onPreviewDraftApp(app.name)}
onClick={() => onPreviewDraftApp(app.name, { materialized: tool.draftReview!.materialized === true })}
className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
data-testid="draft-preview-app"
>
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-chatbot/src/mapMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function detectDraftResult(
packageId?: string;
autoPublishable?: boolean;
failedCount?: number;
materialized?: boolean;
} | undefined {
const obj = parseResultEnvelope(result);
if (!obj || obj.status !== 'drafted') return undefined;
Expand Down Expand Up @@ -167,6 +168,9 @@ function detectDraftResult(
...(typeof obj.packageId === 'string' && obj.packageId ? { packageId: obj.packageId } : {}),
...(obj.autoPublishable === true ? { autoPublishable: true } : {}),
...(failedCount > 0 ? { failedCount } : {}),
// ADR-0045: the build was materialized in-turn (real tables + data, app
// hidden). The canvas then previews the REAL app URL, not the draft overlay.
...(obj.materialized === true ? { materialized: true } : {}),
};
}

Expand Down
Loading