diff --git a/Cargo.lock b/Cargo.lock index 7b85ba5fb5..f362e2b5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6472,6 +6472,7 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "rustix", "serde", "serde_json", "sha2 0.11.0", @@ -6531,6 +6532,7 @@ version = "2.1.2" dependencies = [ "async-trait", "chrono", + "serde_json", "thiserror 2.0.20", "tinyagents-harness", "tinyagents-session", diff --git a/app/package.json b/app/package.json index 81bc642b03..39953cf164 100644 --- a/app/package.json +++ b/app/package.json @@ -68,13 +68,13 @@ "lint": "eslint . --ext .ts,.tsx --cache", "lint:fix": "eslint . --ext .ts,.tsx --fix --cache", "lint:commands-tokens": "bash scripts/lint-token-scan.sh lint:commands-tokens -nU \"(bg|text|border|ring|shadow)-(neutral|primary|sage|amber|canvas|stone|slate)\" src/components/commands/", - "lint:ui-tokens": "bash scripts/lint-token-scan.sh lint:ui-tokens -nP \"^(?!\\s*(//|\\*|/\\*)).*\\b(bg|text|border|ring|divide)-(neutral|stone|slate|canvas|white|black)\\b\" src/components/ui/ src/components/layout/ src/components/settings/ src/features/conversations/ src/components/intelligence/ src/components/feedback/ src/components/flows/ src/components/chat/ src/components/skills/ src/components/channels/ src/pages/ src/components/dashboard/ src/components/composio/ src/components/notifications/ src/components/accounts/ src/components/approvals/ src/features/human/ && bash scripts/lint-token-scan.sh lint:ui-tokens -nP \"^(?!\\s*(//|\\*|/\\*)).*(color-mix\\(|oklch\\(|@layer )\" src/components/ui/ src/styles/fonts.css && node scripts/lint-undefined-scales.mjs", + "lint:ui-tokens": "bash scripts/lint-token-scan.sh lint:ui-tokens -nP \"^(?!\\s*(//|\\*|/\\*)).*\\b(bg|text|border|ring|divide)-(neutral|stone|slate|canvas|white|black)\\b\" src/components/ui/ src/components/layout/ src/components/settings/ src/features/conversations/ src/components/intelligence/ src/components/feedback/ src/components/flows/ src/components/chat/ src/components/skills/ src/components/channels/ src/pages/ src/components/dashboard/ src/components/composio/ src/components/notifications/ src/components/accounts/ src/features/human/ && bash scripts/lint-token-scan.sh lint:ui-tokens -nP \"^(?!\\s*(//|\\*|/\\*)).*(color-mix\\(|oklch\\(|@layer )\" src/components/ui/ src/styles/fonts.css && node scripts/lint-undefined-scales.mjs", "knip": "knip --config knip.json", "knip:production": "knip --config knip.json --production" }, "dependencies": { - "@assistant-ui/core": "^0.3.15", - "@assistant-ui/react": "^0.15.16", + "@assistant-ui/core": "^0.3.20", + "@assistant-ui/react": "^0.15.21", "@assistant-ui/react-lexical": "^0.2.10", "@assistant-ui/react-markdown": "^0.14.12", "@base-ui/react": "^1.7.0", diff --git a/app/src/AppRoutes.guards.test.tsx b/app/src/AppRoutes.guards.test.tsx index 7795788388..468c5b122d 100644 --- a/app/src/AppRoutes.guards.test.tsx +++ b/app/src/AppRoutes.guards.test.tsx @@ -65,6 +65,9 @@ vi.mock('./pages/Accounts', () => ({ default: () =>
})); vi.mock('./pages/Brain', () => ({ default: () =>
})); vi.mock('./pages/dev/AgentInsightsPreview', () => ({ default: () =>
})); vi.mock('./pages/dev/UiGallery', () => ({ default: () =>
})); +vi.mock('./pages/dev/ToolCallGallery', () => ({ + default: () =>
, +})); vi.mock('./pages/Invites', () => ({ default: () =>
})); vi.mock('./pages/Notifications', () => ({ default: () =>
, @@ -102,6 +105,7 @@ const OWNED: Array<{ path: string; page: string; guard: Guard }> = [ { path: '/notifications', page: 'page-notifications', guard: 'protected' }, { path: '/ptt-overlay', page: 'page-ptt-overlay', guard: 'none' }, { path: '/dev/ui', page: 'page-ui-gallery', guard: 'none' }, + { path: '/dev/tools', page: 'page-tool-call-gallery', guard: 'none' }, ]; describe('AppRoutes — each route renders its page behind the right guard', () => { @@ -200,6 +204,7 @@ describe('AppRoutes — the whole route table stays classified', () => { '/ptt-overlay': 'none', '/dev/agent-insights': 'none', '/dev/ui': 'none', + '/dev/tools': 'none', '/dev/assistant-ui': 'none', '*': 'none', }; diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 23de9170b7..866083606f 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -12,6 +12,7 @@ import Activity from './pages/Activity'; import Brain from './pages/Brain'; import AgentInsightsPreview from './pages/dev/AgentInsightsPreview'; import AssistantUiDemoPage from './pages/dev/assistant-ui-demo'; +import ToolCallGallery from './pages/dev/ToolCallGallery'; import UiGallery from './pages/dev/UiGallery'; import FlowCanvasPage, { FlowCanvasDraftPage } from './pages/FlowCanvasPage'; import FlowsPage from './pages/FlowsPage'; @@ -260,6 +261,9 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { {/* Gallery of every shared UI primitive, in the active theme. */} } /> + {/* Tool-call presentation: every state and the whole core catalog. */} + } /> + {/* The upstream assistant-ui `base` demo on a mock runtime. */} } /> diff --git a/app/src/components/__tests__/a11y.smoke.test.tsx b/app/src/components/__tests__/a11y.smoke.test.tsx index 03c3eefa25..c3f13fb8eb 100644 --- a/app/src/components/__tests__/a11y.smoke.test.tsx +++ b/app/src/components/__tests__/a11y.smoke.test.tsx @@ -8,24 +8,14 @@ * Kept deliberately small and provider-light so it stays fast and stable; grow * it screen-by-screen rather than pulling in the full app shell. */ -import { configureStore } from '@reduxjs/toolkit'; import { render } from '@testing-library/react'; import { axe } from 'jest-axe'; -import { Provider } from 'react-redux'; import { describe, expect, it, vi } from 'vitest'; -import chatRuntimeReducer, { - type ArtifactSnapshot, - type PendingApproval, - setPendingApprovalForThread, -} from '../../store/chatRuntimeSlice'; -import ApprovalRequestCard from '../chat/ApprovalRequestCard'; -import ArtifactCard from '../chat/ArtifactCard'; +import { ApprovalCardAdapter } from '../../features/conversations/aui/ApprovalCardAdapter'; +import { ArtifactCardAdapter } from '../../features/conversations/aui/ArtifactCardAdapter'; +import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice'; -vi.mock('../../services/artifactDownloadService', () => ({ - saveArtifactViaDialog: vi.fn(), - revealArtifactInFileManager: vi.fn(), -})); vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); async function expectNoViolations(container: HTMLElement) { @@ -34,7 +24,7 @@ async function expectNoViolations(container: HTMLElement) { } describe('accessibility smoke', () => { - it('ArtifactCard (ready) has no axe violations', async () => { + it('ArtifactCardAdapter (ready) has no axe violations', async () => { const artifact: ArtifactSnapshot = { artifactId: 'a-1', kind: 'presentation', @@ -44,11 +34,11 @@ describe('accessibility smoke', () => { path: 'a-1/deck.pptx', updatedAt: 0, }; - const { container } = render(); + const { container } = render(); await expectNoViolations(container); }); - it('ArtifactCard (failed with error) has no axe violations', async () => { + it('ArtifactCardAdapter (failed with error) has no axe violations', async () => { const artifact: ArtifactSnapshot = { artifactId: 'a-2', kind: 'document', @@ -57,23 +47,22 @@ describe('accessibility smoke', () => { error: 'producer crashed', updatedAt: 0, }; - const { container } = render(); + const { container } = render(); await expectNoViolations(container); }); - it('ApprovalRequestCard has no axe violations', async () => { - const approval: PendingApproval = { - requestId: 'req-1', - toolName: 'shell', - message: 'Run `shell` — shell (18 bytes of arguments)', - command: 'pip show yfinance', - }; - const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } }); - store.dispatch(setPendingApprovalForThread({ threadId: 't1', approval })); + it('ApprovalCardAdapter has no axe violations', async () => { const { container } = render( - - - + ); await expectNoViolations(container); }); diff --git a/app/src/components/approvals/ApprovalDecisionCard.test.tsx b/app/src/components/approvals/ApprovalDecisionCard.test.tsx deleted file mode 100644 index 07438c1e83..0000000000 --- a/app/src/components/approvals/ApprovalDecisionCard.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import ApprovalDecisionCard, { type ApprovalDecisionAction } from './ApprovalDecisionCard'; - -const actions: ApprovalDecisionAction[] = [ - { id: 'approve', label: 'Approve once', busyLabel: 'Approving…', variant: 'primary' }, - { - id: 'always', - label: 'Approve always', - busyLabel: 'Saving…', - variant: 'secondary', - title: 'Allow this flow in future', - }, - { id: 'deny', label: 'Deny', busyLabel: 'Denying…', variant: 'secondary', tone: 'danger' }, -]; - -describe('ApprovalDecisionCard', () => { - it('composes an alert dialog from its summary and optional metadata', () => { - render( - Run the shell command} - metadata={Tool: shell} - actions={actions} - onAction={vi.fn()} - testId="approval-card" - className="text-sm" - /> - ); - - const card = screen.getByRole('alertdialog', { name: 'Workflow approval' }); - expect(card).toHaveAttribute('data-testid', 'approval-card'); - expect(card).toHaveClass('text-sm'); - expect(within(card).getByText('Run the shell command')).toBeInTheDocument(); - expect(within(card).getByText('Tool: shell')).toBeInTheDocument(); - }); - - it('renders actions in descriptor order and passes the selected action id', () => { - const onAction = vi.fn(); - render( - - ); - - expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([ - 'Approve once', - 'Approve always', - 'Deny', - ]); - - fireEvent.click(screen.getByRole('button', { name: 'Approve always' })); - expect(onAction).toHaveBeenCalledWith('always'); - }); - - it('shows the busy label only for the active action and disables every action', () => { - render( - - ); - - expect(screen.getByRole('button', { name: 'Approve once' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Deny' })).toBeDisabled(); - expect(screen.queryByText('Approving…')).not.toBeInTheDocument(); - expect(screen.queryByText('Denying…')).not.toBeInTheDocument(); - }); - - it('forwards variant, tone, title, and action ids to the rendered buttons', () => { - render( - - ); - - const approve = screen.getByRole('button', { name: 'Approve once' }); - const always = screen.getByRole('button', { name: 'Approve always' }); - const deny = screen.getByRole('button', { name: 'Deny' }); - - expect(approve).toHaveAttribute('data-testid', 'approve'); - expect(approve).toHaveClass('bg-primary-500'); - expect(always).toHaveAttribute('data-testid', 'always'); - expect(always).toHaveClass('border-line-strong'); - expect(always).toHaveAttribute('title', 'Allow this flow in future'); - expect(deny).toHaveAttribute('data-testid', 'deny'); - expect(deny).toHaveClass('text-coral-600'); - expect(deny).toHaveClass('border-coral-300/50'); - }); - - it('supports compact presentation without changing the default density', () => { - const { rerender } = render( - - ); - - const compactLock = screen.getByText('🔒'); - const compactActions = screen.getByRole('button', { name: 'Approve once' }).parentElement; - expect(compactLock).toHaveClass('text-sm'); - expect(compactLock).not.toHaveClass('text-amber-700'); - expect(compactActions).toHaveClass('mt-2', 'gap-1.5'); - expect(screen.getByRole('button', { name: 'Approve once' })).toHaveClass('h-6'); - - rerender( - - ); - - const defaultLock = screen.getByText('🔒'); - const defaultActions = screen.getByRole('button', { name: 'Approve once' }).parentElement; - expect(defaultLock).toHaveClass('text-base', 'text-amber-700'); - expect(defaultActions).toHaveClass('mt-3', 'gap-2'); - expect(screen.getByRole('button', { name: 'Approve once' })).toHaveClass('h-[30px]'); - }); -}); diff --git a/app/src/components/approvals/ApprovalDecisionCard.tsx b/app/src/components/approvals/ApprovalDecisionCard.tsx deleted file mode 100644 index 121acbd42c..0000000000 --- a/app/src/components/approvals/ApprovalDecisionCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { ReactNode } from 'react'; - -import { cn } from '../../lib/cn'; -import { Button } from '../ui'; - -export interface ApprovalDecisionAction { - id: string; - label: ReactNode; - busyLabel?: ReactNode; - variant: 'primary' | 'secondary'; - tone?: 'default' | 'danger'; - title?: string; -} - -export interface ApprovalDecisionCardProps { - ariaLabel: string; - summary: ReactNode; - metadata?: ReactNode; - actions: ApprovalDecisionAction[]; - busyActionId?: string | null; - onAction: (actionId: string) => void; - testId?: string; - className?: string; - density?: 'default' | 'compact'; -} - -export function ApprovalDecisionCard({ - ariaLabel, - summary, - metadata, - actions, - busyActionId = null, - onAction, - testId, - className, - density = 'default', -}: ApprovalDecisionCardProps) { - const compact = density === 'compact'; - - return ( -
-
- - 🔒 - -
- {summary} - {metadata} - -
- {actions.map(action => ( - - ))} -
-
-
-
- ); -} - -export default ApprovalDecisionCard; diff --git a/app/src/components/assistant-ui/__tests__/UserActionBar.test.tsx b/app/src/components/assistant-ui/__tests__/UserActionBar.test.tsx index 2d90a3f3c2..452d44e8a6 100644 --- a/app/src/components/assistant-ui/__tests__/UserActionBar.test.tsx +++ b/app/src/components/assistant-ui/__tests__/UserActionBar.test.tsx @@ -76,17 +76,17 @@ describe('User-message action bar — capability-gated Edit (#5897)', () => { expect(container.querySelector('.aui-user-action-bar-root')).not.toBeNull(); }); - it('does not offer an Edit control while the runtime cannot edit', async () => { + it('offers an Edit control now that the runtime can edit', async () => { const { container } = renderThreadWithOneUserMessage(); await waitFor(() => { expect(screen.getByText('hover me')).toBeInTheDocument(); }); - // `useOpenHumanExternalStore` implements neither `onEdit` nor - // `setMessages`, so assistant-ui reports `edit: false` and the gate must - // withhold the button. Asserted by the class the product CSS and the - // browser spec both key on. - expect(container.querySelectorAll('.aui-user-action-edit')).toHaveLength(0); + // `useOpenHumanExternalStore` now implements `onEdit` (`threads.edit_message`) + // and `setMessages` (a no-op that only exists to un-gate `BranchPicker`), + // so assistant-ui reports `edit: true` and the gate renders the button. + // Asserted by the class the product CSS and the browser spec both key on. + expect(container.querySelectorAll('.aui-user-action-edit')).toHaveLength(1); }); }); diff --git a/app/src/components/assistant-ui/activity-group.tsx b/app/src/components/assistant-ui/activity-group.tsx index c088a77111..a30eb4e0ab 100644 --- a/app/src/components/assistant-ui/activity-group.tsx +++ b/app/src/components/assistant-ui/activity-group.tsx @@ -1,11 +1,21 @@ 'use client'; -import { OpenHumanReasoningGroup } from '@/components/assistant-ui/reasoning-group'; +/** + * Not a vendored element. A local composition of two vendored primitives — + * `ToolGroupRoot` / `ToolGroupTrigger` / `ToolGroupContent` + * (`elements/tool-group.tsx`, from the assistant-ui `tool-group` registry + * item) and `OpenHumanReasoningGroup` (`reasoning-group.tsx`, over the + * `reasoning-trace` element) — that upstream has no equivalent for: one + * disclosure per `MessagePrimitive.GroupedParts` run of reasoning-and-tool + * activity, rather than a separate collapsible per part type. See the + * `ActivityGroup` doc comment below for why. + */ import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger, -} from '@/components/assistant-ui/tool-group'; +} from '@/components/assistant-ui/elements/tool-group'; +import { OpenHumanReasoningGroup } from '@/components/assistant-ui/reasoning-group'; import { type MessagePrimitive, useAuiState } from '@assistant-ui/react'; import { type FC, type PropsWithChildren, useState } from 'react'; diff --git a/app/src/components/assistant-ui/composer-trigger-popover.tsx b/app/src/components/assistant-ui/composer-trigger-popover.tsx index 882678a912..af1bf3c416 100644 --- a/app/src/components/assistant-ui/composer-trigger-popover.tsx +++ b/app/src/components/assistant-ui/composer-trigger-popover.tsx @@ -1,5 +1,18 @@ 'use client'; +/** + * Popover UI for a trigger-driven composer picker (`/` commands, `@` mentions). + * + * Vendored from the assistant-ui `composer-trigger-popover` registry item + * (https://r.assistant-ui.com/styles/base-nova/composer-trigger-popover.json), + * re-synced against the registry's `elements/composer-trigger-popover.aui.tsx`. + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`); import order as + * the repo's lint sorts it. + * - None to the body. The back / empty / loading captions are already props + * with English defaults upstream; OpenHuman callers pass `useT()` strings + * (see `features/conversations/aui/ComposerTriggers.tsx`). + */ import { cn } from '@/components/assistant-ui/lib/utils'; import { ComposerPrimitive, diff --git a/app/src/components/assistant-ui/elements/__tests__/tool-fallback.approval.test.tsx b/app/src/components/assistant-ui/elements/__tests__/tool-fallback.approval.test.tsx new file mode 100644 index 0000000000..b6e76d3505 --- /dev/null +++ b/app/src/components/assistant-ui/elements/__tests__/tool-fallback.approval.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ToolFallbackApproval } from '../tool-fallback'; + +vi.mock('@assistant-ui/react', async () => { + const actual = await vi.importActual('@assistant-ui/react'); + return { ...actual, useAuiState: () => false }; +}); + +describe('ToolFallbackApproval — free-text answer path', () => { + it('renders the plain decision bar when the request has no options/display', async () => { + const respondToApproval = vi.fn().mockResolvedValue(undefined); + render( + + ); + await userEvent.click(screen.getByText('Allow')); + expect(respondToApproval).toHaveBeenCalledWith({ approved: true }); + }); + + it('renders a Textarea + Send for a question (display: "text") and answers with the typed text', async () => { + const respondToApproval = vi.fn().mockResolvedValue(undefined); + render( + + ); + expect(screen.getByText('What is the title?')).toBeInTheDocument(); + const textarea = screen.getByRole('textbox'); + await userEvent.type(textarea, 'Quarterly Deck'); + await userEvent.click(screen.getByText('Send')); + expect(respondToApproval).toHaveBeenCalledWith({ text: 'Quarterly Deck' }); + }); + + it('does not offer a bare Deny button for a question-mode request', () => { + render( + + ); + expect(screen.queryByText('Deny')).toBeNull(); + }); + + it('renders nothing once the approval is already resolved', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/src/components/assistant-ui/elements/agent-plan.tsx b/app/src/components/assistant-ui/elements/agent-plan.tsx new file mode 100644 index 0000000000..7dd78b23a1 --- /dev/null +++ b/app/src/components/assistant-ui/elements/agent-plan.tsx @@ -0,0 +1,85 @@ +'use client'; + +/** + * The agent's step-by-step plan for the current thread, with a progress bar + * and a checkmark/spinner per step. + * + * Vendored from the assistant-ui `elements-agent-plan` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-agent-plan.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - The header text `"Plan"` is a prop (`title`) with that English default, + * so the caller (`PlanReviewPart` in + * `features/conversations/aui/PlanReviewPart.tsx`) supplies the translated + * string via `useT()`. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { CheckIcon, Loader2Icon } from 'lucide-react'; +import type { ComponentProps } from 'react'; + +import { pct, progressOf } from '../utils/range'; +import { mono } from './surfaces'; + +export function AgentPlan({ + steps, + activeIndex, + title = 'Plan', + className, + ...props +}: Omit, 'children' | 'steps' | 'activeIndex' | 'title'> & { + steps: readonly string[]; + activeIndex: number; + title?: string; +}) { + const total = steps.length; + const completed = progressOf(activeIndex, total); + const allDone = completed >= total; + const progress = pct(completed, total); + + return ( +
+
+ {title} + + {completed} of {total} + +
+
+ +
+
    + {steps.map((step, i) => { + const done = allDone || i < completed; + const active = !allDone && i === completed; + return ( +
  • + + {done ? ( + + ) : active ? ( + + ) : ( + + )} + + + {step} + +
  • + ); + })} +
+
+ ); +} diff --git a/app/src/components/assistant-ui/elements/agent-status.aui.tsx b/app/src/components/assistant-ui/elements/agent-status.aui.tsx new file mode 100644 index 0000000000..9e4ef54d1f --- /dev/null +++ b/app/src/components/assistant-ui/elements/agent-status.aui.tsx @@ -0,0 +1,243 @@ +'use client'; + +/** + * Vendored from the assistant-ui `elements-agent-status` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-agent-status.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - `Popover`/`PopoverContent`/`PopoverTrigger` from + * `@/components/assistant-ui/ui/popover` (this app's Radix popover copy), + * not `@/components/ui/popover` (a differently-shaped shared component). + * - `summaryLabel` takes an optional `strings` bag (English defaults + * matching upstream's hard-coded copy) so `AgentStatus`/`TaskTray` can be + * handed `useT()`-sourced copy from the host; every call site that omits + * it keeps upstream's exact English text. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/assistant-ui/ui/popover'; +import { type TaskState, useAuiState } from '@assistant-ui/react'; +import { ChevronDownIcon } from 'lucide-react'; +import { type FC, useMemo, useState } from 'react'; + +import { + formatElapsed, + TASK_PAGE_SIZE, + taskLabel, + taskMeta, + taskStateOf, + useTaskElapsed, +} from '../utils/task'; +import { type AgentState, AgentStatus as AgentStatusBase } from './agent-status'; +import { mono } from './surfaces'; +import { TaskStateIcon } from './task-card'; + +export type TaskSummary = { + readonly total: number; + readonly running: number; + readonly waiting: number; + readonly failed: number; + readonly startedAt: number | undefined; + readonly runningLabel: string | undefined; +}; + +/** English defaults for {@link summaryLabel}; override via its `strings` param. */ +export interface AgentStatusStrings { + taskOne: string; + taskOther: string; + running: string; + waitingForInput: string; + done: string; + failed: string; + of: string; +} + +const DEFAULT_STRINGS: AgentStatusStrings = { + taskOne: 'task', + taskOther: 'tasks', + running: 'running', + waitingForInput: 'waiting for input', + done: 'done', + failed: 'failed', + of: 'of', +}; + +const summarize = (tasks: readonly TaskState[]): TaskSummary => { + let running = 0; + let waiting = 0; + let failed = 0; + let startedAt: number | undefined; + let runningLabel: string | undefined; + for (const task of tasks) { + const state = taskStateOf(task.status, task.isError); + if (state === 'working') { + running += 1; + runningLabel ??= taskLabel(task.toolName, task.args); + const taskStartedAt = task.timing?.startedAt; + if (taskStartedAt !== undefined && (startedAt === undefined || taskStartedAt < startedAt)) { + startedAt = taskStartedAt; + } + } else if (state === 'waiting') { + waiting += 1; + } else if (state === 'failed') { + failed += 1; + } + } + return { total: tasks.length, running, waiting, failed, startedAt, runningLabel }; +}; + +export const useTaskSummary = (): TaskSummary => { + const tasks = useAuiState(s => s.thread.tasks); + return useMemo(() => summarize(tasks), [tasks]); +}; + +const plural = (count: number, strings: AgentStatusStrings) => + `${count} ${count === 1 ? strings.taskOne : strings.taskOther}`; + +export const summaryState = (summary: TaskSummary): AgentState => { + if (summary.running > 0) return 'working'; + if (summary.waiting > 0) return 'waiting'; + return summary.failed > 0 ? 'failed' : 'done'; +}; + +export const summaryLabel = ( + summary: TaskSummary, + strings: AgentStatusStrings = DEFAULT_STRINGS +) => { + if (summary.running === 1 && summary.runningLabel !== undefined) { + return summary.runningLabel; + } + if (summary.running > 0) { + return `${summary.running} ${strings.of} ${plural(summary.total, strings)} ${strings.running}`; + } + if (summary.waiting > 0) { + return `${plural(summary.waiting, strings)} ${strings.waitingForInput}`; + } + if (summary.failed > 0) { + return `${plural(summary.total, strings)} ${strings.done}, ${summary.failed} ${strings.failed}`; + } + return `${plural(summary.total, strings)} ${strings.done}`; +}; + +export const AgentStatus: FC<{ className?: string; strings?: AgentStatusStrings }> = ({ + className, + strings, +}) => { + const summary = useTaskSummary(); + const elapsedMs = useTaskElapsed( + summary.startedAt === undefined ? undefined : { startedAt: summary.startedAt }, + summary.running > 0 + ); + if (summary.running === 0 && summary.waiting === 0) return null; + + return ( + + ); +}; + +const TaskTrayItem: FC<{ task: TaskState }> = ({ task }) => { + const state = taskStateOf(task.status, task.isError); + const meta = taskMeta(task.args); + const elapsedMs = useTaskElapsed( + task.timing, + task.status.type === 'running' || task.status.type === 'requires-action' + ); + + return ( +
  • + + {state} + {taskLabel(task.toolName, task.args)} + {meta !== undefined && ( + {meta} + )} + {elapsedMs !== undefined && ( + + {formatElapsed(elapsedMs)} + + )} +
  • + ); +}; + +export const TaskTray: FC<{ className?: string; strings?: AgentStatusStrings }> = ({ + className, + strings, +}) => { + const tasks = useAuiState(s => s.thread.tasks); + const summary = useMemo(() => summarize(tasks), [tasks]); + const elapsedMs = useTaskElapsed( + summary.startedAt === undefined ? undefined : { startedAt: summary.startedAt }, + summary.running > 0 + ); + const [open, setOpen] = useState(false); + const [visible, setVisible] = useState(TASK_PAGE_SIZE); + const firstTask = tasks[0]; + const listKey = firstTask === undefined ? '' : `${firstTask.messageId}:${firstTask.id}`; + const [seenListKey, setSeenListKey] = useState(listKey); + if (seenListKey !== listKey) { + setSeenListKey(listKey); + setVisible(TASK_PAGE_SIZE); + } + if (summary.total === 0 && open) setOpen(false); + if (summary.total === 0) return null; + const hidden = Math.max(0, tasks.length - visible); + + return ( + { + setOpen(next); + if (!next) setVisible(TASK_PAGE_SIZE); + }}> + + + } + /> + + +
      + {tasks.slice(0, visible).map((task, index) => ( + + ))} + {hidden > 0 && ( +
    • + +
    • + )} +
    +
    +
    + ); +}; diff --git a/app/src/components/assistant-ui/elements/agent-status.tsx b/app/src/components/assistant-ui/elements/agent-status.tsx new file mode 100644 index 0000000000..864245ba92 --- /dev/null +++ b/app/src/components/assistant-ui/elements/agent-status.tsx @@ -0,0 +1,88 @@ +'use client'; + +/** + * A compact status pill for a long-running agent activity: state dot/icon, + * label, optional elapsed time, and a trailing slot (defaults to a + * pause/retry icon). + * + * Vendored from the assistant-ui `elements-agent-status` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-agent-status.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * No literal user-facing strings here — every visible value already comes + * from props/children, so there is nothing to route through `useT()`. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { CheckIcon, PauseIcon, RotateCcwIcon, XIcon } from 'lucide-react'; +import type { ComponentProps, ReactNode } from 'react'; + +import { mono, paper } from './surfaces'; + +export type AgentState = 'working' | 'waiting' | 'done' | 'failed'; + +export interface StatusStep { + state: AgentState; + label: string; +} + +export function AgentStatus({ + state, + label, + elapsed, + trailing, + className, + ...props +}: Omit, 'children' | 'state' | 'label' | 'elapsed'> & { + state: AgentState; + label: string; + elapsed?: string | undefined; + trailing?: ReactNode | undefined; +}) { + return ( + + {state === 'done' ? ( + + ) : state === 'failed' ? ( + + ) : ( + + )} + {state} + + {label} + + {elapsed !== undefined && state !== 'done' && state !== 'failed' && ( + {elapsed} + )} + + {trailing !== undefined ? ( + trailing + ) : state === 'done' || state === 'failed' ? ( + + ) : ( + + )} + + + ); +} diff --git a/app/src/components/assistant-ui/elements/approval-card.tsx b/app/src/components/assistant-ui/elements/approval-card.tsx new file mode 100644 index 0000000000..7f52cbb4f8 --- /dev/null +++ b/app/src/components/assistant-ui/elements/approval-card.tsx @@ -0,0 +1,179 @@ +'use client'; + +/** + * assistant-ui's approval-card element: a decision surface for a parked tool + * call (or any other approve/deny prompt), with the exact command/target + * shown above Deny / Always allow / Allow once. + * + * Vendored from the assistant-ui `elements-approval-card` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-approval-card.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`, this app's alias). + * - Every user-facing label (`Deny`, `Always allow`, `Allow once`, `Approved, + * running`, `Denied`, `Finished with exit 0`) is now a prop with an English + * default, so the calling adapter supplies the translated string via + * `useT()` instead of the label being hard-coded. + * - `allowOnceProps` / `alwaysAllowProps` / `denyProps` slots: OpenHuman's + * WDIO/Playwright specs click a decision button by its + * `data-analytics-id` (`agent-harness-behaviors.spec.ts`), and the upstream + * buttons carry no per-button identity — only the root div spreads + * `...props`. These optional `ComponentProps<'button'>` slots let a caller + * attach `data-analytics-id`/`data-testid` without duplicating the button. + * - `expiry` slot: an optional node rendered next to the subtitle for a TTL + * countdown (`ApprovalRequestCard`'s parked-request TTL has no upstream + * equivalent). + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { CheckIcon, Loader2Icon, TerminalIcon, XIcon } from 'lucide-react'; +import type { ComponentProps, ReactNode } from 'react'; + +import { field, inkButton, paper } from './surfaces'; + +/** + * A button-slot prop type that permits `data-analytics-id` / `data-testid` + * (and any other `data-*` attribute) on an object literal. Plain + * `ComponentProps<'button'>` fails TypeScript's excess-property check for a + * literal assigned to a typed prop (JSX itself allows any `data-*` + * attribute; a plain object literal does not inherit that allowance). + */ +type ButtonSlotProps = ComponentProps<'button'> & Record<`data-${string}`, string>; + +export type ApprovalState = 'request' | 'running' | 'done' | 'denied'; + +export function ApprovalCard({ + state, + command, + title, + subtitle, + expiry, + onAllowOnce, + onAlwaysAllow, + onDeny, + denyLabel = 'Deny', + alwaysAllowLabel = 'Always allow', + allowOnceLabel = 'Allow once', + runningLabel = 'Approved, running', + deniedLabel = 'Denied', + doneLabel = 'Finished with exit 0', + allowOnceProps, + alwaysAllowProps, + denyProps, + className, + ...props +}: Omit< + ComponentProps<'div'>, + | 'children' + | 'state' + | 'command' + | 'title' + | 'subtitle' + | 'onAllowOnce' + | 'onAlwaysAllow' + | 'onDeny' +> & { + state: ApprovalState; + command: string; + title: string; + subtitle: string; + /** Optional node rendered beside the subtitle (e.g. a TTL countdown). */ + expiry?: ReactNode; + onAllowOnce?: () => void; + onAlwaysAllow?: () => void; + onDeny?: () => void; + denyLabel?: string; + alwaysAllowLabel?: string; + allowOnceLabel?: string; + runningLabel?: string; + deniedLabel?: string; + doneLabel?: string; + allowOnceProps?: ButtonSlotProps; + alwaysAllowProps?: ButtonSlotProps; + denyProps?: ButtonSlotProps; +}) { + return ( +
    +
    + + + +
    +

    {title}

    +

    {subtitle}

    + {expiry} +
    +
    + +
    + {command} +
    + +
    + {state === 'request' ? ( + <> + {onDeny && ( + + )} + {onAlwaysAllow && ( + + )} + {onAllowOnce && ( + + )} + + ) : ( +
    + {state === 'running' ? ( + <> + + {runningLabel} + + ) : state === 'denied' ? ( + <> + + {deniedLabel} + + ) : ( + <> + + {doneLabel} + + )} +
    + )} +
    +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/artifact-card.tsx b/app/src/components/assistant-ui/elements/artifact-card.tsx new file mode 100644 index 0000000000..ed404c3383 --- /dev/null +++ b/app/src/components/assistant-ui/elements/artifact-card.tsx @@ -0,0 +1,132 @@ +'use client'; + +/** + * assistant-ui's artifact-card element: a compact row for a generated file + * (document, presentation, ...) — icon, title, and either a "writing" shimmer + * with a live word count, or the settled metadata line once it's done. + * + * Vendored from the assistant-ui `elements-artifact-card` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-artifact-card.json). + * Changes from upstream: + * - `cn` import path and `./surfaces` resolved through this app's alias. + * - `writingLabel` prop (English default supplied by the caller via + * `useT()`) replaces the hardcoded "Writing" string. + * - `icon` prop (defaults to `FileTextIcon`) so a presentation, image, or + * other artifact kind can swap the glyph instead of always showing a + * document icon. + * - `onOpen` replaces upstream's implicit "the whole card is a link" with an + * explicit handler; the card renders as a ` + ); + } + + return ( +
    + +
    + ); +} + +function ArtifactCardBody({ + title, + meta, + generating, + words, + writingLabel, + Icon, +}: { + title: string; + meta: string; + generating: boolean; + words: number; + writingLabel: string; + Icon: ElementType; +}) { + return ( + <> + + + +
    +

    {title}

    + {generating ? ( +

    + + {writingLabel} + + · + {words} words +

    + ) : ( +

    + {meta} +

    + )} +
    + + + ); +} diff --git a/app/src/components/assistant-ui/elements/background-inbox.tsx b/app/src/components/assistant-ui/elements/background-inbox.tsx new file mode 100644 index 0000000000..8621a5c44a --- /dev/null +++ b/app/src/components/assistant-ui/elements/background-inbox.tsx @@ -0,0 +1,129 @@ +'use client'; + +/** + * Vendored from the assistant-ui `elements-background-inbox` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-background-inbox.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - The hard-coded "Running elsewhere" / "{n} ready" / "{n} in flight" copy + * is now a `strings` prop (English defaults matching upstream) so the host + * can supply `useT()`-sourced copy — see `aui/BackgroundInboxCard.tsx`. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { CheckIcon, Loader2Icon, XIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; + +import { mono, paper } from './surfaces'; + +export type BackgroundState = 'running' | 'ready' | 'failed'; + +export interface BackgroundRun { + id: string; + title: string; + state: BackgroundState; + elapsed: string; + summary?: string; +} + +/** English defaults for the inbox header; override via the `strings` prop. */ +export interface BackgroundInboxStrings { + title: string; + ready: (count: number) => string; + inFlight: (count: number) => string; +} + +const DEFAULT_STRINGS: BackgroundInboxStrings = { + title: 'Running elsewhere', + ready: count => `${count} ready`, + inFlight: count => `${count} in flight`, +}; + +export function BackgroundInbox({ + runs, + onCollect, + strings = DEFAULT_STRINGS, + className, + ...props +}: Omit, 'children' | 'runs' | 'onCollect'> & { + runs: readonly BackgroundRun[]; + onCollect?: (id: string) => void; + strings?: BackgroundInboxStrings; +}) { + const ready = runs.filter(run => run.state === 'ready').length; + const running = runs.filter(run => run.state === 'running').length; + + return ( +
    +
    + {strings.title} + 0 ? 'text-blue-600 dark:text-blue-400' : 'text-foreground/35' + )}> + {ready > 0 ? strings.ready(ready) : strings.inFlight(running)} + +
    + + {runs.map(run => { + const rowClassName = cn( + 'flex items-center gap-2.5 rounded-xl px-1.5 py-2 text-start transition-colors', + run.state === 'running' + ? 'cursor-default' + : onCollect + ? 'hover:bg-foreground/[0.04]' + : undefined + ); + const content = ( + <> + + {run.state === 'running' ? ( + + ) : run.state === 'failed' ? ( + + ) : ( + + )} + + + + + {run.title} + + {run.summary && ( + {run.summary} + )} + + + + {run.elapsed} + + + ); + + return onCollect ? ( + + ) : ( +
    + {content} +
    + ); + })} +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/code-diff.tsx b/app/src/components/assistant-ui/elements/code-diff.tsx new file mode 100644 index 0000000000..1657d4a194 --- /dev/null +++ b/app/src/components/assistant-ui/elements/code-diff.tsx @@ -0,0 +1,78 @@ +'use client'; + +/** + * assistant-ui's code-diff element: a file's added and removed lines. + * + * Vendored from assistant-ui `packages/ui/src/components/react/assistant-ui/elements/code-diff.tsx` + * (commit 1abca347). Only the `cn` import path differs from upstream. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import type { ComponentProps } from 'react'; + +import { codeScroll, codeSurface, mono, paper } from './surfaces'; + +export type DiffKind = 'context' | 'added' | 'removed'; + +export interface DiffLine { + kind: DiffKind; + text: string; +} + +const GUTTER: Record = { context: '', added: '+', removed: '−' }; + +export function CodeDiff({ + filename, + additions, + deletions, + lines, + cycle, + className, + ...props +}: Omit< + ComponentProps<'div'>, + 'children' | 'filename' | 'additions' | 'deletions' | 'lines' | 'cycle' +> & { + filename: string; + additions: number; + deletions: number; + lines: readonly DiffLine[]; + cycle: number; +}) { + return ( +
    +
    + {filename} + + +{additions}{' '} + −{deletions} + +
    +
    +
    + {lines.map((line, i) => ( +
    + {GUTTER[line.kind]} + {line.text} +
    + ))} +
    +
    +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/composer.test.tsx b/app/src/components/assistant-ui/elements/composer.test.tsx new file mode 100644 index 0000000000..5ece982cf1 --- /dev/null +++ b/app/src/components/assistant-ui/elements/composer.test.tsx @@ -0,0 +1,67 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { SlashIcon } from 'lucide-react'; +import { describe, expect, it } from 'vitest'; + +import { + applyMention, + type ComposerCommand, + ComposerCommandItem, + ComposerMenu, + type ComposerPerson, + useMentionMatches, + useSlashMatches, +} from './composer'; + +const COMMANDS: ComposerCommand[] = [ + { name: 'plan', description: 'Plan first', icon: SlashIcon }, + { name: 'build', description: 'Build it', icon: SlashIcon }, + { name: 'publish', description: 'Ship it', icon: SlashIcon }, +]; + +const PEOPLE: ComposerPerson[] = [ + { name: 'Researcher', role: 'agent' }, + { name: 'Riley', role: 'human' }, + { name: 'Coder', role: 'agent' }, +]; + +describe('useSlashMatches', () => { + it('returns commands whose name starts with the slash query', () => { + const { result } = renderHook(() => useSlashMatches('/p', COMMANDS)); + expect(result.current.map(c => c.name)).toEqual(['plan', 'publish']); + }); + + it('returns nothing when the value is not a slash command', () => { + const { result } = renderHook(() => useSlashMatches('plan', COMMANDS)); + expect(result.current).toEqual([]); + }); +}); + +describe('useMentionMatches', () => { + it('matches people against a trailing @mention, case-insensitively', () => { + const { result } = renderHook(() => useMentionMatches('ask @r', PEOPLE)); + expect(result.current.map(p => p.name)).toEqual(['Researcher', 'Riley']); + }); + + it('returns nothing when the caret is not in a mention', () => { + const { result } = renderHook(() => useMentionMatches('ask @r now', PEOPLE)); + expect(result.current).toEqual([]); + }); +}); + +describe('applyMention', () => { + it('replaces the trailing @mention with the chosen name', () => { + expect(applyMention('ask @re', 'Researcher')).toBe('ask @Researcher '); + }); +}); + +describe('ComposerCommandItem', () => { + it('renders the command inside an open menu', () => { + render( + + + + ); + expect(screen.getByText('/plan')).toBeInTheDocument(); + expect(screen.getByText('Plan first')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/assistant-ui/elements/composer.tsx b/app/src/components/assistant-ui/elements/composer.tsx new file mode 100644 index 0000000000..8d4467ab1c --- /dev/null +++ b/app/src/components/assistant-ui/elements/composer.tsx @@ -0,0 +1,148 @@ +'use client'; + +/** + * The composer's `/` command menu and `@` mention menu: matching hooks and the + * menu surface they render into. + * + * Vendored from the assistant-ui `elements-composer` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-composer.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - Only the slash-menu and mention pieces are vendored (`useSlashMatches`, + * `useMentionMatches`, `applyMention`, `ComposerMenu`, `ComposerMenuItem`, + * `ComposerCommandItem`, `ComposerPersonItem` and their types). The rest of + * the upstream file — attachments, voice, model picker, context ring and + * send button — is omitted: OpenHuman's composer renders those through + * `ComposerPrimitive` and Lexical in `thread.tsx`. The live product `/` and + * `@` pickers are the primitive-driven `composer-trigger-popover.tsx`, fed + * by `features/conversations/aui/useSlashCommandSource.ts` and + * `useMentionSource.ts`; this file renders the same fixtures in the dev + * gallery (`pages/dev/ToolCallGallery.tsx`). + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import type { LucideIcon } from 'lucide-react'; +import { type ComponentProps, useMemo } from 'react'; + +import { field, floating, mono } from './surfaces'; + +export interface ComposerCommand { + name: string; + description: string; + icon: LucideIcon; +} + +export interface ComposerPerson { + name: string; + role: 'agent' | 'human'; +} + +/** Commands whose name starts with the slash query, or none when not typing one. */ +export function useSlashMatches( + value: string, + commands: readonly ComposerCommand[] | undefined +): ComposerCommand[] { + return useMemo(() => { + if (!commands || !value.startsWith('/')) return []; + const query = value.slice(1).toLowerCase(); + return commands.filter(command => command.name.startsWith(query)); + }, [commands, value]); +} + +/** People matching a trailing @mention, or none when the caret is not in one. */ +export function useMentionMatches( + value: string, + people: readonly ComposerPerson[] | undefined +): ComposerPerson[] { + return useMemo(() => { + if (!people) return []; + const match = /@([\w]*)$/.exec(value); + if (!match) return []; + const query = match[1]?.toLowerCase() ?? ''; + return people.filter(person => person.name.toLowerCase().startsWith(query)); + }, [people, value]); +} + +/** Replaces the trailing @mention with the chosen name. */ +export function applyMention(value: string, name: string): string { + return value.replace(/@[\w]*$/, `@${name} `); +} + +export function ComposerMenu({ + open, + align = 'start', + className, + ...props +}: ComponentProps<'div'> & { open: boolean; align?: 'start' | 'end' }) { + return ( +
    + ); +} + +export function ComposerMenuItem({ + active = false, + className, + ...props +}: ComponentProps<'button'> & { active?: boolean }) { + return ( + + + )} + + {phase === 'reconnecting' && ( + <> + + {reconnectingLabel} + {attempt !== undefined && ( + + {attemptLabel(attempt)} + + )} + + )} + + {phase === 'resumed' && ( + <> + + {resumedLabel} + {resumedTokens !== undefined && ( + + {resumedTokensLabel(resumedTokens)} + + )} + + )} +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/context-breakdown.tsx b/app/src/components/assistant-ui/elements/context-breakdown.tsx new file mode 100644 index 0000000000..af0e129717 --- /dev/null +++ b/app/src/components/assistant-ui/elements/context-breakdown.tsx @@ -0,0 +1,115 @@ +'use client'; + +/** + * Where the context window goes: a stacked bar of labelled segments against + * the limit, one row per segment and a headroom row. + * + * Vendored from the assistant-ui `elements-context-breakdown` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-context-breakdown.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - The "Context" title, the "Headroom" row, and each meter's accessible name + * and value text are props with English defaults, for `useT()` — see + * `ContextUsage` in `features/conversations/aui/ContextUsage.tsx`, the only + * caller. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import type { ComponentProps } from 'react'; + +import { announced, pct } from '../utils/range'; +import { mono, paper } from './surfaces'; + +const fmt = (n: number) => n.toLocaleString('en-US'); + +export interface ContextSegment { + label: string; + tokens: number; + tint: string; +} + +export function ContextBreakdown({ + segments, + limit, + title = 'Context', + headroomLabel = 'Headroom', + meterLabel = label => `${label} context usage`, + meterValueText = (used, max) => `${used} of ${max}`, + className, + ...props +}: Omit, 'children' | 'segments' | 'limit' | 'title'> & { + segments: readonly ContextSegment[]; + limit: number; + title?: string; + headroomLabel?: string; + meterLabel?: (label: string) => string; + meterValueText?: (used: string, limit: string) => string; +}) { + const used = segments.reduce((sum, segment) => sum + segment.tokens, 0); + const pressure = limit === 0 ? 0 : used / limit; + const share = (tokens: number) => pct(tokens, limit); + + return ( +
    +
    + {title} + 0.85 ? 'text-amber-600 dark:text-amber-400' : 'text-foreground/35' + )}> + {fmt(used)} / {fmt(limit)} + +
    + +
    + {segments.map(segment => { + const width = share(segment.tokens); + if (announced(width) === 0) return null; + return ( + + ); + })} +
    + +
    + {segments.map(segment => ( +
    + + + {segment.label} + + + {fmt(segment.tokens)} + +
    + ))} +
    + + + {headroomLabel} + + + {fmt(Math.max(0, limit - used))} + +
    +
    +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/context-display.tsx b/app/src/components/assistant-ui/elements/context-display.tsx new file mode 100644 index 0000000000..6788b84239 --- /dev/null +++ b/app/src/components/assistant-ui/elements/context-display.tsx @@ -0,0 +1,456 @@ +'use client'; + +/** + * How full the model's context window is, as a ring / bar / text trigger with + * the token breakdown in a tooltip. + * + * Vendored from the assistant-ui `context-display` registry item + * (https://r.assistant-ui.com/styles/base-nova/context-display.json) — the + * props-only file, not its `.aui` wrapper, which reads usage through + * `@assistant-ui/ai-sdk` (not a dependency here). Changes from upstream: + * - `cn` and tooltip import paths (`@/components/assistant-ui/...`). + * - The "% full" caption and the Input / Cached input / Output / Reasoning + * row labels are a `labels` prop with English defaults, for `useT()`. + * - The Ring / Bar / Text presets forward any other button props to their + * trigger, so the "Context usage" accessible name can be translated and a + * preset can itself be a popover trigger (`render={}`). + * See `ContextUsage` in `features/conversations/aui/ContextUsage.tsx`, the + * only caller. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/assistant-ui/ui/tooltip'; +import { + type ComponentProps, + createContext, + type FC, + type ReactNode, + useContext, + useMemo, + useState, +} from 'react'; + +export type TokenUsage = { + totalTokens?: number | undefined; + inputTokens?: number | undefined; + cachedInputTokens?: number | undefined; + outputTokens?: number | undefined; + reasoningTokens?: number | undefined; +}; + +const formatTokenCount = (tokens: number): string => { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return `${tokens}`; +}; + +const getUsagePercent = (totalTokens: number | undefined, modelContextWindow: number): number => { + if (!totalTokens) return 0; + return Math.min((totalTokens / modelContextWindow) * 100, 100); +}; + +type UsageSeverity = 'normal' | 'warning' | 'critical'; + +const getUsageSeverity = (percent: number): UsageSeverity => { + if (percent > 85) return 'critical'; + if (percent >= 65) return 'warning'; + return 'normal'; +}; + +const getStrokeColor = (percent: number): string => { + const severity = getUsageSeverity(percent); + if (severity === 'critical') return 'stroke-red-500'; + if (severity === 'warning') return 'stroke-amber-500'; + return 'stroke-foreground'; +}; + +const getBarColor = (percent: number): string => { + const severity = getUsageSeverity(percent); + if (severity === 'critical') return 'bg-red-500'; + if (severity === 'warning') return 'bg-amber-500'; + return 'bg-foreground'; +}; + +const getPercentColor = (percent: number): string => { + const severity = getUsageSeverity(percent); + if (severity === 'critical') return 'text-red-500'; + if (severity === 'warning') return 'text-amber-500'; + return 'text-muted-foreground'; +}; +export type ContextDisplayLabels = { + full: (percent: number) => string; + input: string; + cachedInput: string; + output: string; + reasoning: string; +}; + +const DEFAULT_LABELS: ContextDisplayLabels = { + full: percent => `${percent}% full`, + input: 'Input', + cachedInput: 'Cached input', + output: 'Output', + reasoning: 'Reasoning', +}; + +type ContextDisplayContextValue = { + usage: TokenUsage | undefined; + totalTokens: number; + percent: number; + modelContextWindow: number; + labels: ContextDisplayLabels; +}; + +const ContextDisplayContext = createContext(null); + +function useContextDisplay(): ContextDisplayContextValue { + const ctx = useContext(ContextDisplayContext); + if (!ctx) { + throw new Error('ContextDisplay.* must be used within ContextDisplay.Root'); + } + return ctx; +} +export type PresetProps = Omit, 'children' | 'className'> & { + modelContextWindow: number; + className?: string; + side?: 'top' | 'bottom' | 'left' | 'right'; + usage?: TokenUsage | undefined; + resetKey?: string | undefined; + labels?: ContextDisplayLabels | undefined; +}; + +export type ContextDisplayRootProps = { + modelContextWindow: number; + children: ReactNode; + usage?: TokenUsage | undefined; + resetKey?: string | undefined; + labels?: ContextDisplayLabels | undefined; +}; + +function ContextDisplayRoot({ + modelContextWindow, + children, + usage, + resetKey, + labels = DEFAULT_LABELS, +}: ContextDisplayRootProps) { + const rawTokens = usage?.totalTokens ?? 0; + const [tokenState, setTokenState] = useState({ + resetKey, + totalTokens: rawTokens > 0 ? rawTokens : 0, + usage, + }); + + if ( + tokenState.resetKey !== resetKey || + (rawTokens > 0 && rawTokens !== tokenState.totalTokens) || + usage !== tokenState.usage + ) { + setTokenState(prev => { + if (prev.resetKey !== resetKey) { + return { resetKey, totalTokens: rawTokens > 0 ? rawTokens : 0, usage }; + } + if (rawTokens > 0 && rawTokens !== prev.totalTokens) { + return { ...prev, totalTokens: rawTokens, usage }; + } + if (usage !== prev.usage) { + return { ...prev, usage }; + } + return prev; + }); + } + + const current = + tokenState.resetKey === resetKey + ? tokenState + : { totalTokens: rawTokens > 0 ? rawTokens : 0, usage }; + const totalTokens = current.totalTokens; + const percent = getUsagePercent(totalTokens, modelContextWindow); + const hasUsage = current.usage !== undefined || totalTokens > 0; + + const contextValue = useMemo( + () => ({ usage: current.usage, totalTokens, percent, modelContextWindow, labels }), + [current.usage, totalTokens, percent, modelContextWindow, labels] + ); + + if (!hasUsage) return null; + + return ( + + + {children} + + + ); +} +function ContextDisplayTrigger({ className, children, ...props }: React.ComponentProps<'button'>) { + return ( + + }> + {children} + + ); +} + +type ContextSegment = { label: string; tokens: number }; + +// Whether a provider counts cached tokens inside inputTokens, or reasoning +// inside outputTokens, differs by provider: OpenAI reports cached_tokens as a +// subset of prompt_tokens, while Anthropic documents input_tokens as excluding +// cache_read_input_tokens. Nothing in the usage contract says which is in hand, +// so these are reported as the counts they are and none of them is given a +// share of the bar, which stays the one reading that always holds: the +// provider's own total against the window. +const getContextSegments = ( + usage: TokenUsage | undefined, + labels: ContextDisplayLabels +): ContextSegment[] => { + if (!usage) return []; + return [ + { label: labels.input, tokens: usage.inputTokens ?? 0 }, + { label: labels.cachedInput, tokens: usage.cachedInputTokens ?? 0 }, + { label: labels.output, tokens: usage.outputTokens ?? 0 }, + { label: labels.reasoning, tokens: usage.reasoningTokens ?? 0 }, + ].filter(segment => segment.tokens > 0); +}; + +function ContextDisplayContent({ + side = 'top', + className, +}: { + side?: 'top' | 'bottom' | 'left' | 'right' | undefined; + className?: string; +}) { + const { usage, totalTokens, percent, modelContextWindow, labels } = useContextDisplay(); + const segments = getContextSegments(usage, labels); + + return ( + +
    +
    + {labels.full(Math.round(percent))} + + {formatTokenCount(Math.min(totalTokens, modelContextWindow))} /{' '} + {formatTokenCount(modelContextWindow)} + +
    +
    +
    0 && 'min-w-1', + getBarColor(percent) + )} + style={{ '--usage-width': `${percent}%` } as React.CSSProperties} + /> +
    + {segments.length > 0 && ( +
    + {segments.map(segment => ( +
    + {segment.label} + {formatTokenCount(segment.tokens)} +
    + ))} +
    + )} +
    + + ); +} + +const RING_SIZE = 18; +const RING_STROKE = 2.5; +const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +function RingVisual() { + const { percent } = useContextDisplay(); + + return ( + + ); +} + +function RingPercentLabel() { + const { percent } = useContextDisplay(); + return {Math.round(percent)}%; +} +const ContextDisplayRing: FC = ({ + modelContextWindow, + className, + side, + usage, + resetKey, + labels, + ...triggerProps +}) => ( + + + + + + + +); + +function BarVisual() { + const { percent, totalTokens } = useContextDisplay(); + + return ( +
    +
    +
    +
    + + {formatTokenCount(totalTokens)} ({Math.round(percent)}%) + +
    + ); +} + +const ContextDisplayBar: FC = ({ + modelContextWindow, + className, + side, + usage, + resetKey, + labels, + ...triggerProps +}) => ( + + + + + + +); + +function TextVisual() { + const { totalTokens, modelContextWindow } = useContextDisplay(); + + return ( + <> + {formatTokenCount(totalTokens)} / {formatTokenCount(modelContextWindow)} + + ); +} + +const ContextDisplayText: FC = ({ + modelContextWindow, + className, + side, + usage, + resetKey, + labels, + ...triggerProps +}) => ( + + + + + + +); + +const ContextDisplay = {} as { + Root: typeof ContextDisplayRoot; + Trigger: typeof ContextDisplayTrigger; + Content: typeof ContextDisplayContent; + Ring: typeof ContextDisplayRing; + Bar: typeof ContextDisplayBar; + Text: typeof ContextDisplayText; +}; + +ContextDisplay.Root = ContextDisplayRoot; +ContextDisplay.Trigger = ContextDisplayTrigger; +ContextDisplay.Content = ContextDisplayContent; +ContextDisplay.Ring = ContextDisplayRing; +ContextDisplay.Bar = ContextDisplayBar; +ContextDisplay.Text = ContextDisplayText; + +export { + ContextDisplay, + ContextDisplayRoot, + ContextDisplayTrigger, + ContextDisplayContent, + ContextDisplayRing, + ContextDisplayBar, + ContextDisplayText, +}; diff --git a/app/src/components/assistant-ui/elements/conversation-search.tsx b/app/src/components/assistant-ui/elements/conversation-search.tsx new file mode 100644 index 0000000000..9eadabec47 --- /dev/null +++ b/app/src/components/assistant-ui/elements/conversation-search.tsx @@ -0,0 +1,121 @@ +'use client'; + +/** + * Vendored from the assistant-ui `elements-conversation-search` registry + * item (https://r.assistant-ui.com/styles/base-nova/elements-conversation-search.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - "Find in conversation" placeholder/aria-label and the previous/next match + * aria-labels are props with English defaults, for `useT()` — see + * `features/conversations/aui/ChatConversationSearch.tsx`. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { ChevronDownIcon, ChevronUpIcon, SearchIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; + +import { field, ghostButton, mono, paper } from './surfaces'; + +export interface SearchHit { + id: string; + before: string; + match: string; + after: string; + position: number; +} + +export function ConversationSearch({ + query, + hits, + activeIndex, + onQueryChange, + onStep, + placeholder = 'Find in conversation', + previousMatchLabel = 'Previous match', + nextMatchLabel = 'Next match', + className, + ...props +}: Omit< + ComponentProps<'div'>, + 'children' | 'query' | 'hits' | 'activeIndex' | 'onQueryChange' | 'onStep' +> & { + query: string; + hits: readonly SearchHit[]; + activeIndex: number; + onQueryChange?: (query: string) => void; + onStep?: (delta: number) => void; + placeholder?: string; + previousMatchLabel?: string; + nextMatchLabel?: string; +}) { + const index = hits.length === 0 ? -1 : Math.min(Math.max(activeIndex, 0), hits.length - 1); + const active = index === -1 ? undefined : hits[index]; + + return ( +
    +
    +
    + + onQueryChange?.(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[13px] outline-none" + /> + + {hits.length === 0 ? '0' : `${index + 1}/${hits.length}`} + + {onStep && ( + <> + + + + )} +
    + + {active && ( +
    + {active.before} + + {active.match} + + {active.after} +
    + )} +
    + +
    + {hits.map((hit, i) => ( + + ))} +
    +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/data-table.tsx b/app/src/components/assistant-ui/elements/data-table.tsx new file mode 100644 index 0000000000..13afcb2c2b --- /dev/null +++ b/app/src/components/assistant-ui/elements/data-table.tsx @@ -0,0 +1,111 @@ +'use client'; + +/** + * assistant-ui's data-table element: a compact card for a short list of + * rows, each row a fixed set of columns with a leading letter avatar. + * + * Vendored from the assistant-ui `elements-data-table` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-data-table.json). + * Changes from upstream: + * - `cn` import path. + * - Generalized: upstream hardcodes three `ModelUsage` columns (model / + * context / cost). Here the row shape and column set are both a `columns` + * prop (`DataTableColumn[]`, each an accessor + header text), so any + * tool result that is an array of flat objects can render through this + * element without a bespoke table. `header`/column `label`s are plain + * strings supplied by the caller via `useT()`, not hardcoded here. + * - `avatarKey` picks which column seeds the leading letter avatar (defaults + * to the first column) instead of assuming a `name` field. + */ +import { mono, paper } from '@/components/assistant-ui/elements/surfaces'; +import { cn } from '@/components/assistant-ui/lib/utils'; +import type { ComponentProps, ReactNode } from 'react'; + +export interface DataTableColumn { + /** Stable key for the column, also used for the row's React key when no `rowKey` is given. */ + key: string; + /** Column header text. Caller-supplied (i18n), not hardcoded. */ + header: string; + /** Renders one row's value for this column. */ + cell: (row: TRow, index: number) => ReactNode; + /** `true` right-aligns the column, matching the tabular-numeric columns upstream renders. */ + align?: 'start' | 'end'; +} + +export interface DataTableProps extends Omit, 'children'> { + rows: readonly TRow[]; + columns: readonly DataTableColumn[]; + /** Bumping this replays the row entrance animation, e.g. after a refresh. */ + cycle?: number; + /** Row identity for React's key; defaults to the row's index. */ + rowKey?: (row: TRow, index: number) => string; + /** Which column seeds the leading letter avatar; defaults to the first column. */ + avatarKey?: string; +} + +export function DataTable({ + rows, + columns, + cycle = 0, + rowKey, + avatarKey, + className, + ...props +}: DataTableProps) { + const avatarColumn = columns.find(c => c.key === avatarKey) ?? columns[0]; + + return ( +
    +
    + {columns.map(column => ( + + {column.header} + + ))} +
    +
    +
    + {rows.map((row, index) => { + const key = rowKey?.(row, index) ?? String(index); + const avatarValue = avatarColumn ? avatarColumn.cell(row, index) : null; + const avatarLetter = + typeof avatarValue === 'string' && avatarValue.length > 0 + ? avatarValue[0]!.toUpperCase() + : '·'; + + return ( +
    + + {avatarLetter} + + {columns.map(column => ( + + {column.cell(row, index)} + + ))} +
    + ); + })} +
    +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/day-separator.tsx b/app/src/components/assistant-ui/elements/day-separator.tsx new file mode 100644 index 0000000000..1870c24d91 --- /dev/null +++ b/app/src/components/assistant-ui/elements/day-separator.tsx @@ -0,0 +1,75 @@ +'use client'; + +/** + * Vendored from the assistant-ui `elements-day-separator` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-day-separator.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - The day-boundary check no longer reassigns a `let` across `.map()` + * iterations (flagged by this repo's `react-hooks/immutability` lint); it + * compares each message's day against the previous array entry instead, + * with identical output. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import type { ComponentProps } from 'react'; + +import { mono } from './surfaces'; + +export interface DatedMessage { + id: string; + day: string; + time: string; + role: 'user' | 'assistant'; + text: string; +} + +export function DaySeparator({ + messages, + className, + ...props +}: Omit, 'children' | 'messages'> & { messages: readonly DatedMessage[] }) { + return ( +
    + {messages.map((message, index) => { + const newDay = index === 0 || messages[index - 1].day !== message.day; + + return ( +
    + {newDay && ( +
    + + {message.day} + +
    + )} +
    + + {message.text} + + + {message.time} + +
    +
    + ); + })} +
    + ); +} diff --git a/app/src/components/assistant-ui/elements/edit-message.tsx b/app/src/components/assistant-ui/elements/edit-message.tsx new file mode 100644 index 0000000000..d2862987e0 --- /dev/null +++ b/app/src/components/assistant-ui/elements/edit-message.tsx @@ -0,0 +1,122 @@ +'use client'; + +/** + * Vendored from the assistant-ui `elements-edit-message` registry item + * (https://r.assistant-ui.com/styles/base-nova/elements-edit-message.json). + * Changes from upstream: + * - `cn` import path (`@/components/assistant-ui/lib/utils`). + * - "Cancel"/"Send" are `cancelLabel`/`sendLabel` props; the "Edit your + * message" aria-label is `editAriaLabel`; all with English defaults, for + * `useT()`. + * - The pluralized "sending discards N replies" copy is now a + * `discardedRepliesText(count)` template prop, defaulting to the upstream + * English copy. + */ +import { cn } from '@/components/assistant-ui/lib/utils'; +import { AlertTriangleIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; + +import { field, inkButton, mono, paper } from './surfaces'; + +const defaultDiscardedRepliesText = (count: number) => + `sending discards ${count} ${count === 1 ? 'reply' : 'replies'}`; + +export function EditMessage({ + value, + discardedReplies, + editing, + onValueChange, + onSave, + onCancel, + onStartEdit, + cancelLabel = 'Cancel', + sendLabel = 'Send', + editAriaLabel = 'Edit your message', + discardedRepliesText = defaultDiscardedRepliesText, + className, + ...props +}: Omit< + ComponentProps<'div'>, + | 'children' + | 'value' + | 'discardedReplies' + | 'editing' + | 'onValueChange' + | 'onSave' + | 'onCancel' + | 'onStartEdit' +> & { + value: string; + discardedReplies: number; + editing: boolean; + onValueChange?: (value: string) => void; + onSave?: () => void; + onCancel?: () => void; + onStartEdit?: () => void; + cancelLabel?: string; + sendLabel?: string; + editAriaLabel?: string; + discardedRepliesText?: (count: number) => string; +}) { + if (!editing) { + return ( +
    + +
    + ); + } + + return ( +
    +