diff --git a/frontend/src/components/RunControls.test.tsx b/frontend/src/components/RunControls.test.tsx index 9d053266..7419e608 100644 --- a/frontend/src/components/RunControls.test.tsx +++ b/frontend/src/components/RunControls.test.tsx @@ -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>( @@ -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( - - - , - ) + return render() } afterEach(() => { @@ -96,3 +92,57 @@ describe('InAppReview', () => { ).toBeTruthy() }) }) + +describe('the lent run controls', () => { + it('render with no provider around them', () => { + stubFetch() + render( + <> + + + + , + ) + 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>( + 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( + , + ) + + 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( + , + ) + // 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() + }) +}) diff --git a/frontend/src/components/RunControls.tsx b/frontend/src/components/RunControls.tsx index 35483b0d..02225ae6 100644 --- a/frontend/src/components/RunControls.tsx +++ b/frontend/src/components/RunControls.tsx @@ -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 @@ -102,11 +101,27 @@ export function InAppReview({ runId, ask }: { runId: string; ask: InputRequest } const [error, setError] = useState(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) @@ -129,10 +144,10 @@ export function InAppReview({ runId, ask }: { runId: string; ask: InputRequest } )} - {artifact.data && ( + {artifact && (
-
{artifact.data.title}
- +
{artifact.title}
+
)} {ask.questions?.map((question) => { diff --git a/frontend/src/runtime/druks-ui.test.ts b/frontend/src/runtime/druks-ui.test.ts index 4afb5dd1..2b9046f5 100644 --- a/frontend/src/runtime/druks-ui.test.ts +++ b/frontend/src/runtime/druks-ui.test.ts @@ -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', diff --git a/frontend/src/runtime/druks-ui.ts b/frontend/src/runtime/druks-ui.ts index 36d9c439..a2e09615 100644 --- a/frontend/src/runtime/druks-ui.ts +++ b/frontend/src/runtime/druks-ui.ts @@ -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'