Merge pull request #94 from QRun-IO/feature/assistant-pending-actions

feat(assistant): confirmation card for proposed writes, and the configured persona
This commit was merged in pull request #94.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-13 16:08:57 -05:00
commit 0282328bb0
7 files changed
+369 -4

No files matched your search

@@ -235,6 +235,25 @@ describe('asking a question', () => {
expect(await axe(panel)).toHaveNoViolations()
})
it('shows the configured persona: name, greeting and starter prompts that send', async () => {
mockAssistant({ enabled: true, profile: { displayName: 'Ada', icon: 'school', greeting: 'Hi, I am Ada.', starterPrompts: ['How many posts?'] } })
const stream = controllableStream()
let turnBody: unknown
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
turnBody = await request.json()
return stream.response()
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
expect(within(panel).getAllByText('Ada').length).toBeGreaterThan(0)
expect(within(panel).getByText('Hi, I am Ada.')).toBeInTheDocument()
await user.click(within(panel).getByRole('button', { name: 'How many posts?' }))
await waitFor(() => expect(turnBody).toMatchObject({ text: 'How many posts?' }))
act(() => stream.close())
})
it('stops a reply through the cancel endpoint', async () => {
mockAssistant()
const stream = controllableStream()
+21 -3
View File
@@ -259,7 +259,9 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
}
const showMessages = wide || !railOpen
const title = activeConversationId === null ? 'Assistant' : conversationTitle(conversation)
const profile = assistant.config?.profile
const assistantName = profile?.displayName?.trim() || 'Assistant'
const title = activeConversationId === null ? assistantName : conversationTitle(conversation)
const iconButton = 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11'
const menuItem = 'flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-foreground outline-none data-[highlighted]:bg-accent pointer-coarse:min-h-11'
@@ -392,8 +394,24 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
>
{activeConversationId === null && (
<div className="py-6 text-center text-sm text-muted-foreground" data-qqq-id="assistant-empty">
<p className="font-medium text-foreground">Ask about your data</p>
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. The assistant sees only what you can see.</p>
<p className="font-medium text-foreground">{profile?.greeting?.trim() || 'Ask about your data'}</p>
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. {assistantName} sees and does only what you can, through the same API as these screens.</p>
{(profile?.starterPrompts?.length ?? 0) > 0 && (
<ul className="mt-4 flex flex-wrap justify-center gap-2" aria-label="Suggested questions" data-qqq-id="assistant-starters">
{profile?.starterPrompts?.map((prompt) => (
<li key={prompt} className="max-w-full">
<button
type="button"
onClick={() => send(prompt)}
disabled={Boolean(run)}
className="max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
{prompt}
</button>
</li>
))}
</ul>
)}
</div>
)}
{activeConversationId !== null && detailQuery.isLoading && <p className="text-xs text-muted-foreground" role="status">Loading conversation...</p>}
@@ -0,0 +1,89 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it } from 'vitest'
import { axe } from 'jest-axe'
import type { AssistantPendingActionRender } from '@/types/assistant'
import { server } from '@/mocks/node'
import AssistantPendingActionCard from './AssistantPendingActionCard'
const RENDER: AssistantPendingActionRender = {
type: 'pending_action',
actionId: 7,
toolName: 'create_record',
tableName: 'blog_post',
tableLabel: 'Blog Post',
summary: 'Create a Blog Post: Title = Hello',
changes: [{ field: 'title', label: 'Title', value: 'Hello' }],
}
/**
* Serves the action endpoints with the given state transitions.
*
* @param confirmStatus - The status the confirm endpoint answers.
* @returns The calls made, by kind.
*/
function serve(confirmStatus: 'EXECUTED' | 'FAILED' = 'EXECUTED') {
const calls: string[] = []
server.use(
http.get('*/assistant/actions/7', () => {
calls.push('get')
return HttpResponse.json({ id: 7, status: 'PENDING', toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary })
}),
http.post('*/assistant/actions/7/confirm', () => {
calls.push('confirm')
return HttpResponse.json({ id: 7, status: confirmStatus, toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary, resultSummary: 'Created Blog Post 9', outcome: 'OK', href: '/app/blog_post/9' })
}),
http.post('*/assistant/actions/7/reject', () => {
calls.push('reject')
return HttpResponse.json({ id: 7, status: 'REJECTED', toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary, resultSummary: 'Cancelled by the user; nothing changed.' })
})
)
return calls
}
describe('AssistantPendingActionCard', () => {
it('shows the proposed change and runs it only when confirmed', async () => {
const calls = serve()
const { container } = render(<QueryClientProvider client={new QueryClient()}><AssistantPendingActionCard render={RENDER} /></QueryClientProvider>)
expect(screen.getByText('Create a Blog Post: Title = Hello')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
await waitFor(() => expect(calls).toContain('get'))
expect(calls).not.toContain('confirm')
await userEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => expect(screen.getByText(/Done · Created Blog Post 9/)).toBeInTheDocument())
expect(calls.filter((c) => c === 'confirm')).toHaveLength(1)
expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /open blog post/i })).toHaveAttribute('href', '/app/blog_post/9')
expect(await axe(container)).toHaveNoViolations()
})
it('cancels without running anything', async () => {
const calls = serve()
render(<QueryClientProvider client={new QueryClient()}><AssistantPendingActionCard render={RENDER} /></QueryClientProvider>)
await waitFor(() => expect(calls).toContain('get'))
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(screen.getByText(/Cancelled, nothing changed/)).toBeInTheDocument())
expect(calls).not.toContain('confirm')
})
})
@@ -0,0 +1,159 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file AssistantPendingActionCard — a write the assistant proposed (create, update or delete a
* record). Nothing changes until the user confirms; the server then runs the write through the
* UI API with the user's own session, so the same permissions, record locks and validation apply.
* The card loads the action's current state, so a reloaded conversation shows the outcome.
*/
'use client'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useQueryClient } from '@tanstack/react-query'
import { Check, PencilLine, Plus, Trash2, X } from 'lucide-react'
import type { AssistantPendingAction, AssistantPendingActionRender } from '@/types/assistant'
import { confirmAssistantAction, getAssistantAction, rejectAssistantAction } from '@/lib/api/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { getErrorMessage } from '@/lib/utils/error-utils'
/** What a finished action says, by status. */
const STATUS_TEXT: Record<AssistantPendingAction['status'], string> = {
PENDING: 'Waiting for your confirmation',
RUNNING: 'Saving…',
EXECUTED: 'Done',
FAILED: 'Not saved',
REJECTED: 'Cancelled, nothing changed',
EXPIRED: 'Expired, nothing changed',
}
/**
* The confirmation card of one proposed write.
*
* @param props - Component properties.
* @param props.render - The `pending_action` payload of the tool result.
* @returns The card.
*/
export default function AssistantPendingActionCard({ render }: { render: AssistantPendingActionRender }) {
const [action, setAction] = useState<AssistantPendingAction | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const queryClient = useQueryClient()
useEffect(() => {
let live = true
getAssistantAction(render.actionId)
.then((loaded) => {
if (live) setAction(loaded)
})
.catch((e: unknown) => {
if (live) setError(getErrorMessage(e, 'Could not load this action.'))
})
return () => {
live = false
}
}, [render.actionId])
const status = action?.status ?? 'PENDING'
const pending = status === 'PENDING'
const Icon = render.toolName === 'delete_record' ? Trash2 : render.toolName === 'create_record' ? Plus : PencilLine
const verb = render.toolName === 'delete_record' ? 'Delete' : render.toolName === 'create_record' ? 'Create' : 'Save'
const resultHref = internalAppHref(action?.href ?? render.href)
const tone = status === 'EXECUTED' ? 'text-emerald-700 dark:text-emerald-400' : status === 'FAILED' ? 'text-destructive' : 'text-muted-foreground'
/**
* Confirms or cancels.
*
* @param confirm - True to run the write.
*/
async function decide(confirm: boolean) {
setBusy(true)
setError(null)
try {
const decided = confirm ? await confirmAssistantAction(render.actionId) : await rejectAssistantAction(render.actionId)
setAction(decided)
// the record screens on the page show the saved values without a reload
if (decided.status === 'EXECUTED') await queryClient.invalidateQueries()
} catch (e) {
setError(getErrorMessage(e, confirm ? 'The change could not be saved.' : 'The action could not be cancelled.'))
getAssistantAction(render.actionId).then(setAction).catch(() => undefined)
} finally {
setBusy(false)
}
}
return (
<div className="rounded-lg border border-border bg-card" data-qqq-id="assistant-pending-action" role="group" aria-label={`Proposed change: ${render.summary}`}>
<div className="flex items-start gap-2 border-b border-border px-3 py-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground break-words">{render.summary}</p>
<p className={`text-xs ${tone}`} aria-live="polite" data-qqq-id="assistant-pending-action-status">
{STATUS_TEXT[status]}
{action?.resultSummary && status !== 'PENDING' ? ` · ${action.resultSummary}` : ''}
</p>
</div>
</div>
{render.changes.length > 0 && (
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-1 px-3 py-2 text-xs">
{render.changes.map((change) => (
<React.Fragment key={change.field}>
<dt className="truncate text-muted-foreground">{change.label}</dt>
<dd className="break-words text-foreground">{change.value ?? <span className="text-muted-foreground">(empty)</span>}</dd>
</React.Fragment>
))}
</dl>
)}
{(error || action?.message) && status !== 'EXECUTED' && (
<p className="px-3 pb-2 text-xs text-destructive" role="alert">{error ?? action?.message}</p>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
{pending ? (
<>
<button
type="button"
disabled={busy}
onClick={() => decide(true)}
className={`inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60 ${render.toolName === 'delete_record' ? 'bg-destructive text-white hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
data-qqq-id="assistant-pending-action-confirm"
>
<Check className="h-3.5 w-3.5" aria-hidden="true" />
{busy ? 'Working…' : verb}
</button>
<button
type="button"
disabled={busy}
onClick={() => decide(false)}
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
data-qqq-id="assistant-pending-action-cancel"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
Cancel
</button>
</>
) : null}
{resultHref && status !== 'REJECTED' && status !== 'EXPIRED' && (
<Link href={resultHref} className="ml-auto inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Open {render.tableLabel}
</Link>
)}
</div>
</div>
)
}
@@ -39,6 +39,7 @@ import { internalAppHref } from '@/lib/utils/assistant-links'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { RecordHoverCard } from '@/components/records/RecordHoverCard'
const PendingActionCard = lazy(() => import('./AssistantPendingActionCard'))
const WidgetRenderer = lazy(() => import('@/components/widgets/WidgetRenderer').then((m) => ({ default: m.WidgetRenderer })))
/** Widget types the panel renders inline (charts and statistics); others link to their screen. */
@@ -303,6 +304,12 @@ export function AssistantRenderView({ render }: { render: AssistantRender }) {
return <HistoryView render={render} />
case 'widget':
return <WidgetView render={render} />
case 'pending_action':
return (
<Suspense fallback={<div className="h-24 rounded-lg bg-muted motion-safe:animate-pulse" />}>
<PendingActionCard render={render} />
</Suspense>
)
default:
return null
}
+31
View File
@@ -27,6 +27,7 @@ import type {
AssistantConversationUpdate,
AssistantFeedback,
AssistantPageContext,
AssistantPendingAction,
AssistantStreamEvent,
} from '@/types/assistant'
import apiClient, { apiUrl } from './client'
@@ -146,6 +147,36 @@ export async function sendAssistantFeedback(messageId: number, feedback: Assista
await apiClient.post(`/assistant/messages/${messageId}/feedback`, feedback)
}
/**
* Loads a pending action (a write the assistant proposed) and its current state.
*
* @param actionId - The action id from the `pending_action` render.
* @returns The action.
*/
export async function getAssistantAction(actionId: number): Promise<AssistantPendingAction> {
return apiClient.get<AssistantPendingAction>(`/assistant/actions/${actionId}`)
}
/**
* Confirms a pending action: the server runs the write through the UI API with this session.
*
* @param actionId - The action id.
* @returns The action after it ran, with the outcome and message.
*/
export async function confirmAssistantAction(actionId: number): Promise<AssistantPendingAction> {
return apiClient.post<AssistantPendingAction>(`/assistant/actions/${actionId}/confirm`)
}
/**
* Cancels a pending action; nothing changes.
*
* @param actionId - The action id.
* @returns The rejected action.
*/
export async function rejectAssistantAction(actionId: number): Promise<AssistantPendingAction> {
return apiClient.post<AssistantPendingAction>(`/assistant/actions/${actionId}/reject`)
}
// ─── Streaming ────────────────────────────────────────────────────────────────
/**
+43 -1
View File
@@ -30,7 +30,18 @@ export interface AssistantConfig {
shortcut?: string
model?: string
limits?: { maxTurnSeconds?: number; maxToolCallsPerTurn?: number }
features?: { writes?: boolean }
features?: { writes?: boolean; pendingActions?: boolean }
/** The assistant's persona (admin-configured profile); text only, it never changes access. */
profile?: AssistantProfile
}
/** How the assistant presents itself. */
export interface AssistantProfile {
displayName?: string
/** A Material icon name. */
icon?: string
greeting?: string | null
starterPrompts?: string[]
}
/** Lifecycle of a conversation. */
@@ -125,6 +136,36 @@ export interface AssistantHistoryRender {
entries: AssistantHistoryEntry[]
}
/** A write the assistant proposed; nothing changes until the user confirms it. */
export interface AssistantPendingActionRender {
type: 'pending_action'
actionId: number
toolName: 'create_record' | 'update_record' | 'delete_record'
tableName: string
tableLabel: string
recordId?: string | null
summary: string
changes: Array<{ field: string; label: string; value: string | null }>
href?: string
}
/** A pending action's state on the server. */
export interface AssistantPendingAction {
id: number
status: 'PENDING' | 'RUNNING' | 'EXECUTED' | 'FAILED' | 'REJECTED' | 'EXPIRED'
toolName: string
tableName: string
recordId?: string | null
summary: string
httpStatus?: number | null
resultSummary?: string | null
expiresAt?: string | null
/** After a confirm: the tool outcome and message, and a link to the record. */
outcome?: string
message?: string
href?: string
}
/** Structured UI payload of a tool result (never model-written HTML). */
export type AssistantRender =
| AssistantRecordsRender
@@ -132,6 +173,7 @@ export type AssistantRender =
| AssistantCountRender
| AssistantWidgetRender
| AssistantHistoryRender
| AssistantPendingActionRender
/** Markdown text written by the model. */
export interface AssistantTextPart { type: 'text'; text: string }