From 4ed1969ead9ba6c5c6c3298db102c120e630bbde Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Tue, 21 Jul 2026 14:27:45 +0530 Subject: [PATCH 01/17] Add pairwise chat comparison --- docs/README.md | 8 +- docs/app/api/chat/route.ts | 68 +- .../chat/PAIRWISE_COMPARISON_REQUIREMENTS.md | 181 +++++ .../agent-surfaces/cloud-agent-surface.tsx | 96 +-- .../cloud-full-page-artifact.tsx | 198 ++++++ .../cloud-persisted-thread-bridge.tsx | 79 +++ .../comparison-surface-welcome.tsx | 33 + .../agent-surfaces/markdown-agent-surface.tsx | 75 +++ .../agent-surfaces/oss-agent-surface.tsx | 75 ++- .../app/chat/_components/chat-page-client.tsx | 319 ++++++++- .../app/chat/_components/chat-page-header.tsx | 32 +- docs/app/chat/_components/chat-types.ts | 38 +- .../chat/_components/comparison-controls.tsx | 185 +++++ .../comparison-mode-controller.tsx | 230 +++++++ docs/app/chat/chat-page.module.css | 633 ++++++++++++++---- docs/app/chat/page.tsx | 4 +- docs/components/site-primary-nav.tsx | 6 +- .../store/__tests__/createChatStore.test.ts | 55 ++ .../src/store/createChatStore.ts | 51 +- .../__tests__/openai-completions.test.ts | 48 ++ .../src/stream/adapters/openai-completions.ts | 26 + .../MarkDownRenderer/MarkDownRenderer.tsx | 2 + .../OpenUIChat/GenUIAssistantMessage.tsx | 47 +- .../src/components/OpenUIChat/index.ts | 2 + 24 files changed, 2202 insertions(+), 289 deletions(-) create mode 100644 docs/app/chat/PAIRWISE_COMPARISON_REQUIREMENTS.md create mode 100644 docs/app/chat/_components/agent-surfaces/cloud-full-page-artifact.tsx create mode 100644 docs/app/chat/_components/agent-surfaces/cloud-persisted-thread-bridge.tsx create mode 100644 docs/app/chat/_components/agent-surfaces/comparison-surface-welcome.tsx create mode 100644 docs/app/chat/_components/agent-surfaces/markdown-agent-surface.tsx create mode 100644 docs/app/chat/_components/comparison-controls.tsx create mode 100644 docs/app/chat/_components/comparison-mode-controller.tsx create mode 100644 packages/react-headless/src/stream/adapters/__tests__/openai-completions.test.ts diff --git a/docs/README.md b/docs/README.md index b014ab032..69fff4ad5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,9 +19,11 @@ pnpm --filter @openuidev/docs build ### `/chat` demo configuration -The standalone `/chat` page always starts in **OpenUI OSS** mode. Its selected mode is not -stored across reloads. Live OSS generation uses the existing server-side -`OPENROUTER_API_KEY`. +The standalone `/chat` page compares two visible response modes at a time and defaults to +**Rendered Markdown vs OpenUI OSS**. Use the page-level switcher to show **OSS vs Cloud** or +**Markdown vs Cloud**. All three conversations remain mounted and receive each shared prompt; +switching the visible pair does not reset them. Markdown and OSS generation use the existing +server-side `OPENROUTER_API_KEY`. OpenUI Cloud requires the following server-side variables. If either is missing, Cloud requests show the unavailable state at runtime: diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts index f197cdf55..4ea8edbbb 100644 --- a/docs/app/api/chat/route.ts +++ b/docs/app/api/chat/route.ts @@ -4,7 +4,48 @@ import OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; import { join } from "path"; -const systemPrompt = readFileSync(join(process.cwd(), "generated/chat-system-prompt.txt"), "utf-8"); +const openUiSystemPrompt = readFileSync( + join(process.cwd(), "generated/chat-system-prompt.txt"), + "utf-8", +); + +const markdownSystemPrompt = `You are a helpful assistant. Respond using clear, well-structured GitHub-Flavored Markdown. + +Use headings, lists, tables, links, block quotes, and fenced code blocks when they make the response easier to understand. Use the available tools when they are relevant, and incorporate their results into the answer. + +Return only Markdown content. Do not emit OpenUI Lang, component syntax, JSON UI descriptions, or instructions for a renderer.`; + +type ResponseMode = "markdown" | "openui"; + +interface ChatRequestBody { + messages: unknown[]; + responseMode?: ResponseMode; +} + +function invalidRequest(message: string) { + return Response.json({ error: { message } }, { status: 400 }); +} + +function parseRequestBody(body: unknown): ChatRequestBody | Response { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return invalidRequest("Request body must be a JSON object"); + } + + const { messages, responseMode } = body as Record; + + if (!Array.isArray(messages)) { + return invalidRequest("messages must be an array"); + } + + if (responseMode !== undefined && responseMode !== "markdown" && responseMode !== "openui") { + return invalidRequest('responseMode must be either "markdown" or "openui"'); + } + + return { + messages, + responseMode: responseMode as ResponseMode | undefined, + }; +} // ── Tool implementations ── @@ -209,7 +250,19 @@ function sseToolCallArgs( // ── Route handler ── export async function POST(req: NextRequest) { - const { messages } = await req.json(); + let body: unknown; + try { + body = await req.json(); + } catch { + return invalidRequest("Request body must be valid JSON"); + } + + const parsedBody = parseRequestBody(body); + if (parsedBody instanceof Response) { + return parsedBody; + } + + const { messages, responseMode = "openui" } = parsedBody; const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { @@ -226,7 +279,11 @@ export async function POST(req: NextRequest) { const MODEL = "openai/gpt-5.4"; const cleanMessages = (messages as any[]) - .filter((m) => m.role !== "tool") + .filter( + (m) => + m.role !== "tool" && + (responseMode === "openui" || (m.role !== "system" && m.role !== "developer")), + ) .map((m) => { if (m.role === "assistant" && m.tool_calls?.length) { const { tool_calls: _tc, ...rest } = m; @@ -236,7 +293,10 @@ export async function POST(req: NextRequest) { }); const chatMessages: ChatCompletionMessageParam[] = [ - { role: "system" as const, content: systemPrompt }, + { + role: "system" as const, + content: responseMode === "markdown" ? markdownSystemPrompt : openUiSystemPrompt, + }, ...cleanMessages, ]; diff --git a/docs/app/chat/PAIRWISE_COMPARISON_REQUIREMENTS.md b/docs/app/chat/PAIRWISE_COMPARISON_REQUIREMENTS.md new file mode 100644 index 000000000..0e169d2fd --- /dev/null +++ b/docs/app/chat/PAIRWISE_COMPARISON_REQUIREMENTS.md @@ -0,0 +1,181 @@ +# OpenUI Chat Pairwise Comparison + +Status: implementation-ready + +Target: `docs/app/chat` (`/chat`) + +Reference: `/Users/zahle/github/composition/packages/chat/src/features/Crayon/layout/Compare` + +## Goal + +Turn `/chat` into a product-capability comparison with exactly two visible panels and three available response modes: + +1. Rendered Markdown +2. OpenUI OSS +3. OpenUI Cloud + +The page compares product experiences, not scientifically controlled model output. All three providers stay mounted and receive every assistant-bound action; the pair switcher only changes which two transcripts are visible. + +## Confirmed decisions + +| Topic | Decision | +| -------------------- | ---------------------------------------------------------------- | +| Visible layout | Exactly two panels | +| Pair selection | One global switcher with three fixed pairs | +| Default pair | Rendered Markdown vs OpenUI OSS | +| Markdown | Rendered Markdown, never raw source | +| Prompt fanout | Typed, suggested, and generated follow-ups go to all three modes | +| Cloud model selector | Hidden; use the configured fixed model | +| Cloud artifacts | Enabled in a full-page view | +| Cloud storage | Existing persistence retained | +| Narrow screens | Accessible tabs for the selected pair | +| Turn limit | No UI-enforced cap | +| Suggestions | Match the supplied comparison screenshot | + +## Pair switcher + +The global switcher offers only these presets, in this order: + +1. **Markdown vs OSS** +2. **OSS vs Cloud** +3. **Markdown vs Cloud** + +Requirements: + +- Default to **Markdown vs OSS**. +- Switching a pair only changes visibility; it never resets a conversation or starts a request. +- Keep all three mode surfaces mounted, including the mode not in the selected pair. +- Preserve each mode's transcript, stream, errors, and scroll position while hidden. +- Pair selection is session-local and does not need to survive reload. + +## Shared conversation + +- One suggestion row and one composer serve all three modes. +- Every typed prompt and starter suggestion is submitted concurrently to Markdown, OSS, and Cloud. +- Every LLM-bound generated follow-up, including `ContinueConversation` and submitted generated forms, is normalized to a human-readable prompt and broadcast exactly once to all three modes. +- Local response interactions remain local, including tabs, accordions, unsent form edits, and external links. +- Each mode owns an independent thread, history, stream, cancellation state, loading state, error state, and scroll position. +- Block another submission while any mode is running; the send control becomes **Stop all responses**. +- Stop aborts every running stream and preserves partial output. +- Reset aborts active streams, creates fresh threads, clears the three active comparison histories, and restores composer focus. It must not delete previously persisted Cloud conversations or artifacts. +- Do not impose a client-side turn cap. Existing server limits, rate limits, and budget controls still apply. + +## Mode requirements + +### Rendered Markdown + +- Use `AgentInterface` without a component library so its standard safe Markdown renderer handles the answer. +- Add a server-owned, validated Markdown generation mode; do not accept arbitrary browser-supplied system prompts. +- Render headings, lists, links, block quotes, tables, and code within panel bounds. +- Never display OpenUI Lang source as the Markdown answer. + +### OpenUI OSS + +- Keep `openuiChatLibrary`, the generated OpenUI Lang system prompt, and the existing Chat Completions stream adapter. +- Keep OSS history in memory for the browser session. +- Generated components remain interactive inside their panel. + +### OpenUI Cloud + +- Keep Cloud `chatLibrary`, Responses streaming, existing Cloud storage, and report/presentation artifact renderers. +- Hide the model selector and use the configured fixed Cloud model. +- Retain Cloud conversation and artifact persistence. +- Keep Cloud available as a switcher option when unavailable, with an explicit mode-local error; Markdown and OSS must remain usable. + +## Layout + +- Retain the current `/chat` product header and its docs/run-locally actions. +- Replace the OSS/Cloud toggle with the global three-preset pair switcher. +- Desktop layout, top to bottom: + 1. two equal-width panels separated by a 1px divider; + 2. the screenshot-style shared suggestion row; + 3. the shared composer with send/stop and reset controls. +- Give each panel a labelled header, centered empty state, independent transcript scrolling, and mode-local status/error presentation. +- Hide per-mode sidebars, thread lists, mobile headers, embedded composers, starter rows, and new-chat controls. +- Prevent Markdown, tables, code, charts, and generated UI from widening a panel; reflow or scroll content internally. +- Preserve existing OpenUI tokens, light/dark themes, focus visibility, reduced motion, and mobile safe-area spacing. + +### Empty-state copy + +- Rendered Markdown: **AI responses rendered as standard Markdown.** +- OpenUI OSS: **Interactive responses rendered with the open-source OpenUI library.** +- OpenUI Cloud: **Managed generative responses with tools and artifacts.** + +## Screenshot starter prompts + +Match the supplied screenshot's rounded suggestion-card styling, icon colors, spacing, order, labels, and prompts: + +1. **Exciting stocks to look out for this year** + + `Show me a chart of the top 5 US stocks outperforming the market in 2025 with key trendlines.` + +2. **Hidden travel gems to explore** + + `Give me travel ideas for underrated destinations with notable landmarks and cultural highlights.` + +3. **Greatest blockbusters of all time** + + `Show me a chart of the highest-grossing movies of all time with key milestones and release details.` + +4. **Tell me about global street food** + + `Give me a world map of street foods with charts of popularity and regional highlights.` + +Show suggestions only before the first submitted turn. On narrow screens, make the row horizontally scrollable. + +## Responsive behavior + +- At 900px and wider, show both selected panels side by side. +- Below 900px, show a two-tab accessible tab list for the selected pair and one full-width transcript at a time. +- Pair changes update the two tab labels while preserving a valid active tab. +- The non-active selected panel and unselected third mode remain mounted and continue streaming. +- Hidden surfaces must not expose focusable controls to keyboard users. +- Expose each mode's generating and error state in panel/tab labelling. +- Keep the pair switcher and shared composer reachable without scrolling the whole page. + +## Full-page Cloud artifacts + +- Reports and presentations open above the comparison as a true full-page overlay or route. +- Opening an artifact preserves the selected pair, all transcripts, scroll positions, and active streams. +- Closing returns focus to the invoking control and restores the unchanged comparison. +- Browser Back closes the artifact before leaving `/chat`. + +## Errors and accessibility + +- Isolate loading, streaming, renderer, unavailable, credit, and request errors to the affected mode. +- One slow or failed mode must not block completed output in another mode. +- Preserve completed and partial output after later failures; do not hide Cloud thread errors in CSS. +- Disable the first submission until required controllers are registered. A definitively unavailable Cloud mode must not prevent Markdown and OSS from working. +- Use one page `

