From 9fc9cdf8ba55d07c84b2cd59619f656952993fe9 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 16 Aug 2026 16:11:12 +0200 Subject: [PATCH] An installed app can draw a gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates are a pillar, and an app that shipped its own frontend could not render one. installed.tsx is a hard either/or: a package with dist/entry.js takes its whole namespace and loses the generic subject page — the only place outside ship's hand-built one where InAppReview renders. It was not among the ten lent names, so there was nothing to borrow either. The app either went without a frontend or went without the screen where its work gets approved. CancelRun, RetryRun and InAppReview join @druks/ui, with the InputRequest type the ask arrives as. All three, not just the gate: they are the same category — platform run actions keyed by a run id, sitting on the same page — and lending one would have left the next hole. InAppReview drops react-query. It was one optional artifact read, and an app mounts these outside the shell's tree where no QueryClientProvider exists. The fetch is 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 the next fetch lands. Its calls were never the blocker the ENG-850 notes claimed: /api/artifacts/{id} and /api/runs/{id}/resume are platform paths, so same-origin app code reaches them. The existing tests lose their QueryClientProvider. That removal is the proof — if anything still needed react-query, they would be red. --- frontend/src/components/RunControls.test.tsx | 66 +++++++++++++++++--- frontend/src/components/RunControls.tsx | 37 +++++++---- frontend/src/runtime/druks-ui.test.ts | 3 + frontend/src/runtime/druks-ui.ts | 6 +- 4 files changed, 92 insertions(+), 20 deletions(-) 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'