Merge pull request #101 from QRun-IO/feature/assistant-write-mode
feat(assistant): Read-only / Write switch per conversation
This commit was merged in pull request #101.
This commit is contained in:
3 files changed
+52
-2
No files matched your search
@@ -254,6 +254,32 @@ describe('asking a question', () => {
|
||||
act(() => stream.close())
|
||||
})
|
||||
|
||||
it('switches a conversation between Read-only and Write', async () => {
|
||||
mockAssistant({ enabled: true, features: { writeMode: true } })
|
||||
const stream = controllableStream()
|
||||
let patched: unknown
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations`, () => HttpResponse.json({ ...CONVERSATION, writeMode: false }, { status: 201 })),
|
||||
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: { ...CONVERSATION, writeMode: false }, messages: [] })),
|
||||
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
|
||||
http.patch(`${BASE}/conversations/4`, async ({ request }) => {
|
||||
patched = await request.json()
|
||||
return HttpResponse.json({ ...CONVERSATION, writeMode: true })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'hi{Enter}')
|
||||
act(() => stream.close())
|
||||
const toggle = await within(panel).findByRole('switch', { name: /read-only/i })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(patched).toEqual({ writeMode: true }))
|
||||
expect(await within(panel).findByRole('switch', { name: /write/i })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('stops a reply through the cancel endpoint', async () => {
|
||||
mockAssistant()
|
||||
const stream = controllableStream()
|
||||
|
||||
@@ -28,7 +28,7 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Archive, ArchiveRestore, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
|
||||
import { Archive, ArchiveRestore, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
@@ -310,6 +310,25 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
|
||||
) : (
|
||||
<h2 id={titleId} className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-foreground" data-qqq-id="assistant-title">{title}</h2>
|
||||
)}
|
||||
{conversation && activeConversationId !== null && assistant.config?.features?.writeMode && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(conversation.writeMode)}
|
||||
onClick={() => void updateConversation({ writeMode: !conversation.writeMode })}
|
||||
title={conversation.writeMode
|
||||
? 'Write mode: the assistant may propose changes, which you confirm. It acts only with your own permissions.'
|
||||
: 'Read-only: the assistant cannot change anything. Switch to Write to let it propose changes you confirm.'}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center gap-1 rounded-full border px-2.5 text-xs font-medium pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
conversation.writeMode ? 'border-amber-500/60 bg-amber-500/10 text-amber-800 dark:text-amber-300' : 'border-border text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id="assistant-write-mode"
|
||||
>
|
||||
{conversation.writeMode ? <PencilLine className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{conversation.writeMode ? 'Write' : 'Read-only'}
|
||||
</button>
|
||||
)}
|
||||
{conversation && activeConversationId !== null && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
|
||||
@@ -30,7 +30,9 @@ export interface AssistantConfig {
|
||||
shortcut?: string
|
||||
model?: string
|
||||
limits?: { maxTurnSeconds?: number; maxToolCallsPerTurn?: number }
|
||||
features?: { writes?: boolean; pendingActions?: boolean }
|
||||
features?: { writes?: boolean; pendingActions?: boolean; writeMode?: boolean; autoConfirm?: boolean }
|
||||
/** The user's own preferences. */
|
||||
preferences?: { defaultWriteMode?: boolean; autoConfirmLowRisk?: boolean }
|
||||
/** The assistant's persona (admin-configured profile); text only, it never changes access. */
|
||||
profile?: AssistantProfile
|
||||
}
|
||||
@@ -56,6 +58,8 @@ export interface AssistantConversation {
|
||||
lastMessageAt: string | null
|
||||
unreadCount: number
|
||||
createDate: string
|
||||
/** Write mode (Read-only when false or absent): the assistant's write tools exist only in Write mode. */
|
||||
writeMode?: boolean
|
||||
}
|
||||
|
||||
/** Page the user is on, sent as a hint with a turn (never trusted by the server). */
|
||||
@@ -233,6 +237,7 @@ export interface AssistantConversationUpdate {
|
||||
title?: string
|
||||
pinned?: boolean
|
||||
status?: AssistantConversationStatus
|
||||
writeMode?: boolean
|
||||
}
|
||||
|
||||
/** Body of `POST /assistant/messages/{id}/feedback`. */
|
||||
|
||||
Reference in new issue
Block a user