fix(tui): preserve global form ownership

This commit is contained in:
Aiden Cline 2026-07-05 22:47:20 -05:00
commit 57dd2fba7d
5 changed files with 58 additions and 13 deletions

View file

@ -69,6 +69,12 @@ function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID]) return JSON.stringify([location.directory, location.workspaceID])
} }
function matchesGlobalForm(form: FormInfo, id: string, location?: LocationRef) {
if (form.id !== id) return false
if (!form.location || !location) return true
return locationKey(form.location) === locationKey(location)
}
function locationQuery(ref?: LocationRef) { function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
} }
@ -589,7 +595,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
break break
case "form.created": case "form.created":
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break if (
store.session.form[event.data.form.sessionID]?.some((form) =>
event.data.form.sessionID === "global"
? matchesGlobalForm(form, event.data.form.id, event.location)
: form.id === event.data.form.id,
)
)
break
setStore("session", "form", event.data.form.sessionID, [ setStore("session", "form", event.data.form.sessionID, [
...(store.session.form[event.data.form.sessionID] ?? []), ...(store.session.form[event.data.form.sessionID] ?? []),
mutable({ ...event.data.form, location: event.location }), mutable({ ...event.data.form, location: event.location }),
@ -601,7 +614,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
"session", "session",
"form", "form",
event.data.sessionID, event.data.sessionID,
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), (store.session.form[event.data.sessionID] ?? []).filter((form) =>
event.data.sessionID === "global"
? !matchesGlobalForm(form, event.data.id, event.location)
: form.id !== event.data.id,
),
) )
break break
case "shell.created": case "shell.created":

View file

@ -26,6 +26,13 @@ function sessionErrorMessage(error: SessionError) {
return "Session error" return "Session error"
} }
function formKey(
event: Extract<V2Event, { type: "form.created" | "form.replied" | "form.cancelled" }>,
id: string,
) {
return JSON.stringify([event.location?.directory, event.location?.workspaceID, id])
}
const tui: TuiPlugin = async (api) => { const tui: TuiPlugin = async (api) => {
const active = new Set<string>() const active = new Set<string>()
const errored = new Set<string>() const errored = new Set<string>()
@ -34,18 +41,18 @@ const tui: TuiPlugin = async (api) => {
const permissions = new Set<string>() const permissions = new Set<string>()
api.event.on("form.created", (event) => { api.event.on("form.created", (event) => {
if (event.data.form.sessionID === "global") return const key = formKey(event, event.data.form.id)
if (forms.has(event.data.form.id)) return if (forms.has(key)) return
forms.add(event.data.form.id) forms.add(key)
notify(api, event.data.form.sessionID, "Input needs response", "question") notify(api, event.data.form.sessionID, "Input needs response", "question")
}) })
api.event.on("form.replied", (event) => { api.event.on("form.replied", (event) => {
forms.delete(event.data.id) forms.delete(formKey(event, event.data.id))
}) })
api.event.on("form.cancelled", (event) => { api.event.on("form.cancelled", (event) => {
forms.delete(event.data.id) forms.delete(formKey(event, event.data.id))
}) })
api.event.on("question.asked", (event) => { api.event.on("question.asked", (event) => {

View file

@ -187,6 +187,11 @@ export function Session() {
...(data.session.form.list("global", location()) ?? []), ...(data.session.form.list("global", location()) ?? []),
] ]
}) })
const formKey = createMemo(() => {
const form = forms()[0]
if (!form) return
return JSON.stringify([form.location?.directory, form.location?.workspaceID, form.id])
})
const [composer, setComposer] = createStore({ const [composer, setComposer] = createStore({
open: false, open: false,
tab: undefined as string | undefined, tab: undefined as string | undefined,
@ -261,6 +266,7 @@ export function Session() {
return return
} }
await data.session.form.refresh("global", info.location) await data.session.form.refresh("global", info.location)
if (route.sessionID !== sessionID) return
project.workspace.set(info.location.workspaceID) project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory) editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
@ -932,17 +938,17 @@ export function Session() {
<box flexShrink={0}> <box flexShrink={0}>
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
open={composer.open || !!session()?.parentID} open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)} defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)} onClose={() => setComposer("open", false)}
/> />
<Switch> <Switch>
<Match when={composer.open || !!session()?.parentID}>{null}</Match> <Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={permissions().length > 0}> <Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} /> <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
</Match> </Match>
<Match when={forms().length > 0}> <Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed> <Show when={formKey()} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? <FormPrompt form={form} /> : null return form ? <FormPrompt form={form} /> : null

View file

@ -155,6 +155,11 @@ const formNotification: TuiAttentionNotifyInput = {
sound: { name: "question", when: "always" }, sound: { name: "question", when: "always" },
} }
const globalFormNotification: TuiAttentionNotifyInput = {
...formNotification,
title: undefined,
}
const permissionNotification: TuiAttentionNotifyInput = { const permissionNotification: TuiAttentionNotifyInput = {
title: "Demo session", title: "Demo session",
message: "Permission needs input", message: "Permission needs input",
@ -173,12 +178,12 @@ describe("internal notifications TUI plugin", () => {
expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification]) expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification])
}) })
test("ignores global forms until the TUI can render them", async () => { test("notifies for global forms once the TUI can render them", async () => {
const harness = await setup() const harness = await setup()
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "global") } }) harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "global") } })
expect(harness.notifications).toEqual([]) expect(harness.notifications).toEqual([globalFormNotification])
}) })
test("dedupes pending forms, questions, and permissions until they are resolved", async () => { test("dedupes pending forms, questions, and permissions until they are resolved", async () => {

View file

@ -914,13 +914,23 @@ test("tracks global forms by location", async () => {
expect(data.session.form.list("global", { directory }) ?? []).toEqual([]) expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
events.emit({ events.emit({
id: "evt_form_replied_global", id: "evt_form_created_global_default",
created: 1, created: 1,
location: { directory },
type: "form.created",
data: { form: { id: "frm_global", sessionID: "global", mode: "form", fields: [] } },
})
await wait(() => data.session.form.list("global", { directory })?.length === 1)
events.emit({
id: "evt_form_replied_global",
created: 2,
location: other, location: other,
type: "form.replied", type: "form.replied",
data: { id: "frm_global", sessionID: "global", answer: {} }, data: { id: "frm_global", sessionID: "global", answer: {} },
}) })
await wait(() => data.session.form.list("global", other)?.length === 0) await wait(() => data.session.form.list("global", other)?.length === 0)
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_global"])
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }