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:
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user