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
18 changes: 16 additions & 2 deletions docs/ui-interaction-and-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,14 @@ retryable errors. Without a Workspace, only the Harness header remains: clicking
it opens the existing setup landing flow, without creating or selecting a
Workspace. Do not repeat setup copy or a second setup button below it. Before
readiness, the new-research shortcut is hidden.
Studio is a compact outlined tool button with route-owned selection, separate
from conversation rows; Quant and Prediction share its presentation.
Studio is a quiet, borderless child navigation row aligned with Sessions, with
route-owned selection. Its arrow appears on hover or keyboard focus and remains
visible on touch devices; Quant and Prediction share its presentation.
`SidebarChildRow` and `SidebarChildRowButton` own Harness child geometry for
both Studio and Sessions: a 16px icon slot, 8px label gap, shared selection and
keyboard focus, and sibling action controls. Expanded fine-pointer desktop rows
are 30px tall with no additional per-destination vertical padding; other surfaces
retain the existing Session row density. Keep runtime behavior in the caller.
Harness working views use one content top bar, not a second conversation sidebar.
TerminalView has no card/canvas mode: its header always uses PageTopBar and its
single grid row fills the remaining height. Do not reserve a local header row
Expand Down Expand Up @@ -172,6 +178,14 @@ 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.
User messages use a quiet, borderless bubble; assistant prose sits directly on
the canvas. Execution summaries are lightweight disclosure rows, with an inset
rail for individual actions rather than nested activity cards. Preserve the
shared Markdown table scroll wrapper instead of overriding table display.
Completed text has a copy action that copies only the displayed message, not
reasoning or tool payloads. Older actions reveal on hover or keyboard focus;
the latest message and touch surfaces keep them visible. Clipboard failures
are actionable, and interrupted tools must say incomplete rather than completed.

