From 17f8f81d42298b162546915274673556a0a8484e Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:30:31 +0800 Subject: [PATCH 1/2] Unify browser conversations behind adapter-neutral UI --- docs/ui-interaction-and-motion.md | 27 ++ .../components/conversation/ComposerShell.tsx | 24 + .../conversation/ConversationTranscript.tsx | 162 +++++++ .../conversation/ConversationView.spec.tsx | 64 +++ .../conversation/ConversationView.tsx | 110 +++++ .../components/conversation/conversation.css | 199 ++++++++ ui/src/components/conversation/types.ts | 29 ++ .../components/workspace/WebPiView.spec.tsx | 16 +- ui/src/components/workspace/WebPiView.tsx | 430 ++---------------- .../workspace/useWebPiConversation.ts | 44 ++ .../workspace/webpi-presentation.spec.ts | 21 + .../workspace/webpi-presentation.ts | 47 ++ ui/src/components/workspace/workspaces.css | 195 -------- ui/src/pages/ChatLandingPage.tsx | 51 +-- 14 files changed, 802 insertions(+), 617 deletions(-) create mode 100644 ui/src/components/conversation/ComposerShell.tsx create mode 100644 ui/src/components/conversation/ConversationTranscript.tsx create mode 100644 ui/src/components/conversation/ConversationView.spec.tsx create mode 100644 ui/src/components/conversation/ConversationView.tsx create mode 100644 ui/src/components/conversation/conversation.css create mode 100644 ui/src/components/conversation/types.ts create mode 100644 ui/src/components/workspace/useWebPiConversation.ts create mode 100644 ui/src/components/workspace/webpi-presentation.spec.ts create mode 100644 ui/src/components/workspace/webpi-presentation.ts diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md index a6be32e6f..8e04d9a00 100644 --- a/docs/ui-interaction-and-motion.md +++ b/docs/ui-interaction-and-motion.md @@ -145,6 +145,33 @@ reference material, not proof of the installed version or current Workspace configuration. Keep document loading/errors independent, retain the sessions sidebar, and use the shared reading renderer rather than a new Markdown stack. +### Agent conversation presentation + +`components/conversation/` owns the adapter-neutral browser conversation view, +content/activity rendering and composer shell. `ComposerShell` is shared with +the Harness launch page; its context, controls and details are caller-owned +slots, not embedded Pi selectors. Existing `oa-harness-composer-*` styling seams +remain the shared visual material. Messages and composer use a 46rem reading +measure, with local scrolling for wide output and wrapping toolbar controls. + +The normalized types in this folder are ephemeral presentation data, not a new +persisted transcript or execution protocol. An adapter converts wire messages +before rendering and supplies only supported send/stop actions. Missing actions +do not produce fake controls. Reasoning, tool input/output, failed operations, +and unknown payloads remain inspectable; failures expand their activity details. +Presentation must not import runtime APIs, parse provider event discriminators, +or fetch Workspace data. Pi's conversion lives in `webpi-presentation.ts` and +its polling/commands in `useWebPiConversation`; `WebPiView` composes the adapter. + +Pending sends keep and lock their draft until acknowledgement, reject repeated +submission, and preserve the draft on failure. Enter respects IME composition; +Shift+Enter inserts a newline. Session identity changes remount local composition +state and ignore prior requests. New revisions follow the tail only while the +reader is already there; Jump to latest is explicit and honors reduced motion. +Idle needs no top-bar badge; busy and failure states remain visible. Runtime +settings remain in the existing Session settings until an adapter actually +supports a corresponding inline control. + ### Long-form Markdown `MarkdownContent` owns one parser and interaction contract with two deliberate diff --git a/ui/src/components/conversation/ComposerShell.tsx b/ui/src/components/conversation/ComposerShell.tsx new file mode 100644 index 000000000..c67b24d40 --- /dev/null +++ b/ui/src/components/conversation/ComposerShell.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' + +/** Shared geometry; callers retain their input, selectors and action semantics. */ +export function ComposerShell({ context, children, controls, action, details }: { + readonly context?: ReactNode + readonly children: ReactNode + readonly controls?: ReactNode + readonly action: ReactNode + readonly details?: ReactNode +}) { + return ( +
+ {context &&
{context}
} +
+ {children} +
+
{controls}
+ {action} +
+ {details} +
+
+ ) +} diff --git a/ui/src/components/conversation/ConversationTranscript.tsx b/ui/src/components/conversation/ConversationTranscript.tsx new file mode 100644 index 000000000..c509dd168 --- /dev/null +++ b/ui/src/components/conversation/ConversationTranscript.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState, type ReactElement } from 'react' +import { Check, ChevronRight, CircleAlert, CircleDashed, LoaderCircle } from 'lucide-react' +import { MarkdownContent } from '../MarkdownContent' +import type { ConversationActivity, ConversationContent, ConversationItem, ConversationToolStep } from './types' + +export function ConversationTranscriptItem({ + item, + working, +}: { + readonly item: ConversationItem + readonly working: boolean +}): ReactElement { + if (item.kind === 'user') { + return ( +
+
+
+ ) + } + if (item.kind === 'unknown') { + return ( +
+
+
+ ) + } + return ( +
+
+ {item.progress.map((text, index) => ( +
+ ))} + {item.activity && } + {item.final &&
} +
+
+ ) +} + +function ConversationActivityGroup({ activity, working }: { readonly activity: ConversationActivity; readonly working: boolean }): ReactElement { + const failedCount = activity.steps.filter((step) => step.status === 'failed').length + const running = activity.steps.some((step) => step.status === 'running') + const thinkingCount = activity.thinking.length + + activity.steps.reduce((count, step) => count + step.thinking.length, 0) + const [open, setOpen] = useState(failedCount > 0) + + useEffect(() => { + if (failedCount > 0) setOpen(true) + }, [failedCount]) + + const title = failedCount > 0 + ? `${failedCount} failed` + : running ? (working ? 'Working' : 'Incomplete') + : activity.steps.length > 0 ? `${activity.steps.length} action${activity.steps.length === 1 ? '' : 's'}` + : 'Reasoning' + const tools = activity.steps.map((step) => step.name).filter((name, index, names) => names.indexOf(name) === index).join(' · ') + const detail = tools || (thinkingCount > 0 ? `${thinkingCount} note${thinkingCount === 1 ? '' : 's'}` : 'Details') + + return ( +
0 ? ' is-error' : ''}${running ? ' is-running' : ''}`} + open={open} + onToggle={(event) => setOpen(event.currentTarget.open)} + > + + + {title} + {detail} + +
+ {activity.steps.map((step) => )} + {activity.thinking.length > 0 && ( + + )} + {activity.unknownParts.length > 0 && ( +
+ Raw events · {activity.unknownParts.length} +
{activity.unknownParts.join('\n\n')}
+
+ )} +
+
+ ) +} + +function ConversationToolStepView({ step, working }: { readonly step: ConversationToolStep; readonly working: boolean }): ReactElement { + const failed = step.status === 'failed' + const [open, setOpen] = useState(failed) + const summary = step.summary + const resultChars = step.resultChars ?? null + + useEffect(() => { + if (failed) setOpen(true) + }, [failed]) + + return ( +
setOpen(event.currentTarget.open)} + > + + + {step.name} + {summary ?? (step.status === 'running' ? 'Running…' : 'Completed')} + {resultChars !== null && {formatChars(resultChars)}} + +
+ {step.thinking.length > 0 && } +
+

Input

+
{step.input}
+
+ {step.result !== undefined && ( +
+

{failed ? 'Error' : 'Result'}

+
+
+ )} +
+
+ ) +} + +function ConversationReasoning({ notes, label }: { readonly notes: readonly string[]; readonly label: string }): ReactElement { + return ( +
+ {label} · {notes.length} +
+ {notes.map((note, index) => )} +
+
+ ) +} + + +export function ConversationContentView({ content }: { readonly content: ConversationContent }): ReactElement { + return
{content.map((block, index) => { + if (block.kind === 'markdown') return + if (block.kind === 'disclosure') return
{block.label}
+ return
{block.text}
+ })}
+} + +function formatChars(chars: number): string { + if (chars < 1_000) return `${chars} chars` + return `${(chars / 1_000).toFixed(chars < 10_000 ? 1 : 0)}k chars` +} diff --git a/ui/src/components/conversation/ConversationView.spec.tsx b/ui/src/components/conversation/ConversationView.spec.tsx new file mode 100644 index 000000000..6fdd4fd46 --- /dev/null +++ b/ui/src/components/conversation/ConversationView.spec.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ConversationView, type ConversationViewProps } from './ConversationView' + +const base: ConversationViewProps = { + items: [], revision: 1, busy: false, ready: true, + placeholder: 'Ask another agent…', empty: 'Ready', +} +beforeEach(() => { Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: vi.fn() }) }) +afterEach(cleanup) + +describe('adapter-neutral conversation', () => { + it('renders a non-Pi transcript, failed execution and preserved unknown data', () => { + render() + expect(screen.getByText('Checking the repository')).toBeTruthy() + expect(screen.getByText('Missing file')).toBeTruthy() + expect(screen.getByText('1 failed').closest('details')?.open).toBe(true) + expect(screen.getByText('{"customEvent":"retained"}')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Send message' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Stop response' })).toBeNull() + }) + + it('only exposes supported actions and blocks duplicate requests while pending', async () => { + let complete!: () => void + const send = vi.fn(() => new Promise((resolve) => { complete = resolve })) + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'Do the work' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(send).toHaveBeenCalledTimes(1) + expect((input as HTMLTextAreaElement).disabled).toBe(true) + complete() + await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe('')) + }) + + it('keeps the draft on send failure and permits a deliberate retry', async () => { + const send = vi.fn().mockRejectedValueOnce(new Error('Connection lost')).mockResolvedValueOnce(undefined) + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'Keep this draft' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + expect(await screen.findByRole('alert')).toBeTruthy() + expect((input as HTMLTextAreaElement).value).toBe('Keep this draft') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + await waitFor(() => expect(send).toHaveBeenCalledTimes(2)) + }) + + it('cannot send while busy and does not invent stop support', () => { + const send = vi.fn() + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Later' } }) + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) + expect(send).not.toHaveBeenCalled() + expect(screen.queryByRole('button')).toBeNull() + }) +}) diff --git a/ui/src/components/conversation/ConversationView.tsx b/ui/src/components/conversation/ConversationView.tsx new file mode 100644 index 000000000..b7886aef0 --- /dev/null +++ b/ui/src/components/conversation/ConversationView.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { ArrowUp, LoaderCircle, Square } from 'lucide-react' +import { Textarea } from '../ui/textarea' +import { Button } from '../ui/button' +import { ComposerShell } from './ComposerShell' +import { ConversationTranscriptItem } from './ConversationTranscript' +import type { ConversationItem } from './types' +import './conversation.css' + +export interface ConversationViewProps { + readonly items: readonly ConversationItem[] + readonly revision: number + readonly busy: boolean + readonly ready: boolean + readonly placeholder: string + readonly empty: ReactNode + readonly context?: ReactNode + readonly controls?: ReactNode + readonly status?: ReactNode + readonly error?: string | null + /** An absent action means the adapter does not support it. */ + readonly send?: (message: string) => Promise + readonly stop?: () => Promise + readonly stopLabel?: string + readonly retry?: () => void + readonly recover?: () => void +} + +export function isConversationNearBottom(metrics: Pick, threshold = 72): boolean { + return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <= threshold +} + +/** No runtime protocol, polling or workspace knowledge belongs in this view. */ +export function ConversationView(props: ConversationViewProps) { + const [draft, setDraft] = useState('') + const [pending, setPending] = useState(false) + const [actionError, setActionError] = useState(null) + const [following, setFollowing] = useState(true) + const followingRef = useRef(true) + const pendingRef = useRef(false) + const scroller = useRef(null) + const mounted = useRef(true) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) + + function jump(behavior: ScrollBehavior = 'smooth') { + followingRef.current = true + setFollowing(true) + const motion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : behavior + scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: motion }) + } + useEffect(() => { + if (followingRef.current) scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: 'auto' }) + }, [props.revision, props.items.length]) + + async function submit() { + const message = draft.trim() + if (!props.send || !props.ready || props.busy || pendingRef.current || !message) return + pendingRef.current = true + setPending(true) + setActionError(null) + // Lock this draft only until the request is acknowledged; failed sends keep it. + try { + await props.send(message) + if (mounted.current) { setDraft(''); jump('auto') } + } catch (error) { + if (mounted.current) setActionError(error instanceof Error ? error.message : String(error)) + } finally { + pendingRef.current = false + if (mounted.current) setPending(false) + } + } + + async function stop() { + if (!props.stop || pendingRef.current) return + pendingRef.current = true + setPending(true) + setActionError(null) + try { await props.stop() } + catch (error) { if (mounted.current) setActionError(error instanceof Error ? error.message : String(error)) } + finally { pendingRef.current = false; if (mounted.current) setPending(false) } + } + + const error = actionError ?? props.error + return
+
{ + followingRef.current = isConversationNearBottom(event.currentTarget) + setFollowing(followingRef.current) + }}> + {props.items.length === 0 && !error &&
{props.empty}
} + {props.items.map((item, index) => )} + {error &&
+ Could not continue{error} + {props.retry && } + {props.recover && } +
} +
+
+ {!following &&
} + {props.status} + {(props.send || (props.busy && props.stop)) && void stop()}>{pending ? : }) + : props.send && + }> +