` and two visible labelled `
` landmarks. +- Label the composer **Ask Rendered Markdown, OpenUI OSS, and OpenUI Cloud** and disclose that submission starts three requests. +- Give pair options, mobile tabs, send, stop-all, reset, artifact close, and relevant retry controls explicit accessible names and at least 44px targets. +- Track `aria-busy` per mode and announce lifecycle transitions without announcing streamed tokens. + +## Implementation plan + +1. Add server-validated `markdown | openui` output modes to the shared chat streaming route. +2. Add a Markdown surface using the default Markdown assistant renderer. +3. Adapt OSS and Cloud surfaces to register invisible mode controllers while suppressing their embedded navigation, starters, and composers. +4. Add a comparison coordinator that keeps all three surfaces mounted and fans out submit, stop, reset, and generated actions. +5. Replace conditional OSS/Cloud mounting with the global pair switcher and two visible panel slots. +6. Add desktop split-pane, mobile-tab, shared-control, and hidden-surface styling. +7. Route Cloud artifact detailed views into the full-page experience without unmounting the comparison. +8. Update tests, metadata, docs, and the comparison preview as needed. + +## Acceptance criteria + +- Desktop initially shows **Rendered Markdown vs OpenUI OSS** in two equal panels. +- The global switcher changes cleanly among **Markdown vs OSS**, **OSS vs Cloud**, and **Markdown vs Cloud**. +- Exactly two panels are visible while all three providers remain mounted. +- Switching pairs never resets, regenerates, or loses in-flight output. +- One typed prompt or starter produces one aligned user turn and three independently streamed responses. +- Generated assistant follow-ups reach all three modes exactly once. +- Markdown is rendered; OSS and Cloud generated UI remains interactive. +- The Cloud model selector is absent, while Cloud storage and artifact persistence continue working. +- Cloud artifacts open full page and return to the intact comparison; Browser Back closes them first. +- Stop and reset safely affect all three modes, including hidden streams. +- There is no UI-enforced turn cap. +- A Cloud failure leaves Markdown and OSS operational and visibly reports the Cloud error. +- Below 900px, the selected pair is navigable as accessible tabs while every mode continues streaming. +- Starter prompts and shared-control styling visually match the supplied screenshot in light and dark themes. +- Desktop, mobile, keyboard-only, reduced-motion, type-check, lint, build, and targeted stream/fanout tests pass. diff --git a/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx b/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx index 562fb6453..46e9feef0 100644 --- a/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx +++ b/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx @@ -3,47 +3,49 @@ import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; import { CLOUD_USER_ID_HEADER } from "@/lib/openui-cloud/user-id"; import { defineArtifactCategories } from "@openuidev/react-headless"; -import { AgentInterface } from "@openuidev/react-ui"; +import { + AgentInterface, + GenUIAssistantMessage, + type AssistantMessage, + type GenUIConversationAction, +} from "@openuidev/react-ui"; import { chatLibrary, presentationArtifactRenderer, reportArtifactRenderer, useOpenuiCloudStorage, } from "@openuidev/thesys"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import styles from "../../chat-page.module.css"; +import { useCallback, useMemo, useState } from "react"; +import type { ComparisonControllerRegistry } from "../comparison-mode-controller"; +import { ComparisonModeControllerBridge } from "../comparison-mode-controller"; import { createCloudChatLLM } from "./cloud-chat-llm"; -import { CloudModelSwitcher } from "./cloud-model-switcher"; +import { CloudArtifactHistoryBridge, CloudFullPageArtifactPanel } from "./cloud-full-page-artifact"; +import { CloudPersistedThreadBridge } from "./cloud-persisted-thread-bridge"; import { getOrCreateCloudUserId } from "./cloud-user-id"; +import { ComparisonSurfaceWelcome } from "./comparison-surface-welcome"; const { artifactRenderers, artifactCategories } = defineArtifactCategories([ { name: "Presentations", renderers: [presentationArtifactRenderer] }, { name: "Reports", renderers: [reportArtifactRenderer] }, ]); -const CLOUD_STARTERS = [ - { - displayText: "Pricing strategy tips", - prompt: "List five quick tips for pricing a new electric vehicle competitively.", - }, - { - displayText: "Quarterly deck", - prompt: "Create a short presentation about our Q2 results with three slides.", - }, - { - displayText: "Market report", - prompt: "Write a brief market-analysis report on the EV sector.", - }, -]; - interface CloudAgentSurfaceProps { themeMode: "light" | "dark"; + registry: ComparisonControllerRegistry; + onConversationAction: (action: GenUIConversationAction) => void; } -export function CloudAgentSurface({ themeMode }: CloudAgentSurfaceProps) { - const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); +export function CloudAgentSurface({ + themeMode, + registry, + onConversationAction, +}: CloudAgentSurfaceProps) { const [userId] = useState(getOrCreateCloudUserId); - const [llm] = useState(createCloudChatLLM); + const [llm] = useState(() => { + const cloudLLM = createCloudChatLLM(); + cloudLLM.setSelectedModel(DEFAULT_MODEL); + return cloudLLM; + }); const cloudFetch = useMemo(() => { return async (input, init) => { if (typeof input !== "string" || input !== "/api/openui-cloud/frontend-token") { @@ -62,16 +64,20 @@ export function CloudAgentSurface({ themeMode }: CloudAgentSurfaceProps) { fetch: cloudFetch, }); - useEffect(() => { - llm.setSelectedModel(selectedModel); - }, [llm, selectedModel]); - - const handleModelChange = useCallback( - (model: string) => { - llm.setSelectedModel(model); - setSelectedModel(model); - }, - [llm], + const AssistantMessageRenderer = useCallback( + ({ message }: { message: AssistantMessage }) => ( + + ), + [onConversationAction], + ); + const components = useMemo( + () => ({ AssistantMessage: AssistantMessageRenderer }), + [AssistantMessageRenderer], ); return ( @@ -80,29 +86,23 @@ export function CloudAgentSurface({ themeMode }: CloudAgentSurfaceProps) { storage={cloudStorage} llm={llm} componentLibrary={chatLibrary} + components={components} artifactRenderers={artifactRenderers} artifactCategories={artifactCategories} agentName="OpenUI Cloud" scrollVariant="always" scrollOnLoad={false} theme={{ mode: themeMode }} - starterVariant="short" - starters={CLOUD_STARTERS} > - - } - /> - - - - + + + + + + + + + ); diff --git a/docs/app/chat/_components/agent-surfaces/cloud-full-page-artifact.tsx b/docs/app/chat/_components/agent-surfaces/cloud-full-page-artifact.tsx new file mode 100644 index 000000000..2284c8de0 --- /dev/null +++ b/docs/app/chat/_components/agent-surfaces/cloud-full-page-artifact.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + useActiveDetailedView, + useDetailedView, + useDetailedViewStore, + useTheme, + type ToolDetailedViewPanel, +} from "@openuidev/react-ui"; +import * as Dialog from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { Component, useEffect, useRef, type ReactNode } from "react"; +import styles from "../../chat-page.module.css"; + +const CLOUD_ARTIFACT_HISTORY_KEY = "__openuiCloudArtifact"; +const CLOUD_ARTIFACT_QUERY_PARAM = "cloudArtifact"; + +interface ArtifactErrorBoundaryProps { + children: ReactNode; +} + +interface ArtifactErrorBoundaryState { + hasError: boolean; +} + +class ArtifactErrorBoundary extends Component< + ArtifactErrorBoundaryProps, + ArtifactErrorBoundaryState +> { + state: ArtifactErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ArtifactErrorBoundaryState { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return ( +
+ This artifact could not be rendered. Close it to return to the comparison. +
+ ); + } + + return this.props.children; + } +} + +/** + * Full-viewport replacement for AgentInterface's in-thread detailed-view panel. + * It remains inside the Cloud ChatProvider React tree, while Radix portals the + * visual surface to `document.body` so the two comparison lanes stay mounted. + */ +export const CloudFullPageArtifactPanel: ToolDetailedViewPanel = ({ + viewId, + title = "Artifact", + children, +}) => { + const { isActive, close } = useDetailedView(viewId); + const { portalThemeClassName } = useTheme(); + + return ( + { + if (!open) close(); + }} + > + + + event.preventDefault()} + > +
+ {title} + + + +
+
+ {children} +
+
+
+
+ ); +}; + +/** + * Gives a Cloud detailed view one same-page browser-history entry. Browser Back + * closes the artifact first; closing through UI consumes that entry with Back. + * An artifact version/id migration replaces the entry instead of stacking one. + */ +export function CloudArtifactHistoryBridge() { + const { activeDetailedViewId } = useActiveDetailedView(); + const detailedViewStore = useDetailedViewStore(); + const activeIdRef = useRef(activeDetailedViewId); + const ownsHistoryEntryRef = useRef(false); + const initializedRef = useRef(false); + + useEffect(() => { + activeIdRef.current = activeDetailedViewId; + }, [activeDetailedViewId]); + + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + const markerId = readArtifactHistoryMarker(event.state); + + if (markerId) { + ownsHistoryEntryRef.current = true; + if (activeIdRef.current !== markerId) { + detailedViewStore.getState().setActiveDetailedView(markerId); + } + return; + } + + ownsHistoryEntryRef.current = false; + if (activeIdRef.current !== null) { + detailedViewStore.getState().setActiveDetailedView(null); + } + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [detailedViewStore]); + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + + // A hard refresh cannot restore the originating in-memory message tree. + // Normalize a stale artifact marker in place rather than navigating Back. + if (activeDetailedViewId === null && readArtifactHistoryMarker(window.history.state)) { + window.history.replaceState( + withoutArtifactHistoryMarker(window.history.state), + "", + artifactUrl(null), + ); + } + } + + if (activeDetailedViewId !== null) { + const nextState = { + ...historyStateRecord(window.history.state), + [CLOUD_ARTIFACT_HISTORY_KEY]: activeDetailedViewId, + }; + + if (ownsHistoryEntryRef.current) { + window.history.replaceState(nextState, "", artifactUrl(activeDetailedViewId)); + } else { + window.history.pushState(nextState, "", artifactUrl(activeDetailedViewId)); + ownsHistoryEntryRef.current = true; + } + return; + } + + if (ownsHistoryEntryRef.current) { + ownsHistoryEntryRef.current = false; + window.history.back(); + } + }, [activeDetailedViewId]); + + return null; +} + +function artifactUrl(artifactId: string | null): string { + const url = new URL(window.location.href); + if (artifactId) { + url.searchParams.set(CLOUD_ARTIFACT_QUERY_PARAM, artifactId); + } else { + url.searchParams.delete(CLOUD_ARTIFACT_QUERY_PARAM); + } + return `${url.pathname}${url.search}${url.hash}`; +} + +function historyStateRecord(state: unknown): Record { + if (typeof state !== "object" || state === null || Array.isArray(state)) return {}; + return state as Record; +} + +function readArtifactHistoryMarker(state: unknown): string | null { + const marker = historyStateRecord(state)[CLOUD_ARTIFACT_HISTORY_KEY]; + return typeof marker === "string" && marker.length > 0 ? marker : null; +} + +function withoutArtifactHistoryMarker(state: unknown): Record { + const nextState = { ...historyStateRecord(state) }; + delete nextState[CLOUD_ARTIFACT_HISTORY_KEY]; + return nextState; +} diff --git a/docs/app/chat/_components/agent-surfaces/cloud-persisted-thread-bridge.tsx b/docs/app/chat/_components/agent-surfaces/cloud-persisted-thread-bridge.tsx new file mode 100644 index 000000000..bb74fd5df --- /dev/null +++ b/docs/app/chat/_components/agent-surfaces/cloud-persisted-thread-bridge.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useThreadList } from "@openuidev/react-headless"; +import { useEffect, useRef, useState } from "react"; + +const CLOUD_SELECTED_THREAD_STORAGE_KEY = "openui-cloud-selected-thread-id"; + +/** + * Restores the Cloud thread selected in this tab after a page reload. The + * comparison intentionally hides AgentInterface's sidebar, so the default + * ThreadList cannot perform this selection for the user. + */ +export function CloudPersistedThreadBridge() { + const threads = useThreadList((state) => state.threads); + const isLoadingThreads = useThreadList((state) => state.isLoadingThreads); + const selectedThreadId = useThreadList((state) => state.selectedThreadId); + const loadThreads = useThreadList((state) => state.loadThreads); + const selectThread = useThreadList((state) => state.selectThread); + const [storedThreadId] = useState(readStoredThreadId); + const sawInitialLoadRef = useRef(false); + const restoreFinishedRef = useRef(false); + + useEffect(() => { + loadThreads(); + }, [loadThreads]); + + useEffect(() => { + if (isLoadingThreads) { + sawInitialLoadRef.current = true; + return; + } + if (!sawInitialLoadRef.current || restoreFinishedRef.current) return; + + restoreFinishedRef.current = true; + if (!storedThreadId) return; + + if (threads.some((thread) => thread.id === storedThreadId)) { + selectThread(storedThreadId); + } else { + removeStoredThreadId(); + } + }, [isLoadingThreads, selectThread, storedThreadId, threads]); + + useEffect(() => { + if (!restoreFinishedRef.current) return; + + if (selectedThreadId) { + storeThreadId(selectedThreadId); + } else { + removeStoredThreadId(); + } + }, [selectedThreadId]); + + return null; +} + +function readStoredThreadId(): string | null { + try { + return sessionStorage.getItem(CLOUD_SELECTED_THREAD_STORAGE_KEY); + } catch { + return null; + } +} + +function storeThreadId(threadId: string): void { + try { + sessionStorage.setItem(CLOUD_SELECTED_THREAD_STORAGE_KEY, threadId); + } catch { + // Persistence is best-effort when browser storage is unavailable. + } +} + +function removeStoredThreadId(): void { + try { + sessionStorage.removeItem(CLOUD_SELECTED_THREAD_STORAGE_KEY); + } catch { + // Persistence is best-effort when browser storage is unavailable. + } +} diff --git a/docs/app/chat/_components/agent-surfaces/comparison-surface-welcome.tsx b/docs/app/chat/_components/agent-surfaces/comparison-surface-welcome.tsx new file mode 100644 index 000000000..87878ac70 --- /dev/null +++ b/docs/app/chat/_components/agent-surfaces/comparison-surface-welcome.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Cloud, LayoutTemplate, TextQuote } from "lucide-react"; +import styles from "../../chat-page.module.css"; +import type { ComparisonMode } from "../comparison-mode-controller"; + +const CONTENT = { + markdown: { + icon: TextQuote, + description: "AI responses rendered as standard Markdown.", + }, + oss: { + icon: LayoutTemplate, + description: "Interactive responses rendered with the open-source OpenUI library.", + }, + cloud: { + icon: Cloud, + description: "Managed generative responses with tools and full-page artifacts.", + }, +} as const; + +export function ComparisonSurfaceWelcome({ mode }: { mode: ComparisonMode }) { + const { icon: Icon, description } = CONTENT[mode]; + + return ( +
+ +

{description}

+
+ ); +} diff --git a/docs/app/chat/_components/agent-surfaces/markdown-agent-surface.tsx b/docs/app/chat/_components/agent-surfaces/markdown-agent-surface.tsx new file mode 100644 index 000000000..3b078ae9e --- /dev/null +++ b/docs/app/chat/_components/agent-surfaces/markdown-agent-surface.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { isDemoCreditsErrorPayload } from "@/lib/demo-credits"; +import { + AgentInterface, + openAIAdapter, + openAIMessageFormat, + type ChatLLM, +} from "@openuidev/react-ui"; +import { useMemo } from "react"; +import type { ComparisonControllerRegistry } from "../comparison-mode-controller"; +import { ComparisonModeControllerBridge } from "../comparison-mode-controller"; +import { ComparisonSurfaceWelcome } from "./comparison-surface-welcome"; + +interface MarkdownAgentSurfaceProps { + themeMode: "light" | "dark"; + onCreditsExhausted: () => void; + registry: ComparisonControllerRegistry; +} + +export function MarkdownAgentSurface({ + themeMode, + onCreditsExhausted, + registry, +}: MarkdownAgentSurfaceProps) { + const llm = useMemo( + () => ({ + send: async ({ messages, signal }) => { + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: openAIMessageFormat.toApi(messages), + responseMode: "markdown", + }), + signal, + }); + + if (!response.ok) { + const errorPayload = await response + .clone() + .json() + .catch(() => ({})); + + if (isDemoCreditsErrorPayload((errorPayload as { error?: unknown }).error)) { + onCreditsExhausted(); + } + } + + return response; + }, + streamProtocol: openAIAdapter(), + }), + [onCreditsExhausted], + ); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx b/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx index 79739b5cd..e9e80d901 100644 --- a/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx +++ b/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx @@ -3,42 +3,32 @@ import { isDemoCreditsErrorPayload } from "@/lib/demo-credits"; import { AgentInterface, + GenUIAssistantMessage, openAIAdapter, openAIMessageFormat, + type AssistantMessage, type ChatLLM, + type GenUIConversationAction, } from "@openuidev/react-ui"; import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import type { ComparisonControllerRegistry } from "../comparison-mode-controller"; +import { ComparisonModeControllerBridge } from "../comparison-mode-controller"; +import { ComparisonSurfaceWelcome } from "./comparison-surface-welcome"; interface OssAgentSurfaceProps { themeMode: "light" | "dark"; onCreditsExhausted: () => void; + registry: ComparisonControllerRegistry; + onConversationAction: (action: GenUIConversationAction) => void; } -const OSS_STARTERS = [ - { - displayText: "Revenue dashboard", - prompt: - "Build a revenue dashboard with a bar chart showing monthly revenue for Q4, key metrics, and a summary table.", - }, - { - displayText: "Signup form", - prompt: - "Create a user registration form with name, email, password, and country fields with validation.", - }, - { - displayText: "Compare React vs Vue", - prompt: - "Show me a comparison of React and Vue frameworks using tabs with pros, cons, and a feature comparison table.", - }, - { - displayText: "Travel destinations", - prompt: - "Show me a carousel of 3 popular travel destinations with images, descriptions, and best time to visit.", - }, -]; - -export function OssAgentSurface({ themeMode, onCreditsExhausted }: OssAgentSurfaceProps) { +export function OssAgentSurface({ + themeMode, + onCreditsExhausted, + registry, + onConversationAction, +}: OssAgentSurfaceProps) { const llm = useMemo( () => ({ send: async ({ messages, signal }) => { @@ -47,6 +37,7 @@ export function OssAgentSurface({ themeMode, onCreditsExhausted }: OssAgentSurfa headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages), + responseMode: "openui", }), signal, }); @@ -59,9 +50,6 @@ export function OssAgentSurface({ themeMode, onCreditsExhausted }: OssAgentSurfa if (isDemoCreditsErrorPayload((errorPayload as { error?: unknown }).error)) { onCreditsExhausted(); - return new Response("data: [DONE]\n\n", { - headers: { "Content-Type": "text/event-stream" }, - }); } } @@ -72,20 +60,39 @@ export function OssAgentSurface({ themeMode, onCreditsExhausted }: OssAgentSurfa [onCreditsExhausted], ); + const AssistantMessageRenderer = useCallback( + ({ message }: { message: AssistantMessage }) => ( + + ), + [onConversationAction], + ); + + const components = useMemo( + () => ({ AssistantMessage: AssistantMessageRenderer }), + [AssistantMessageRenderer], + ); + return (
- + + + + + + +
); diff --git a/docs/app/chat/_components/chat-page-client.tsx b/docs/app/chat/_components/chat-page-client.tsx index 34c893045..1727846d7 100644 --- a/docs/app/chat/_components/chat-page-client.tsx +++ b/docs/app/chat/_components/chat-page-client.tsx @@ -2,13 +2,35 @@ import { DemoCreditsDialog } from "@/components/DemoCreditsDialog"; import { OPENUI_CLOUD_UNAVAILABLE_MESSAGE } from "@/lib/openui-cloud/errors"; +import type { GenUIConversationAction } from "@openuidev/react-ui"; import { useTheme } from "next-themes"; import dynamic from "next/dynamic"; -import { Component, useCallback, useState, type ReactNode } from "react"; +import { + Component, + useCallback, + useEffect, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactNode, +} from "react"; import styles from "../chat-page.module.css"; +import { MarkdownAgentSurface } from "./agent-surfaces/markdown-agent-surface"; import { OssAgentSurface } from "./agent-surfaces/oss-agent-surface"; import { ChatPageHeader } from "./chat-page-header"; -import type { ChatMode } from "./chat-types"; +import { COMPARISON_MODE_LABELS, getComparisonPair, type ComparisonPair } from "./chat-types"; +import { ComparisonControls } from "./comparison-controls"; +import type { + ComparisonMode, + ComparisonModeController, + ComparisonModeSnapshot, +} from "./comparison-mode-controller"; +import { + createComparisonControllerRegistry, + useComparisonModeSnapshot, +} from "./comparison-mode-controller"; + +const ALL_MODES = ["markdown", "oss", "cloud"] as const; const CloudAgentSurface = dynamic( () => import("./agent-surfaces/cloud-agent-surface").then((module) => module.CloudAgentSurface), @@ -18,76 +40,303 @@ const CloudAgentSurface = dynamic( }, ); -interface CloudSurfaceErrorBoundaryProps { +interface SurfaceErrorBoundaryProps { children: ReactNode; + fallbackMessage: string; + onError?: () => void; } -class CloudSurfaceErrorBoundary extends Component< - CloudSurfaceErrorBoundaryProps, - { hasError: boolean } -> { +class SurfaceErrorBoundary extends Component { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } + componentDidCatch() { + this.props.onError?.(); + } + render() { - if (this.state.hasError) return ; + if (this.state.hasError) { + return ; + } return this.props.children; } } export function ChatPageClient() { const { resolvedTheme } = useTheme(); - const [mode, setMode] = useState("oss"); + const [pair, setPair] = useState("markdown-oss"); + const selectedPair = getComparisonPair(pair); + const [mobileMode, setMobileMode] = useState<(typeof ALL_MODES)[number]>(selectedPair.modes[0]); + const [isCompact, setIsCompact] = useState(false); const [announcement, setAnnouncement] = useState(""); const [creditsDialogOpen, setCreditsDialogOpen] = useState(false); + const [unavailableModes, setUnavailableModes] = useState>(() => new Set()); + const [registry] = useState(createComparisonControllerRegistry); const themeMode = resolvedTheme === "dark" ? "dark" : "light"; + const markdown = useComparisonModeSnapshot(registry, "markdown"); + const oss = useComparisonModeSnapshot(registry, "oss"); + const cloud = useComparisonModeSnapshot(registry, "cloud"); + const snapshots = { markdown, oss, cloud } as const; + const availableModeCount = ALL_MODES.length - unavailableModes.size; + const allReady = + availableModeCount > 0 && + ALL_MODES.every((mode) => unavailableModes.has(mode) || snapshots[mode].isReady); + const anyRunning = ALL_MODES.some((mode) => snapshots[mode].isRunning); + const hasStarted = ALL_MODES.some((mode) => snapshots[mode].messageCount > 0); + + useEffect(() => { + const media = window.matchMedia("(max-width: 900px)"); + const update = () => setIsCompact(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + const handleCreditsExhausted = useCallback(() => { setCreditsDialogOpen(true); - }, []); + }, [setCreditsDialogOpen]); + + const markModeUnavailable = useCallback( + (mode: ComparisonMode) => { + setUnavailableModes((current) => { + if (current.has(mode)) return current; + const next = new Set(current); + next.add(mode); + return next; + }); + }, + [setUnavailableModes], + ); + + const getRegisteredControllers = useCallback( + () => + ALL_MODES.map((mode) => registry.getController(mode)).filter( + (controller): controller is ComparisonModeController => controller !== null, + ), + [registry], + ); - const requestModeChange = useCallback( - (nextMode: ChatMode) => { - if (nextMode === mode) return; + const getReadyControllers = useCallback(() => { + const controllers: ComparisonModeController[] = []; + for (const mode of ALL_MODES) { + if (unavailableModes.has(mode)) continue; + const controller = registry.getController(mode); + if (!controller) return null; + controllers.push(controller); + } + return controllers.length > 0 ? controllers : null; + }, [registry, unavailableModes]); - setMode(nextMode); - setAnnouncement( - `${nextMode === "oss" ? "OpenUI OSS" : "OpenUI Cloud"} mode selected. New chat started.`, - ); + const submitToAll = useCallback( + (content: string) => { + const controllers = getReadyControllers(); + if (!controllers) return; + const states = controllers.map((controller) => controller.getSnapshot()); + if (states.some((state) => !state.isReady || state.isRunning)) return; + + void Promise.allSettled(controllers.map((controller) => controller.send(content))); }, - [mode], + [getReadyControllers], ); + const stopAll = useCallback(() => { + const controllers = getRegisteredControllers(); + if (controllers.length === 0) return; + controllers.forEach((controller) => controller.cancel()); + setAnnouncement("Stopped all comparison responses."); + }, [getRegisteredControllers, setAnnouncement]); + + const handleConversationAction = useCallback( + (action: GenUIConversationAction) => submitToAll(action.content), + [submitToAll], + ); + + const resetAll = useCallback(() => { + const controllers = getRegisteredControllers(); + if (controllers.length === 0) return; + controllers.forEach((controller) => controller.reset()); + setAnnouncement("All comparison conversations were reset."); + }, [getRegisteredControllers, setAnnouncement]); + + const requestPairChange = useCallback( + (nextPair: ComparisonPair) => { + const next = getComparisonPair(nextPair); + setPair(nextPair); + setMobileMode((current) => (next.modes.includes(current) ? current : next.modes[0])); + setAnnouncement(`${next.label} selected. Existing conversations were preserved.`); + }, + [setAnnouncement, setMobileMode, setPair], + ); + + const selectAdjacentMobileMode = (event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const nextMode = + mobileMode === selectedPair.modes[0] ? selectedPair.modes[1] : selectedPair.modes[0]; + setMobileMode(nextMode); + requestAnimationFrame(() => document.getElementById(`comparison-tab-${nextMode}`)?.focus()); + }; + return (
-

OpenUI Chat

- - -
- {mode === "oss" ? ( - - ) : ( - - - - )} -
+

Compare OpenUI response modes

+ + +
+
+ {selectedPair.modes.map((mode) => ( + + ))} +
+ +
+ {ALL_MODES.map((mode) => { + const position = selectedPair.modes.indexOf(mode); + const inPair = position !== -1; + const visible = inPair && (!isCompact || mobileMode === mode); + const snapshot = snapshots[mode]; + + return ( + + ); + })} +
+ + 0} + /> +

{announcement}

+

+ {ALL_MODES.map( + (mode) => + `${COMPARISON_MODE_LABELS[mode]} ${getModeStatus( + snapshots[mode], + unavailableModes.has(mode), + )}`, + ).join(". ")} +

setCreditsDialogOpen(false)} />
); } +function getModeStatus(snapshot: ComparisonModeSnapshot, unavailable = false) { + if (unavailable || snapshot.threadError) return "error"; + if (!snapshot.isReady) return "loading"; + if (snapshot.isRunning) return "generating"; + return "ready"; +} + +function ModeStatus({ + snapshot, + unavailable, +}: { + snapshot: ComparisonModeSnapshot; + unavailable: boolean; +}) { + const status = getModeStatus(snapshot, unavailable); + const label = `${status.charAt(0).toUpperCase()}${status.slice(1)}`; + + return ( + + + ); +} + +function ModeStatusDot({ + snapshot, + unavailable, +}: { + snapshot: ComparisonModeSnapshot; + unavailable: boolean; +}) { + const status = getModeStatus(snapshot, unavailable); + return