Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/ui-interaction-and-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ restore shows a spinner and rejects repeated clicks; failures stay on the row
and allow retry. Headless occupancy still opens the single-writer explanation.
The primary row has no separate play/stop target; settings, stop and archive live
in its options menu. Direct links and history browsers retain view-only opening.
In expanded navigation, a selected Session does not also select its Harness
header. The compact rail retains the Harness selection because Session rows
are hidden there; returning to the Harness landing selects its header.
Quant/Prediction retain their explicit default
Workspace readiness gates before exposing sessions and Studio. The navigation
distinguishes setup, existing-Workspace selection, loading, and
Expand Down Expand Up @@ -145,6 +148,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
Expand Down
24 changes: 24 additions & 0 deletions ui/src/components/conversation/ComposerShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="oa-harness-composer isolate" data-slot="conversation-composer">
{context && <div data-testid="harness-landing-context" className="oa-harness-context-tray relative z-0 mx-[13px] -mb-3 flex min-h-12 min-w-0 items-center gap-0.5 overflow-hidden rounded-t-[20px] px-3 pb-4 pt-2 text-[12px] leading-4 text-muted-foreground">{context}</div>}
<div data-testid="harness-composer-shell" className="oa-harness-composer-shell relative z-10 rounded-[26px] bg-card px-3 pb-2.5 pt-3">
{children}
<div data-testid="harness-landing-controls" className="flex min-h-8 min-w-0 items-end justify-between gap-2 px-0.5 pt-1">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-0.5">{controls}</div>
{action}
</div>
{details}
</div>
</div>
)
}
162 changes: 162 additions & 0 deletions ui/src/components/conversation/ConversationTranscript.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<article className="conversation-message is-user">
<div className="conversation-message-body"><ConversationContentView content={item.content} /></div>
</article>
)
}
if (item.kind === 'unknown') {
return (
<article className="conversation-message is-assistant">
<div className="conversation-message-body"><ConversationContentView content={item.content} /></div>
</article>
)
}
return (
<article className="conversation-message is-assistant is-turn">
<div className="conversation-message-body">
{item.progress.map((text, index) => (
<div key={index} className="conversation-progress-text"><MarkdownContent text={text} /></div>
))}
{item.activity && <ConversationActivityGroup activity={item.activity} working={working} />}
{item.final && <div className="conversation-final-text"><MarkdownContent text={item.final} /></div>}
</div>
</article>
)
}

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 (
<details
className={`conversation-activity${failedCount > 0 ? ' is-error' : ''}${running ? ' is-running' : ''}`}
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary>
<span className="conversation-activity-status" aria-hidden="true">
{failedCount > 0
? <CircleAlert size={14} />
: running
? working ? <LoaderCircle size={14} className="animate-spin" /> : <CircleDashed size={14} />
: <Check size={14} />}
</span>
<span className="conversation-activity-title">{title}</span>
<span className="conversation-activity-meta">{detail}</span>
<ChevronRight size={14} className="conversation-disclosure" aria-hidden="true" />
</summary>
<div className="conversation-activity-body">
{activity.steps.map((step) => <ConversationToolStepView key={step.id} step={step} working={working} />)}
{activity.thinking.length > 0 && (
<ConversationReasoning notes={activity.thinking} label="Final reasoning" />
)}
{activity.unknownParts.length > 0 && (
<details className="conversation-reasoning">
<summary>Raw events · {activity.unknownParts.length}</summary>
<pre>{activity.unknownParts.join('\n\n')}</pre>
</details>
)}
</div>
</details>
)
}

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 (
<details
className={`conversation-tool-step is-${step.status}`}
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary>
<span className="conversation-step-status" aria-hidden="true">
{failed
? <CircleAlert size={13} />
: step.status === 'running'
? working ? <LoaderCircle size={13} className="animate-spin" /> : <CircleDashed size={13} />
: <Check size={13} />}
</span>
<code>{step.name}</code>
<span className="conversation-step-summary">{summary ?? (step.status === 'running' ? 'Running…' : 'Completed')}</span>
{resultChars !== null && <span className="conversation-step-size">{formatChars(resultChars)}</span>}
<ChevronRight size={13} className="conversation-disclosure" aria-hidden="true" />
</summary>
<div className="conversation-step-detail">
{step.thinking.length > 0 && <ConversationReasoning notes={step.thinking} label="Reasoning" />}
<section>
<h4>Input</h4>
<pre>{step.input}</pre>
</section>
{step.result !== undefined && (
<section>
<h4>{failed ? 'Error' : 'Result'}</h4>
<div className="conversation-step-result"><ConversationContentView content={step.result} /></div>
</section>
)}
</div>
</details>
)
}

function ConversationReasoning({ notes, label }: { readonly notes: readonly string[]; readonly label: string }): ReactElement {
return (
<details className="conversation-reasoning">
<summary>{label} · {notes.length}</summary>
<div className="conversation-reasoning-notes">
{notes.map((note, index) => <MarkdownContent key={index} text={note} />)}
</div>
</details>
)
}


export function ConversationContentView({ content }: { readonly content: ConversationContent }): ReactElement {
return <div className="conversation-content-parts">{content.map((block, index) => {
if (block.kind === 'markdown') return <MarkdownContent key={index} text={block.text} />
if (block.kind === 'disclosure') return <details key={index} className="conversation-detail"><summary>{block.label}</summary><ConversationContentView content={block.content} /></details>
return <pre key={index} className="conversation-unknown">{block.text}</pre>
})}</div>
}

function formatChars(chars: number): string {
if (chars < 1_000) return `${chars} chars`
return `${(chars / 1_000).toFixed(chars < 10_000 ? 1 : 0)}k chars`
}
64 changes: 64 additions & 0 deletions ui/src/components/conversation/ConversationView.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<ConversationView {...base} items={[{
kind: 'assistant-turn', key: 'external-turn-1', progress: ['Checking the repository'], final: 'The check failed.',
activity: { thinking: ['Compare the result'], unknownParts: ['{"customEvent":"retained"}'], steps: [{
id: 'external-operation', name: 'validate', summary: 'Check source', input: '{"target":"src"}',
status: 'failed', thinking: [], result: [{ kind: 'markdown', text: 'Missing file' }],
}] },
}]} />)
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<void>((resolve) => { complete = resolve }))
render(<ConversationView {...base} send={send} />)
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(<ConversationView {...base} send={send} />)
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(<ConversationView {...base} busy send={send} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Later' } })
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
expect(send).not.toHaveBeenCalled()
expect(screen.queryByRole('button')).toBeNull()
})
})
Loading