Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions frontend/src/components/RunControls.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import type { InputRequest } from '../api/types'
import { InAppReview } from './RunControls'
import { CancelRun, InAppReview, RetryRun } from './RunControls'

function stubFetch() {
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(
Expand All @@ -13,13 +12,10 @@ function stubFetch() {
return fetchMock
}

// No provider. These are lent to installed apps through @druks/ui, which mount
// them outside the shell's tree — so anything they need has to be their own.
function renderReview(ask: InputRequest) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<InAppReview runId="run-123" ask={ask} />
</QueryClientProvider>,
)
return render(<InAppReview runId="run-123" ask={ask} />)
}

afterEach(() => {
Expand Down Expand Up @@ -96,3 +92,57 @@ describe('InAppReview', () => {
).toBeTruthy()
})
})

describe('the lent run controls', () => {
it('render with no provider around them', () => {
stubFetch()
render(
<>
<CancelRun runId="run-123" />
<RetryRun runId="run-123" />
<InAppReview runId="run-123" ask={{ presentation: 'in_app', controls: ['approve'] }} />
</>,
)
expect(screen.getByText('cancel run')).toBeTruthy()
expect(screen.getByText('retry run')).toBeTruthy()
expect(screen.getByText('Approve')).toBeTruthy()
})

it('reads the ask artifact itself, without react-query', async () => {
const fetchMock = vi.fn<(url: string) => Promise<Response>>(
async () =>
new Response(JSON.stringify({ kind: 'plan', title: 'The plan', content: 'step one' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)

render(
<InAppReview
runId="run-123"
ask={{ presentation: 'in_app', controls: ['approve'], artifact_id: 'art-1' }}
/>,
)

expect(await screen.findByText('The plan')).toBeTruthy()
expect(screen.getByText('step one')).toBeTruthy()
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/artifacts/art-1')
})

it('renders the panel without the artifact when the read fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('nope', { status: 404, statusText: 'Not Found' })),
)
render(
<InAppReview
runId="run-123"
ask={{ presentation: 'in_app', controls: ['approve'], artifact_id: 'gone' }}
/>,
)
// The ask's own controls are what the operator answers; a missing artifact
// must not take the gate down with it.
expect(await screen.findByText('Approve')).toBeTruthy()
})
})
37 changes: 26 additions & 11 deletions frontend/src/components/RunControls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'

import { api } from '../api/client'
import type { InputRequest } from '../api/types'
import type { ArtifactContent, InputRequest } from '../api/types'
import { Markdown } from './Markdown'

// Cancel is a run-level action: end any active run, parked or running. A destructive
Expand Down Expand Up @@ -102,11 +101,27 @@ export function InAppReview({ runId, ask }: { runId: string; ask: InputRequest }
const [error, setError] = useState<string | null>(null)
const critique = ask.context?.trim() ?? ''

const artifact = useQuery({
queryKey: ['artifact', ask.artifact_id],
queryFn: () => api.artifact(ask.artifact_id as string),
enabled: Boolean(ask.artifact_id),
})
// Fetched here rather than through react-query: an installed app borrows this
// component through the import map and mounts it outside the shell's tree,
// where there is no QueryClientProvider. One artifact, read once per ask.
// Held with the id it belongs to, so a new ask stops showing the old plan on
// the render that changes it rather than after its fetch lands.
const [fetched, setFetched] = useState<{ id: string; content: ArtifactContent } | null>(null)
const artifact = fetched && fetched.id === ask.artifact_id ? fetched.content : null
useEffect(() => {
const artifactId = ask.artifact_id
if (!artifactId) return
let live = true
// A missing artifact leaves the panel without it: the ask's own questions
// and controls are what the operator answers.
api
.artifact(artifactId)
.then((content) => live && setFetched({ id: artifactId, content }))
.catch(() => {})
return () => {
live = false
}
}, [ask.artifact_id])

async function choose(control: string) {
setPending(control)
Expand All @@ -129,10 +144,10 @@ export function InAppReview({ runId, ask }: { runId: string; ask: InputRequest }
<Markdown source={critique} />
</div>
)}
{artifact.data && (
{artifact && (
<div className="review-artifact">
<div className="review-artifact-title">{artifact.data.title}</div>
<Markdown source={artifact.data.content} />
<div className="review-artifact-title">{artifact.title}</div>
<Markdown source={artifact.content} />
</div>
)}
{ask.questions?.map((question) => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/runtime/druks-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import * as ui from './druks-ui'
// Red means stop and ask. Never edit the list to match the code.
const LENT = [
'Button',
'CancelRun',
'EmptyState',
'Field',
'InAppReview',
'Page',
'PageHeader',
'RelTime',
'RetryRun',
'SectionHead',
'Select',
'StatusGlyph',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/runtime/druks-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ export { EmptyState } from '../components/EmptyState'
export { StatusGlyph } from '../components/StatusGlyph'
export { RelTime } from '../components/RelTime'
export { Button, Field, Select, TextInput } from '../components/Control'
// A run's operator actions. Gates are a platform pillar, so an app that ships a
// frontend has to be able to draw one — it loses the generic subject page that
// would otherwise carry it.
export { CancelRun, InAppReview, RetryRun } from '../components/RunControls'

export type { RunState } from '../api/types'
export type { InputRequest, RunState } from '../api/types'