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:
james.maes authored and GitHub committed 2026-10-13 17:50:01 -05:00
commit a2f231862c
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()
+20 -1
View File
@@ -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>
+6 -1
View File
@@ -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`. */