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)}
+ >
+
+
+ {failedCount > 0
+ ?
+ : running
+ ? working ? :
+ : }
+
+ {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)}
+ >
+
+
+ {failed
+ ?
+ : step.status === 'running'
+ ? working ? :
+ : }
+
+ {step.name}
+ {summary ?? (step.status === 'running' ? 'Running…' : 'Completed')}
+ {resultChars !== null && {formatChars(resultChars)}}
+
+
+
+ {step.thinking.length > 0 &&
}
+
+ {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 &&
+ }>
+ }
+
+
+}
diff --git a/ui/src/components/conversation/conversation.css b/ui/src/components/conversation/conversation.css
new file mode 100644
index 000000000..4c841f4ee
--- /dev/null
+++ b/ui/src/components/conversation/conversation.css
@@ -0,0 +1,199 @@
+/* Conversation is deliberately a surface inside the existing terminal slot. */
+.conversation-shell {
+ position: relative;
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ container: conversation / inline-size;
+ color: var(--foreground);
+ background: var(--background);
+}
+
+.conversation-phase { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-foreground); font-size: 10px; text-transform: capitalize; }
+.conversation-phase.is-working, .conversation-phase.is-retrying, .conversation-phase.is-compacting { color: var(--primary); }
+.conversation-phase.is-failed { color: var(--destructive); }
+
+.conversation-messages {
+ flex: 1;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 32px max(24px, calc((100% - 736px) / 2));
+ scrollbar-gutter: stable;
+}
+
+.conversation-empty { padding: 12vh 16px; text-align: center; color: var(--muted-foreground); font-size: 13px; }
+.conversation-message { max-width: 100%; min-width: 0; display: flex; align-items: flex-start; margin: 0 0 26px; }
+.conversation-message.is-user { justify-content: flex-end; margin-left: 12%; }
+.conversation-message-body { flex: 1; min-width: 0; font-size: 14px; line-height: 1.75; }
+.conversation-message.is-user .conversation-message-body { flex: 0 1 auto; max-width: min(88%, 736px); padding: 10px 13px; border: 1px solid var(--border); border-radius: 20px; background: var(--secondary); }
+.conversation-message.is-turn .conversation-message-body { display: grid; gap: 12px; }
+.conversation-message-body > *,
+.conversation-progress-text,
+.conversation-final-text,
+.conversation-content-parts,
+.conversation-detail,
+.conversation-activity,
+.conversation-reasoning {
+ min-width: 0;
+ max-width: 100%;
+}
+.conversation-progress-text { color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); }
+.conversation-final-text { color: var(--foreground); }
+.conversation-message-body .markdown-content {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+}
+.conversation-message-body .code-block-wrapper {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+.conversation-message-body .code-block-wrapper .code-header > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.conversation-message-body .code-block-wrapper pre {
+ width: 100%;
+ max-width: 100%;
+ overscroll-behavior-inline: contain;
+}
+.conversation-message-body .markdown-content table {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+}
+.conversation-content-parts > * + * { margin-top: 9px; }
+.conversation-detail { padding: 6px 9px; border-left: 2px solid var(--border); color: var(--muted-foreground); font-size: 11px; }
+.conversation-detail summary { cursor: pointer; user-select: none; font-weight: 600; }
+.conversation-detail pre, .conversation-unknown { margin-top: 6px; max-height: 220px; overflow: auto; white-space: pre-wrap; font: 10px/1.5 var(--font-mono, monospace); }
+
+.conversation-activity {
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--secondary) 54%, transparent);
+ transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
+}
+.conversation-activity:hover { border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); }
+.conversation-activity[open] { background: color-mix(in srgb, var(--secondary) 72%, transparent); box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 5%, transparent); }
+.conversation-activity.is-error { border-color: color-mix(in srgb, var(--destructive) 48%, var(--border)); }
+.conversation-activity > summary,
+.conversation-tool-step > summary,
+.conversation-reasoning > summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+}
+.conversation-activity > summary::-webkit-details-marker,
+.conversation-tool-step > summary::-webkit-details-marker,
+.conversation-reasoning > summary::-webkit-details-marker { display: none; }
+.conversation-activity > summary {
+ min-height: 36px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ color: var(--muted-foreground);
+ font-size: 11px;
+}
+.conversation-activity-status { width: 18px; height: 18px; flex: 0 0 18px; display: grid; place-items: center; border-radius: 6px; color: color-mix(in srgb, var(--muted-foreground) 82%, transparent); background: color-mix(in srgb, var(--border) 42%, transparent); }
+.conversation-activity.is-running .conversation-activity-status { color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
+.conversation-activity.is-error .conversation-activity-status { color: var(--destructive); background: color-mix(in srgb, var(--destructive) 10%, transparent); }
+.conversation-activity-title { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); font-weight: 650; }
+.conversation-activity-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 10px/1.4 var(--font-mono, monospace); }
+.conversation-disclosure { flex: 0 0 auto; margin-left: auto; transition: transform 150ms ease; }
+.conversation-activity[open] > summary .conversation-disclosure,
+.conversation-tool-step[open] > summary .conversation-disclosure { transform: rotate(90deg); }
+.conversation-activity-body { border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent); padding: 3px 10px 7px; }
+.conversation-activity[open] > .conversation-activity-body,
+.conversation-tool-step[open] > .conversation-step-detail { animation: conversation-detail-reveal 150ms ease-out both; }
+
+.conversation-tool-step { border-bottom: 1px solid color-mix(in srgb, var(--border) 68%, transparent); }
+.conversation-tool-step:last-of-type { border-bottom: 0; }
+.conversation-tool-step > summary { min-height: 38px; display: grid; grid-template-columns: 18px minmax(58px, auto) minmax(0, 1fr) auto 14px; align-items: center; gap: 8px; padding: 6px 1px; color: var(--muted-foreground); }
+.conversation-tool-step > summary:hover { color: var(--foreground); }
+.conversation-step-status { width: 18px; height: 18px; display: grid; place-items: center; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); }
+.conversation-tool-step.is-running .conversation-step-status { color: var(--primary); }
+.conversation-tool-step.is-failed .conversation-step-status { color: var(--destructive); }
+.conversation-tool-step > summary code { overflow: hidden; color: color-mix(in srgb, var(--foreground) 90%, var(--muted-foreground)); font: 600 10px/1.4 var(--font-mono, monospace); text-overflow: ellipsis; }
+.conversation-step-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
+.conversation-step-size { white-space: nowrap; color: color-mix(in srgb, var(--muted-foreground) 72%, transparent); font: 9px/1.4 var(--font-mono, monospace); }
+.conversation-tool-step > summary .conversation-disclosure { margin-left: 0; }
+.conversation-step-detail { display: grid; gap: 9px; margin: 0 0 8px 26px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--background) 64%, transparent); }
+.conversation-step-detail section { min-width: 0; }
+.conversation-step-detail h4 { margin: 0 0 5px; color: var(--muted-foreground); font-size: 9px; font-weight: 700; }
+.conversation-step-detail pre { max-height: 230px; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); font: 10px/1.5 var(--font-mono, monospace); }
+.conversation-step-result { max-height: 320px; overflow: auto; font-size: 11px; }
+
+.conversation-reasoning { color: var(--muted-foreground); }
+.conversation-reasoning > summary { width: fit-content; padding: 7px 1px; font-size: 10px; font-weight: 600; }
+.conversation-reasoning > summary:hover { color: var(--foreground); }
+.conversation-reasoning-notes { display: grid; gap: 8px; margin: 0 0 7px; padding: 8px 10px; border-left: 2px solid color-mix(in srgb, var(--primary) 24%, var(--border)); color: color-mix(in srgb, var(--foreground) 82%, var(--muted-foreground)); font-size: 10px; }
+.conversation-reasoning pre { max-height: 220px; margin: 0 0 7px; overflow: auto; white-space: pre-wrap; font: 10px/1.5 var(--font-mono, monospace); }
+
+.conversation-error { display: grid; gap: 7px; margin: 20px 0; padding: 13px; border: 1px solid color-mix(in srgb, var(--destructive) 50%, var(--border)); border-radius: 10px; color: var(--destructive); font-size: 12px; }
+.conversation-error button { width: fit-content; color: var(--foreground); text-decoration: underline; }
+.conversation-jump-latest { position: absolute; right: max(18px, calc((100% - 736px) / 2)); bottom: 82px; z-index: 2; padding: 6px 10px; border: 1px solid color-mix(in srgb, var(--primary) 42%, var(--border)); border-radius: 999px; color: var(--foreground); background: color-mix(in srgb, var(--secondary) 92%, transparent); box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow-color) 16%, transparent); font-size: 10px; font-weight: 650; }
+.conversation-jump-latest:hover { border-color: var(--primary); color: var(--primary); }
+.conversation-compaction-status { display: flex; align-items: flex-start; gap: 9px; margin: 0 max(18px, calc((100% - 736px) / 2)) 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border)); border-radius: 10px; color: var(--primary); background: color-mix(in srgb, var(--primary) 7%, var(--secondary)); }
+.conversation-compaction-status > svg { flex: 0 0 auto; margin-top: 2px; }
+.conversation-compaction-status > div { display: grid; gap: 2px; min-width: 0; }
+.conversation-compaction-status strong { color: var(--foreground); font-size: 11px; font-weight: 650; }
+.conversation-compaction-status span { color: var(--muted-foreground); font-size: 10px; line-height: 1.45; }
+.conversation-composer-wrap { position: relative; flex-shrink: 0; width: 100%; min-width: 0; padding: 12px max(24px, calc((100% - 736px) / 2)) 20px; }
+.conversation-input[data-slot="textarea"] { display: block; width: 100%; min-height: 68px; max-height: 168px; resize: none; border: 0; border-radius: 0; padding: 6px; color: var(--foreground); background: transparent; box-shadow: none; font-size: 14px; line-height: 21px; }
+.conversation-input[data-slot="textarea"]:focus-visible { outline: none; box-shadow: none; }
+.conversation-send[data-slot="button"] { flex-shrink: 0; border-radius: 999px; color: var(--background); background: var(--foreground); }
+.conversation-send[data-slot="button"]:hover { opacity: .9; }
+.conversation-send[data-slot="button"]:disabled { color: var(--muted-foreground); background: var(--muted); opacity: .55; }
+.conversation-jump-row { position: absolute; bottom: 100%; left: 0; right: 0; display: flex; justify-content: center; padding: 8px; pointer-events: none; }
+.conversation-jump-row .conversation-jump-latest { position: static; pointer-events: auto; font-size: 12px; }
+.conversation-composer-wrap .conversation-compaction-status { margin: 0 0 10px; }
+.conversation-activity > summary, .conversation-tool-step > summary, .conversation-reasoning > summary { font-size: 12px; }
+.conversation-activity-meta, .conversation-step-summary, .conversation-tool-step > summary code { font-size: 11px; }
+.conversation-activity summary:focus-visible, .conversation-detail summary:focus-visible, .conversation-reasoning summary:focus-visible { outline: 2px solid var(--oa-focus-ring); outline-offset: -2px; }
+.conversation-unknown { overflow-wrap: anywhere; }
+
+@keyframes conversation-detail-reveal {
+ from { opacity: 0; transform: translateY(-3px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .conversation-activity[open] > .conversation-activity-body,
+ .conversation-tool-step[open] > .conversation-step-detail { animation: none; }
+}
+
+/* Conversation shares the viewport with the global rail and Workspace sidebar. Its
+ * responsive state must therefore follow the surface's real inline size, not
+ * the browser viewport. */
+@container conversation (max-width: 720px) {
+ .conversation-messages, .conversation-composer-wrap { padding-left: 12px; padding-right: 12px; }
+ .conversation-compaction-status { margin-left: 12px; margin-right: 12px; }
+ .conversation-jump-latest { right: 12px; }
+ .conversation-message.is-user { margin-left: 0; }
+ .conversation-tool-step > summary { grid-template-columns: 18px minmax(52px, auto) minmax(0, 1fr) 14px; }
+ .conversation-step-size { display: none; }
+ .conversation-step-detail { margin-left: 0; }
+}
+
+@container conversation (max-width: 460px) {
+ .conversation-messages { padding-top: 18px; }
+ .conversation-message { margin-bottom: 20px; }
+ .conversation-activity > summary { gap: 6px; padding-inline: 8px; }
+ .conversation-composer-hint { text-align: left; }
+}
diff --git a/ui/src/components/conversation/types.ts b/ui/src/components/conversation/types.ts
new file mode 100644
index 000000000..082129db7
--- /dev/null
+++ b/ui/src/components/conversation/types.ts
@@ -0,0 +1,29 @@
+/** Ephemeral presentation data. Adapters own wire protocols and persistence. */
+export type ConversationContent = readonly ConversationBlock[]
+
+export type ConversationBlock =
+ | { readonly kind: 'markdown'; readonly text: string }
+ | { readonly kind: 'disclosure'; readonly label: string; readonly content: ConversationContent }
+ | { readonly kind: 'data'; readonly text: string }
+
+export interface ConversationToolStep {
+ readonly id: string
+ readonly name: string
+ readonly summary: string | null
+ readonly input: string
+ readonly result?: ConversationContent
+ readonly resultChars?: number
+ readonly thinking: readonly string[]
+ readonly status: 'running' | 'succeeded' | 'failed'
+}
+
+export interface ConversationActivity {
+ readonly steps: readonly ConversationToolStep[]
+ readonly thinking: readonly string[]
+ readonly unknownParts: readonly string[]
+}
+
+export type ConversationItem =
+ | { readonly kind: 'user'; readonly key: string; readonly content: ConversationContent }
+ | { readonly kind: 'assistant-turn'; readonly key: string; readonly progress: readonly string[]; readonly final: string | null; readonly activity: ConversationActivity | null }
+ | { readonly kind: 'unknown'; readonly key: string; readonly content: ConversationContent }
diff --git a/ui/src/components/workspace/WebPiView.spec.tsx b/ui/src/components/workspace/WebPiView.spec.tsx
index d166f0491..eeb3b84da 100644
--- a/ui/src/components/workspace/WebPiView.spec.tsx
+++ b/ui/src/components/workspace/WebPiView.spec.tsx
@@ -65,7 +65,7 @@ describe('WebPi transcript scrolling', () => {
)
await waitFor(() => expect(mocks.getWebPiSession).toHaveBeenCalled())
- const scroller = container.querySelector('.webpi-messages') as HTMLDivElement
+ const scroller = container.querySelector('.conversation-messages') as HTMLDivElement
Object.defineProperties(scroller, {
scrollTop: { configurable: true, writable: true, value: 120 },
clientHeight: { configurable: true, value: 300 },
@@ -91,7 +91,7 @@ describe('WebPi transcript scrolling', () => {
await vi.advanceTimersByTimeAsync(0)
})
- const scroller = container.querySelector('.webpi-messages') as HTMLDivElement
+ const scroller = container.querySelector('.conversation-messages') as HTMLDivElement
Object.defineProperties(scroller, {
scrollTop: { configurable: true, writable: true, value: 120 },
clientHeight: { configurable: true, value: 300 },
@@ -112,6 +112,18 @@ describe('WebPi transcript scrolling', () => {
})
describe('WebPi composer keyboard submission', () => {
+ it('does not let a late response from the previous session replace the selected one', async () => {
+ let resolvePrevious!: (value: WebPiSnapshot) => void
+ mocks.getWebPiSession.mockImplementation((_workspace: string, id: string) => id === 'old'
+ ? new Promise((resolve) => { resolvePrevious = resolve })
+ : Promise.resolve({ ...snapshot('idle'), recordId: 'new', messages: [{ role: 'user', content: 'New conversation' }] }))
+ const { rerender } = render()
+ rerender()
+ expect(await screen.findByText('New conversation')).toBeTruthy()
+ await act(async () => { resolvePrevious({ ...snapshot('idle'), messages: [{ role: 'user', content: 'Old conversation' }] }) })
+ expect(screen.queryByText('Old conversation')).toBeNull()
+ })
+
it('uses the shared content-sized textarea so multiline prompts grow until the CSS cap', async () => {
mocks.getWebPiSession.mockResolvedValue(snapshot('idle'))
render(
diff --git a/ui/src/components/workspace/WebPiView.tsx b/ui/src/components/workspace/WebPiView.tsx
index 95e2241d1..e834d576b 100644
--- a/ui/src/components/workspace/WebPiView.tsx
+++ b/ui/src/components/workspace/WebPiView.tsx
@@ -1,33 +1,10 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
+import { type ReactNode } from 'react'
+import { LoaderCircle } from 'lucide-react'
import { PageTopBar } from '../PageTopBar'
-import { Check, ChevronRight, CircleAlert, CircleDashed, LoaderCircle, Send, Square } from 'lucide-react'
+import { ConversationView } from '../conversation/ConversationView'
+import { useWebPiConversation } from './useWebPiConversation'
-import { MarkdownContent } from '../MarkdownContent'
-import { Textarea } from '@/components/ui/textarea'
-import {
- abortWebPiSession,
- getWebPiSession,
- promptWebPiSession,
- type WebPiSnapshot,
-} from './api'
-import {
- activityToolLabel,
- contentText,
- groupWebPiTranscript,
- summarizeToolInput,
- type WebPiActivity,
- type WebPiToolStep,
- type WebPiTranscriptItem,
-} from './webpi-transcript'
-
-const FOLLOW_LATEST_THRESHOLD_PX = 72
-
-export function isWebPiNearBottom(
- metrics: Pick,
- threshold = FOLLOW_LATEST_THRESHOLD_PX,
-): boolean {
- return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <= threshold
-}
+export { isConversationNearBottom as isWebPiNearBottom } from '../conversation/ConversationView'
interface Props {
readonly wsId: string
@@ -37,367 +14,38 @@ interface Props {
readonly onSessionLost: () => void
}
-/** A thin browser renderer over Pi's own RPC messages. Pi remains responsible
- * for the conversation schema and JSONL persistence; this component does not
- * introduce an OpenAlice message model. */
-export function WebPiView({ wsId, sessionId, label, headerActions, onSessionLost }: Props): ReactElement {
- const [snapshot, setSnapshot] = useState(null)
- const [draft, setDraft] = useState('')
- const [error, setError] = useState(null)
- const [followingLatest, setFollowingLatest] = useState(true)
- const scrollerRef = useRef(null)
- const snapshotRef = useRef(null)
- const followLatestRef = useRef(true)
-
- const acceptSnapshot = useCallback((next: WebPiSnapshot): void => {
- snapshotRef.current = next
- setSnapshot(next)
- }, [])
-
- const refresh = useCallback(async (): Promise => {
- try {
- const next = await getWebPiSession(wsId, sessionId, snapshotRef.current?.revision)
- if (next) acceptSnapshot(next)
- setError(next?.error ?? null)
- } catch (err) {
- setError((err as Error).message)
- }
- }, [acceptSnapshot, sessionId, wsId])
-
- useEffect(() => {
- let cancelled = false
- let timer: number | null = null
- const loop = async (): Promise => {
- await refresh()
- if (cancelled) return
- const phase = snapshotRef.current?.phase
- const delay = phase === 'working' || phase === 'compacting' || phase === 'retrying'
- ? 350
- : 1_500
- timer = window.setTimeout(() => void loop(), delay)
- }
- void loop()
- return () => {
- cancelled = true
- if (timer !== null) window.clearTimeout(timer)
- }
- }, [refresh])
-
- const messages = useMemo(() => {
- if (!snapshot) return []
- return snapshot.streamingMessage
- ? [...snapshot.messages, snapshot.streamingMessage]
- : [...snapshot.messages]
- }, [snapshot])
- const transcript = useMemo(() => groupWebPiTranscript(messages), [messages])
-
- const scrollToLatest = useCallback((behavior: ScrollBehavior = 'smooth'): void => {
- const scroller = scrollerRef.current
- if (!scroller) return
- followLatestRef.current = true
- setFollowingLatest(true)
- scroller.scrollTo({ top: scroller.scrollHeight, behavior })
- }, [])
-
- useEffect(() => {
- if (followLatestRef.current) scrollToLatest('auto')
- }, [scrollToLatest, snapshot?.revision, transcript.length])
-
- const working = snapshot?.phase === 'working' || snapshot?.phase === 'compacting' || snapshot?.phase === 'retrying'
-
- const submit = async (): Promise => {
- const message = draft.trim()
- if (!message || working) return
- followLatestRef.current = true
- setFollowingLatest(true)
- setDraft('')
- setError(null)
- try {
- acceptSnapshot(await promptWebPiSession(wsId, sessionId, message))
- } catch (err) {
- setDraft(message)
- setError((err as Error).message)
- }
- }
-
- const abort = async (): Promise => {
- try {
- acceptSnapshot(await abortWebPiSession(wsId, sessionId))
- } catch (err) {
- setError((err as Error).message)
- }
- }
-
- return (
-
-
- WebPi · Beta
-
- {(working || !snapshot) && }
- {snapshot?.phase ?? 'starting'}
-
-
-
-
{
- const follows = isWebPiNearBottom(event.currentTarget)
- followLatestRef.current = follows
- setFollowingLatest(follows)
- }}
- >
- {messages.length === 0 && !error && (
-
This Pi conversation is ready in the browser.
- )}
- {transcript.map((item, index) => (
-
- ))}
- {error && (
-
- WebPi could not continue.
- {error}
-
-
-
- )}
-
-
- {snapshot?.phase === 'compacting' && (
-
-
-
- Compacting conversation context
- Pi is summarizing older history. Sending will resume when the compact finishes.
-
-
- )}
-
- {!followingLatest && (
-
- )}
-
-
-
-
-
Enter to send · Shift+Enter for a new line
-
-
- )
-}
-
-function PiTranscriptItem({
- item,
- working,
-}: {
- readonly item: WebPiTranscriptItem
- 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 PiActivityGroup({ activity, working }: { readonly activity: WebPiActivity; 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 = activityToolLabel(activity.steps)
- 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)}
- >
-
-
- {failedCount > 0
- ?
- : running
- ? working ? :
- : }
-
- {title}
- {detail}
-
-
-
- {activity.steps.map((step) =>
)}
- {activity.thinking.length > 0 && (
-
- )}
- {activity.unknownParts.length > 0 && (
-
- Raw events · {activity.unknownParts.length}
- {JSON.stringify(activity.unknownParts, null, 2)}
-
- )}
-
-
- )
-}
-
-function PiToolStepView({ step, working }: { readonly step: WebPiToolStep; readonly working: boolean }): ReactElement {
- const failed = step.status === 'failed'
- const [open, setOpen] = useState(failed)
- const summary = summarizeToolInput(step.name, step.input)
- const resultChars = step.result === undefined ? null : contentText(step.result).length
-
- useEffect(() => {
- if (failed) setOpen(true)
- }, [failed])
-
- return (
- setOpen(event.currentTarget.open)}
- >
-
-
- {failed
- ?
- : step.status === 'running'
- ? working ? :
- : }
-
- {step.name}
- {summary ?? (step.status === 'running' ? 'Running…' : 'Completed')}
- {resultChars !== null && {formatChars(resultChars)}}
-
-
-
- {step.thinking.length > 0 &&
}
-
- Input
- {JSON.stringify(step.input, null, 2)}
-
- {step.result !== undefined && (
-
- {failed ? 'Error' : 'Result'}
-
-
- )}
-
-
- )
-}
-
-function PiReasoning({ notes, label }: { readonly notes: readonly string[]; readonly label: string }): ReactElement {
- return (
-
- {label} · {notes.length}
-
- {notes.map((note, index) => )}
-
-
- )
-}
-
-function PiContent({ value }: { readonly value: unknown }): ReactElement {
- if (typeof value === 'string') return
- if (!Array.isArray(value)) {
- const record = asRecord(value)
- const text = typeof record?.['text'] === 'string' ? record['text'] : JSON.stringify(value, null, 2)
- return
- }
- return (
-
- {value.map((part, index) => {
- const item = asRecord(part)
- const type = typeof item?.['type'] === 'string' ? item['type'] : 'unknown'
- if (type === 'text' && typeof item?.['text'] === 'string') {
- return
- }
- if (type === 'thinking') {
- const thinking = typeof item?.['thinking'] === 'string' ? item['thinking'] : String(item?.['text'] ?? '')
- return
Thinking
- }
- if (type === 'toolCall') {
- return (
-
- Used {String(item?.['name'] ?? 'tool')}
- {JSON.stringify(item?.['arguments'] ?? {}, null, 2)}
-
- )
- }
- return
{JSON.stringify(part, null, 2)}
- })}
-
- )
-}
-
-function asRecord(value: unknown): Record | null {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
- ? value as Record
- : null
-}
-
-function formatChars(chars: number): string {
- if (chars < 1_000) return `${chars} chars`
- return `${(chars / 1_000).toFixed(chars < 10_000 ? 1 : 0)}k chars`
+export function WebPiView(props: Props) {
+ return
+}
+
+function WebPiSession({ wsId, sessionId, label, headerActions, onSessionLost }: Props) {
+ const session = useWebPiConversation(wsId, sessionId)
+ const { snapshot, busy } = session
+ return <>
+
+ {(busy || !snapshot || snapshot.phase === 'failed') &&
+ {(busy || !snapshot) && }
+ {snapshot?.phase ?? 'starting'}
+ }
+
+ Pi · Web preview}
+ status={snapshot?.phase === 'compacting' &&
+
+
Compacting conversation contextPi is summarizing older history. Sending will resume when the compact finishes.
+
}
+ error={session.error ?? (snapshot?.phase === 'stopped' ? 'This session has stopped. Refresh the session to reconnect.' : null)}
+ send={session.send}
+ stop={session.stop}
+ stopLabel="Stop Pi"
+ retry={() => void session.refresh()}
+ recover={onSessionLost}
+ />
+ >
}
diff --git a/ui/src/components/workspace/useWebPiConversation.ts b/ui/src/components/workspace/useWebPiConversation.ts
new file mode 100644
index 000000000..9413be429
--- /dev/null
+++ b/ui/src/components/workspace/useWebPiConversation.ts
@@ -0,0 +1,44 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { abortWebPiSession, getWebPiSession, promptWebPiSession, type WebPiSnapshot } from './api'
+import { presentPiTranscript } from './webpi-presentation'
+
+/** One mounted identity; WebPiView keys this hook's owner by workspace/session. */
+export function useWebPiConversation(wsId: string, sessionId: string) {
+ const [snapshot, setSnapshot] = useState(null)
+ const [error, setError] = useState(null)
+ const current = useRef(null)
+ const alive = useRef(false)
+ const accept = useCallback((next: WebPiSnapshot) => {
+ if (!alive.current || (current.current && next.revision < current.current.revision)) return
+ current.current = next
+ setSnapshot(next)
+ setError(next.error)
+ }, [])
+ const refresh = useCallback(async () => {
+ try {
+ const next = await getWebPiSession(wsId, sessionId, current.current?.revision)
+ if (next) accept(next)
+ else if (alive.current) setError(current.current?.error ?? null)
+ } catch (error) { if (alive.current) setError(error instanceof Error ? error.message : String(error)) }
+ }, [accept, wsId, sessionId])
+ useEffect(() => {
+ alive.current = true
+ let cancelled = false
+ let timer: number | undefined
+ async function poll() {
+ await refresh()
+ if (cancelled) return
+ timer = window.setTimeout(() => void poll(), isBusy(current.current?.phase) ? 350 : 1500)
+ }
+ void poll()
+ return () => { alive.current = false; cancelled = true; window.clearTimeout(timer) }
+ }, [refresh])
+ const items = useMemo(() => presentPiTranscript(snapshot ? [...snapshot.messages, ...(snapshot.streamingMessage ? [snapshot.streamingMessage] : [])] : []), [snapshot])
+ return {
+ snapshot, error, items, busy: isBusy(snapshot?.phase), refresh,
+ send: async (message: string) => { accept(await promptWebPiSession(wsId, sessionId, message)) },
+ stop: async () => { accept(await abortWebPiSession(wsId, sessionId)) },
+ }
+}
+
+function isBusy(phase: WebPiSnapshot['phase'] | undefined) { return phase === 'working' || phase === 'retrying' || phase === 'compacting' }
diff --git a/ui/src/components/workspace/webpi-presentation.spec.ts b/ui/src/components/workspace/webpi-presentation.spec.ts
new file mode 100644
index 000000000..5baeca5e9
--- /dev/null
+++ b/ui/src/components/workspace/webpi-presentation.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { piContent, presentPiTranscript } from './webpi-presentation'
+
+describe('Pi presentation boundary', () => {
+ it('normalizes wire content without losing unrecognized events', () => {
+ expect(piContent([{ type: 'text', text: 'Hello' }, { type: 'thinking', thinking: 'Reason' }, { type: 'future', value: 42 }])).toEqual([
+ { kind: 'markdown', text: 'Hello' },
+ { kind: 'disclosure', label: 'Thinking', content: [{ kind: 'markdown', text: 'Reason' }] },
+ { kind: 'data', text: JSON.stringify({ type: 'future', value: 42 }, null, 2) },
+ ])
+ })
+ it('normalizes correlated tool results rather than passing Pi parts into the UI', () => {
+ const items = presentPiTranscript([
+ { role: 'assistant', content: [{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'README.md' } }] },
+ { role: 'toolResult', toolCallId: 'call-1', content: [{ type: 'text', text: 'Not found' }], isError: true },
+ ])
+ expect(items[0]).toMatchObject({ kind: 'assistant-turn', activity: { steps: [{
+ status: 'failed', result: [{ kind: 'markdown', text: 'Not found' }], input: JSON.stringify({ path: 'README.md' }, null, 2),
+ }] } })
+ })
+})
diff --git a/ui/src/components/workspace/webpi-presentation.ts b/ui/src/components/workspace/webpi-presentation.ts
new file mode 100644
index 000000000..cd1940813
--- /dev/null
+++ b/ui/src/components/workspace/webpi-presentation.ts
@@ -0,0 +1,47 @@
+import type { ConversationContent, ConversationItem } from '../conversation/types'
+import { contentText, groupWebPiTranscript, summarizeToolInput } from './webpi-transcript'
+
+/** Pi's wire content is interpreted here, never by the shared renderer. */
+export function piContent(value: unknown): ConversationContent {
+ if (typeof value === 'string') return [{ kind: 'markdown', text: value }]
+ if (!Array.isArray(value)) {
+ const item = record(value)
+ return typeof item?.text === 'string'
+ ? [{ kind: 'markdown', text: item.text }]
+ : [{ kind: 'data', text: json(value) }]
+ }
+ return value.flatMap((part): ConversationContent => {
+ const item = record(part)
+ if (item?.type === 'text' && typeof item.text === 'string') return [{ kind: 'markdown', text: item.text }]
+ if (item?.type === 'thinking') return [{ kind: 'disclosure', label: 'Thinking', content: [{ kind: 'markdown', text: String(item.thinking ?? item.text ?? '') }] }]
+ if (item?.type === 'toolCall') return [{ kind: 'disclosure', label: `Used ${String(item.name ?? 'tool')}`, content: [{ kind: 'data', text: json(item.arguments ?? {}) }] }]
+ return [{ kind: 'data', text: json(part) }]
+ })
+}
+
+export function presentPiTranscript(messages: readonly unknown[]): ConversationItem[] {
+ return groupWebPiTranscript(messages).map((item): ConversationItem => {
+ if (item.kind === 'user') return { ...item, content: piContent(item.content) }
+ if (item.kind === 'unknown') return { kind: 'unknown', key: item.key, content: [{ kind: 'data', text: json(item.value) }] }
+ return {
+ ...item,
+ activity: item.activity && {
+ ...item.activity,
+ unknownParts: item.activity.unknownParts.map(json),
+ steps: item.activity.steps.map((step) => ({
+ ...step,
+ summary: summarizeToolInput(step.name, step.input),
+ input: json(step.input),
+ result: step.result === undefined ? undefined : piContent(step.result),
+ resultChars: step.result === undefined ? undefined : contentText(step.result).length,
+ })),
+ },
+ }
+ })
+}
+
+function record(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : null
+}
+
+function json(value: unknown): string { return JSON.stringify(value, null, 2) ?? '' }
diff --git a/ui/src/components/workspace/workspaces.css b/ui/src/components/workspace/workspaces.css
index 969a02f6f..03148c734 100644
--- a/ui/src/components/workspace/workspaces.css
+++ b/ui/src/components/workspace/workspaces.css
@@ -1307,201 +1307,6 @@ button.files-row:focus-visible {
color: var(--foreground);
}
-/* WebPi is deliberately a surface inside the existing terminal slot. */
-.webpi-shell {
- position: relative;
- width: 100%;
- max-width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- container: webpi / inline-size;
- color: var(--foreground);
- background:
- radial-gradient(circle at 50% -20%, color-mix(in srgb, var(--primary) 8%, transparent), transparent 38%),
- var(--background);
-}
-
-.webpi-phase { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-foreground); font-size: 10px; text-transform: capitalize; }
-.webpi-phase.is-working, .webpi-phase.is-retrying, .webpi-phase.is-compacting { color: var(--primary); }
-.webpi-phase.is-failed { color: var(--destructive); }
-
-.webpi-messages {
- flex: 1;
- width: 100%;
- max-width: 100%;
- min-width: 0;
- min-height: 0;
- overflow-y: auto;
- overflow-x: hidden;
- padding: 26px max(18px, calc((100% - 760px) / 2));
- scrollbar-gutter: stable;
-}
-
-.webpi-empty { padding: 12vh 16px; text-align: center; color: var(--muted-foreground); font-size: 13px; }
-.webpi-message { max-width: 100%; min-width: 0; display: flex; align-items: flex-start; margin: 0 0 26px; }
-.webpi-message.is-user { justify-content: flex-end; margin-left: 12%; }
-.webpi-message-body { flex: 1; min-width: 0; font-size: 13px; line-height: 1.65; }
-.webpi-message.is-user .webpi-message-body { flex: 0 1 auto; max-width: min(88%, 760px); padding: 10px 13px; border: 1px solid var(--border); border-radius: 12px; background: var(--secondary); }
-.webpi-message.is-turn .webpi-message-body { display: grid; gap: 12px; }
-.webpi-message-body > *,
-.webpi-progress-text,
-.webpi-final-text,
-.webpi-content-parts,
-.webpi-detail,
-.webpi-activity,
-.webpi-reasoning {
- min-width: 0;
- max-width: 100%;
-}
-.webpi-progress-text { color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); }
-.webpi-final-text { color: var(--foreground); }
-.webpi-message-body .markdown-content {
- width: 100%;
- min-width: 0;
- max-width: 100%;
- overflow-wrap: anywhere;
-}
-.webpi-message-body .code-block-wrapper {
- width: 100%;
- min-width: 0;
- max-width: 100%;
-}
-.webpi-message-body .code-block-wrapper .code-header > span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.webpi-message-body .code-block-wrapper pre {
- width: 100%;
- max-width: 100%;
- overscroll-behavior-inline: contain;
-}
-.webpi-message-body .markdown-content table {
- display: block;
- max-width: 100%;
- overflow-x: auto;
- overscroll-behavior-inline: contain;
-}
-.webpi-content-parts > * + * { margin-top: 9px; }
-.webpi-detail { padding: 6px 9px; border-left: 2px solid var(--border); color: var(--muted-foreground); font-size: 11px; }
-.webpi-detail summary { cursor: pointer; user-select: none; font-weight: 600; }
-.webpi-detail pre, .webpi-unknown { margin-top: 6px; max-height: 220px; overflow: auto; white-space: pre-wrap; font: 10px/1.5 var(--font-mono, monospace); }
-
-.webpi-activity {
- overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
- border-radius: 10px;
- background: color-mix(in srgb, var(--secondary) 54%, transparent);
- transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
-}
-.webpi-activity:hover { border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); }
-.webpi-activity[open] { background: color-mix(in srgb, var(--secondary) 72%, transparent); box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 5%, transparent); }
-.webpi-activity.is-error { border-color: color-mix(in srgb, var(--destructive) 48%, var(--border)); }
-.webpi-activity > summary,
-.webpi-tool-step > summary,
-.webpi-reasoning > summary {
- list-style: none;
- cursor: pointer;
- user-select: none;
-}
-.webpi-activity > summary::-webkit-details-marker,
-.webpi-tool-step > summary::-webkit-details-marker,
-.webpi-reasoning > summary::-webkit-details-marker { display: none; }
-.webpi-activity > summary {
- min-height: 36px;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 7px 10px;
- color: var(--muted-foreground);
- font-size: 11px;
-}
-.webpi-activity-status { width: 18px; height: 18px; flex: 0 0 18px; display: grid; place-items: center; border-radius: 6px; color: color-mix(in srgb, var(--muted-foreground) 82%, transparent); background: color-mix(in srgb, var(--border) 42%, transparent); }
-.webpi-activity.is-running .webpi-activity-status { color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
-.webpi-activity.is-error .webpi-activity-status { color: var(--destructive); background: color-mix(in srgb, var(--destructive) 10%, transparent); }
-.webpi-activity-title { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); font-weight: 650; }
-.webpi-activity-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 10px/1.4 var(--font-mono, monospace); }
-.webpi-disclosure { flex: 0 0 auto; margin-left: auto; transition: transform 150ms ease; }
-.webpi-activity[open] > summary .webpi-disclosure,
-.webpi-tool-step[open] > summary .webpi-disclosure { transform: rotate(90deg); }
-.webpi-activity-body { border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent); padding: 3px 10px 7px; }
-.webpi-activity[open] > .webpi-activity-body,
-.webpi-tool-step[open] > .webpi-step-detail { animation: webpi-detail-reveal 150ms ease-out both; }
-
-.webpi-tool-step { border-bottom: 1px solid color-mix(in srgb, var(--border) 68%, transparent); }
-.webpi-tool-step:last-of-type { border-bottom: 0; }
-.webpi-tool-step > summary { min-height: 38px; display: grid; grid-template-columns: 18px minmax(58px, auto) minmax(0, 1fr) auto 14px; align-items: center; gap: 8px; padding: 6px 1px; color: var(--muted-foreground); }
-.webpi-tool-step > summary:hover { color: var(--foreground); }
-.webpi-step-status { width: 18px; height: 18px; display: grid; place-items: center; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); }
-.webpi-tool-step.is-running .webpi-step-status { color: var(--primary); }
-.webpi-tool-step.is-failed .webpi-step-status { color: var(--destructive); }
-.webpi-tool-step > summary code { overflow: hidden; color: color-mix(in srgb, var(--foreground) 90%, var(--muted-foreground)); font: 600 10px/1.4 var(--font-mono, monospace); text-overflow: ellipsis; }
-.webpi-step-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
-.webpi-step-size { white-space: nowrap; color: color-mix(in srgb, var(--muted-foreground) 72%, transparent); font: 9px/1.4 var(--font-mono, monospace); }
-.webpi-tool-step > summary .webpi-disclosure { margin-left: 0; }
-.webpi-step-detail { display: grid; gap: 9px; margin: 0 0 8px 26px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--background) 64%, transparent); }
-.webpi-step-detail section { min-width: 0; }
-.webpi-step-detail h4 { margin: 0 0 5px; color: var(--muted-foreground); font-size: 9px; font-weight: 700; }
-.webpi-step-detail pre { max-height: 230px; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); font: 10px/1.5 var(--font-mono, monospace); }
-.webpi-step-result { max-height: 320px; overflow: auto; font-size: 11px; }
-
-.webpi-reasoning { color: var(--muted-foreground); }
-.webpi-reasoning > summary { width: fit-content; padding: 7px 1px; font-size: 10px; font-weight: 600; }
-.webpi-reasoning > summary:hover { color: var(--foreground); }
-.webpi-reasoning-notes { display: grid; gap: 8px; margin: 0 0 7px; padding: 8px 10px; border-left: 2px solid color-mix(in srgb, var(--primary) 24%, var(--border)); color: color-mix(in srgb, var(--foreground) 82%, var(--muted-foreground)); font-size: 10px; }
-.webpi-reasoning pre { max-height: 220px; margin: 0 0 7px; overflow: auto; white-space: pre-wrap; font: 10px/1.5 var(--font-mono, monospace); }
-
-.webpi-error { display: grid; gap: 7px; margin: 20px 0; padding: 13px; border: 1px solid color-mix(in srgb, var(--destructive) 50%, var(--border)); border-radius: 10px; color: var(--destructive); font-size: 12px; }
-.webpi-error button { width: fit-content; color: var(--foreground); text-decoration: underline; }
-.webpi-jump-latest { position: absolute; right: max(18px, calc((100% - 760px) / 2)); bottom: 82px; z-index: 2; padding: 6px 10px; border: 1px solid color-mix(in srgb, var(--primary) 42%, var(--border)); border-radius: 999px; color: var(--foreground); background: color-mix(in srgb, var(--secondary) 92%, transparent); box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow-color) 16%, transparent); font-size: 10px; font-weight: 650; }
-.webpi-jump-latest:hover { border-color: var(--primary); color: var(--primary); }
-.webpi-compaction-status { display: flex; align-items: flex-start; gap: 9px; margin: 0 max(18px, calc((100% - 760px) / 2)) 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border)); border-radius: 10px; color: var(--primary); background: color-mix(in srgb, var(--primary) 7%, var(--secondary)); }
-.webpi-compaction-status > svg { flex: 0 0 auto; margin-top: 2px; }
-.webpi-compaction-status > div { display: grid; gap: 2px; min-width: 0; }
-.webpi-compaction-status strong { color: var(--foreground); font-size: 11px; font-weight: 650; }
-.webpi-compaction-status span { color: var(--muted-foreground); font-size: 10px; line-height: 1.45; }
-.webpi-composer-wrap { width: 100%; max-width: 100%; min-width: 0; padding: 10px max(18px, calc((100% - 760px) / 2)) 14px; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--background) 88%, transparent); }
-.webpi-composer { width: 100%; max-width: 100%; min-width: 0; display: flex; align-items: flex-end; gap: 8px; padding: 9px 10px 9px 13px; border: 1px solid var(--border); border-radius: 14px; background: var(--secondary); box-shadow: 0 6px 24px color-mix(in srgb, var(--shadow-color) 9%, transparent); }
-.webpi-composer:focus-within { border-color: color-mix(in srgb, var(--primary) 60%, var(--border)); }
-.webpi-composer [data-slot="textarea"] { width: 100%; min-width: 0; flex: 1; min-height: 25px; max-height: 150px; overflow-y: auto; resize: none; border: 0; border-radius: 0; padding: 0; outline: 0; color: var(--foreground); background: transparent; box-shadow: none; font: inherit; font-size: 13px; line-height: 1.6; }
-.webpi-send { width: 30px; height: 30px; flex: 0 0 30px; display: grid; place-items: center; border-radius: 9px; color: var(--primary-foreground); background: var(--primary); }
-.webpi-send:disabled { opacity: .35; }
-.webpi-composer-hint { padding: 5px 3px 0; text-align: right; color: var(--muted-foreground); font-size: 9px; }
-
-@keyframes webpi-detail-reveal {
- from { opacity: 0; transform: translateY(-3px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .webpi-activity[open] > .webpi-activity-body,
- .webpi-tool-step[open] > .webpi-step-detail { animation: none; }
-}
-
-/* WebPi shares the viewport with the global rail and Workspace sidebar. Its
- * responsive state must therefore follow the surface's real inline size, not
- * the browser viewport. */
-@container webpi (max-width: 720px) {
- .webpi-messages, .webpi-composer-wrap { padding-left: 12px; padding-right: 12px; }
- .webpi-compaction-status { margin-left: 12px; margin-right: 12px; }
- .webpi-jump-latest { right: 12px; }
- .webpi-message.is-user { margin-left: 0; }
- .webpi-tool-step > summary { grid-template-columns: 18px minmax(52px, auto) minmax(0, 1fr) 14px; }
- .webpi-step-size { display: none; }
- .webpi-step-detail { margin-left: 0; }
-}
-
-@container webpi (max-width: 460px) {
- .webpi-messages { padding-top: 18px; }
- .webpi-message { margin-bottom: 20px; }
- .webpi-activity > summary { gap: 6px; padding-inline: 8px; }
- .webpi-composer-hint { text-align: left; }
-}
@container resume-cta (max-width: 480px) {
.resume-cta-actions .resume-cta-btn {
diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx
index 11b9e8634..ecc294699 100644
--- a/ui/src/pages/ChatLandingPage.tsx
+++ b/ui/src/pages/ChatLandingPage.tsx
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react'
import { useTranslation } from 'react-i18next'
+import { ComposerShell } from '../components/conversation/ComposerShell'
import { PageTopBar } from '../components/PageTopBar'
import {
ArrowUp,
@@ -545,11 +546,8 @@ function HarnessLandingPage({