The normalized types in this folder are ephemeral presentation data, not a new
persisted transcript or execution protocol. An adapter converts wire messages
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/ActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function ActivityBar({
badge = {
count: unreadInbox,
label: t('nav.unread', { count: unreadInbox }),
tone: 'bg-sidebar-foreground text-sidebar',
tone: 'oa-inbox-unread-count',
}
} else if (item.page === 'portfolio' && pendingPush > 0) {
badge = {
Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/InboxSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function WorkspaceView({
{workspaceLabel}
</span>
{unread > 0 && (
<span className="shrink-0 min-w-[15px] h-[15px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] leading-[14px] font-semibold tabular-nums flex items-center justify-center">
<span className="shrink-0 min-w-[15px] text-center text-muted-foreground text-[11px] leading-[15px] font-medium tabular-nums">
{unread}
</span>
)}
Expand Down Expand Up @@ -393,7 +393,7 @@ function ClusterRow({
{active && <SelectionIndicator />}
<span
aria-hidden
className={`mt-[7px] shrink-0 w-1.5 h-1.5 rounded-full ${unread ? 'bg-primary' : 'bg-transparent'}`}
className={`mt-[7px] shrink-0 w-1.5 h-1.5 rounded-full ${unread ? 'oa-inbox-unread-dot' : 'bg-transparent'}`}
/>
<span className="min-w-0">
<span
Expand Down Expand Up @@ -499,7 +499,7 @@ function TimeRow({
<div className="flex min-w-0 items-start gap-1.5">
<span
aria-hidden
className={`mt-1.5 shrink-0 w-1.5 h-1.5 rounded-full ${unread ? 'bg-primary' : 'bg-transparent'}`}
className={`mt-1.5 shrink-0 w-1.5 h-1.5 rounded-full ${unread ? 'oa-inbox-unread-dot' : 'bg-transparent'}`}
/>
<span className="min-w-0 flex-1">
<span
Expand Down
23 changes: 23 additions & 0 deletions ui/src/components/SidebarChildRow.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { SidebarChildRow, SidebarChildRowButton } from './SidebarChildRow'

afterEach(cleanup)

it('keeps destination and trailing actions independent with shared selection geometry', () => {
const open = vi.fn()
const options = vi.fn()
render(<SidebarChildRow active>
<SidebarChildRowButton icon={<svg />} onClick={open} aria-current="page">Studio</SidebarChildRowButton>
<button onClick={options}>Options</button>
</SidebarChildRow>)
const main = screen.getByRole('button', { name: 'Studio' })
expect(main.getAttribute('aria-current')).toBe('page')
expect(main.parentElement?.dataset.active).toBe('true')
expect(main.querySelector('span')?.className).toContain('size-4')
fireEvent.click(screen.getByRole('button', { name: 'Options' }))
expect(open).not.toHaveBeenCalled()
fireEvent.click(main)
expect(open).toHaveBeenCalledOnce()
expect(options).toHaveBeenCalledOnce()
})
19 changes: 19 additions & 0 deletions ui/src/components/SidebarChildRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ComponentProps, ReactNode } from 'react'
import { SelectionIndicator } from './SelectionIndicator'

/** Shared geometry for Harness child destinations; actions are sibling controls. */
export function SidebarChildRow({ active, className = '', children, ...props }: ComponentProps<'div'> & { active: boolean }) {
return <div {...props} data-active={active}
className={`oa-sidebar-child-row text-body group relative mx-1.5 flex min-h-9 items-center gap-1 rounded-md px-2 py-1.5 transition-colors ${active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/65'} ${className}`}>
{active && <SelectionIndicator />}
{children}
</div>
}

export function SidebarChildRowButton({ icon, children, className = '', ...props }: ComponentProps<'button'> & { icon: ReactNode }) {
return <button type="button" {...props}
className={`oa-sidebar-child-row-main flex min-w-0 flex-1 items-center gap-2 text-left outline-none disabled:cursor-default ${className}`}>
<span className="flex size-4 shrink-0 items-center justify-center text-foreground/80" aria-hidden>{icon}</span>
{children}
</button>
}
11 changes: 8 additions & 3 deletions ui/src/components/conversation/ConversationTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ 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'
import { MessageActions } from './MessageActions'

export function ConversationTranscriptItem({
item,
working,
latest = false,
}: {
readonly item: ConversationItem
readonly working: boolean
readonly latest?: boolean
}): ReactElement {
if (item.kind === 'user') {
return (
<article className="conversation-message is-user">
<article className={`conversation-message is-user${latest ? ' is-latest' : ''}`}>
<div className="conversation-message-body"><ConversationContentView content={item.content} /></div>
{item.content.some(block => block.kind === 'markdown') && <MessageActions text={item.content.flatMap(block => block.kind === 'markdown' ? [block.text] : []).join('\n\n')} />}
</article>
)
}
Expand All @@ -32,14 +36,15 @@ export function ConversationTranscriptItem({
)
}
return (
<article className="conversation-message is-assistant is-turn">
<article className={`conversation-message is-assistant is-turn${latest ? ' is-latest' : ''}`}>
<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>
{!working && item.final && <MessageActions text={item.final} />}
</article>
)
}
Expand Down Expand Up @@ -122,7 +127,7 @@ function ConversationToolStepView({ step, working }: { readonly step: Conversati
: <Check size={13} />}
</span>
<code>{step.name}</code>
<span className="conversation-step-summary">{summary ?? (step.status === 'running' ? 'Running…' : 'Completed')}</span>
<span className="conversation-step-summary">{summary ?? (step.status === 'running' ? (working ? 'Running…' : 'Incomplete') : failed ? 'Failed' : 'Completed')}</span>
{resultChars !== null && <span className="conversation-step-size">{formatChars(resultChars)}</span>}
<ChevronRight size={13} className="conversation-disclosure" aria-hidden="true" />
</summary>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/conversation/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function ConversationView(props: ConversationViewProps) {
setFollowing(followingRef.current)
}}>
{props.items.length === 0 && !error && <div className="conversation-empty">{props.empty}</div>}
{props.items.map((item, index) => <ConversationTranscriptItem key={item.key} item={item} working={props.busy && index === props.items.length - 1} />)}
{props.items.map((item, index) => <ConversationTranscriptItem key={item.key} item={item} latest={index === props.items.length - 1} working={props.busy && index === props.items.length - 1} />)}
{error && <div className="conversation-error" role="alert">
<strong>Could not continue</strong><span>{error}</span>
{props.retry && <button type="button" onClick={() => { setActionError(null); props.retry?.() }}>Retry</button>}
Expand Down
45 changes: 45 additions & 0 deletions ui/src/components/conversation/MessageActions.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { ConversationTranscriptItem } from './ConversationTranscript'
import { MessageActions } from './MessageActions'

afterEach(() => { cleanup(); vi.restoreAllMocks() })

it('copies the final response without reasoning or tool data', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ConversationTranscriptItem working={false} item={{
kind: 'assistant-turn', key: 'turn', progress: ['Progress only'], final: '**Final answer**',
activity: { steps: [], thinking: ['Private reasoning'], unknownParts: ['raw payload'] },
}} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy message' }))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeTruthy()
expect(writeText).toHaveBeenCalledWith('**Final answer**')
})

it('reports clipboard failures and supports a deliberate retry', async () => {
const writeText = vi.fn().mockRejectedValueOnce(new Error('Denied')).mockResolvedValueOnce(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<MessageActions text="Reply" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy message' }))
expect(await screen.findByRole('alert')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Copy message' }))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeTruthy()
expect(screen.queryByRole('alert')).toBeNull()
})

it('does not offer copy for a response still streaming', () => {
render(<ConversationTranscriptItem working item={{ kind: 'assistant-turn', key: 'stream', progress: [], final: 'Partial', activity: null }} />)
expect(screen.queryByRole('button', { name: 'Copy message' })).toBeNull()
})

it('labels an unfinished tool as incomplete after execution stops', () => {
render(<ConversationTranscriptItem working={false} item={{
kind: 'assistant-turn', key: 'interrupted', progress: [], final: null,
activity: { thinking: [], unknownParts: [], steps: [{ id: 'read', name: 'read', summary: null, status: 'running', input: '{}', thinking: [] }] },
}} />)
expect(screen.getAllByText('Incomplete')).toHaveLength(2)
expect(screen.queryByText('Completed')).toBeNull()
expect(screen.queryByText('Running…')).toBeNull()
})
38 changes: 38 additions & 0 deletions ui/src/components/conversation/MessageActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'

/** Operates only on the visible message, never reasoning or tool payloads. */
export function MessageActions({ text }: { readonly text: string }) {
const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false; clearTimeout(timer.current) }
}, [])
async function copy() {
clearTimeout(timer.current)
setState('idle')
try {
await navigator.clipboard.writeText(text)
if (!mounted.current) return
setState('copied')
clearTimeout(timer.current)
timer.current = setTimeout(() => setState('idle'), 2000)
} catch {
if (mounted.current) setState('failed')
}
}
const label = state === 'copied' ? 'Copied' : 'Copy message'
return <div className={`conversation-message-actions${state === 'failed' ? ' is-error' : ''}`}>
<Tooltip>
<TooltipTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label={label} onClick={() => void copy()} />}>
{state === 'copied' ? <Check aria-hidden /> : <Copy aria-hidden />}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
{state === 'failed' && <span className="conversation-copy-error" role="alert">Could not copy. Select the message to copy it manually.</span>}
</div>
}
Loading