diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index e0aaa385be..d765bdd870 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -667,6 +667,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) }, + async refreshChildren(sessionID: string) { + for (const session of mutable((await sdk.api.session.list({ parentID: sessionID, limit: 200 })).data)) { + setStore("session", "info", session.id, session) + registerSession(session.id) + } + }, message: { ids(sessionID: string) { return (store.session.message[sessionID] ?? []).map((message) => message.id) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 821e689305..633904be95 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -46,13 +46,13 @@ const tui: TuiPlugin = async (api) => { forms.delete(event.data.id) }) - api.event.on("permission.asked", (event) => { + api.event.on("permission.v2.asked", (event) => { if (permissions.has(event.data.id)) return permissions.add(event.data.id) notify(api, event.data.sessionID, "Permission needs input", "permission") }) - api.event.on("permission.replied", (event) => { + api.event.on("permission.v2.replied", (event) => { permissions.delete(event.data.requestID) }) @@ -79,9 +79,12 @@ const tui: TuiPlugin = async (api) => { api.event.on("session.next.step.started", (event) => started(event.data.sessionID)) api.event.on("session.next.retried", (event) => started(event.data.sessionID)) api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID)) - api.event.on("session.next.step.ended", (event) => { - if (event.data.finish === "tool-calls") return + api.event.on("session.next.execution.settled", (event) => { + if (event.data.outcome === "failure" && active.has(event.data.sessionID) && !errored.has(event.data.sessionID)) { + errored.add(event.data.sessionID) + notify(api, event.data.sessionID, "Session error", "error") + } + if (event.data.outcome === "interrupted") errored.add(event.data.sessionID) ended(event.data.sessionID) }) api.event.on("session.next.step.failed", (event) => { diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 068b51c732..35adc9d437 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -120,10 +120,19 @@ function stateApi(sync: ReturnType, data: ReturnType @@ -140,7 +149,15 @@ function stateApi(sync: ReturnType, data: ReturnType ({ + id: request.id, + sessionID: request.sessionID, + permission: request.action, + patterns: request.resources, + metadata: request.metadata ?? {}, + always: request.save ?? [], + tool: request.source?.type === "tool" ? { messageID: request.source.messageID, callID: request.source.callID } : undefined, + })) }, question(sessionID) { return sync.data.question[sessionID] ?? [] diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 982cfbc02d..7240765e0a 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -52,6 +52,14 @@ function validateText(field: Field, text: string): string | undefined { if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time" } +function validateSelection(field: Field, value: FormValue | undefined) { + if (field.type !== "multiselect" || value === undefined) return + if (!Array.isArray(value)) return "Expected selections" + if (field.required && value.length === 0) return "Select at least one option" + if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}` + if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}` +} + function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] { if (field.type === "boolean") return [ @@ -187,6 +195,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { }) const single = createMemo(() => { const list = fields() + if (props.form.fields.length !== 1) return false if (list.length !== 1) return false const field = list[0]! return field.type === "boolean" || (field.type === "string" && field.options !== undefined) @@ -199,7 +208,12 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { ) return width <= dimensions().width - 8 }) - const answered = createMemo(() => fields().filter((item) => store.answers[item.key] !== undefined).length) + const answered = createMemo(() => + fields().filter((item) => { + const value = store.answers[item.key] + return Array.isArray(value) ? value.length > 0 : value !== undefined + }).length, + ) const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)]) const confirm = createMemo(() => !single() && store.tab >= fields().length) const rows = createMemo(() => (field() ? fieldRows(field()!) : [])) @@ -285,7 +299,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { const index = list.indexOf(value) if (index === -1) list.push(value) if (index !== -1) list.splice(index, 1) - answer(current.key, list) + answer(current.key, list.length === 0 ? undefined : list) } function selectTab(index: number) { @@ -426,7 +440,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { if (prev) { const existing = store.answers[current.key] const list = Array.isArray(existing) ? existing.filter((item) => item !== prev) : [] - answer(current.key, list) + answer(current.key, list.length === 0 ? undefined : list) setStore("custom", { ...store.custom, [current.key]: "" }) } setStore("editing", false) @@ -521,6 +535,11 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: "Submit form", group: "Form", cmd: () => { + const invalid = fields().find((field) => validateSelection(field, store.answers[field.key])) + if (invalid) { + setStore("error", validateSelection(invalid, store.answers[invalid.key]) ?? "Invalid answer") + return + } sdk.api.form .reply({ sessionID: props.form.sessionID, @@ -824,14 +843,18 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { {(item) => { const value = () => display(item, store.answers[item.key]) - const answered = () => store.answers[item.key] !== undefined + const answered = () => { + const value = store.answers[item.key] + return Array.isArray(value) ? value.length > 0 : value !== undefined + } const missing = () => !answered() && item.required === true + const invalid = () => validateSelection(item, store.answers[item.key]) return ( {truncate(fieldLabel(item), 40)}:{" "} - - {answered() ? value() : missing() ? "(required)" : "(not answered)"} + + {invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2f69eff3dd..ffeae192b3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -182,9 +182,10 @@ export function Session() { ) }) const forms = createMemo(() => { - if (session()?.parentID) return [] return [ - ...[route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.form.list(sessionID) ?? []), + ...[route.sessionID, ...(session()?.parentID ? [] : descendantSessionIDs())].flatMap( + (sessionID) => data.session.form.list(sessionID) ?? [], + ), ...(data.session.form.list("global") ?? []), ] }) @@ -251,12 +252,7 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - await Promise.all([ - data.session.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - data.session.form.refresh("global"), - ]) + await data.session.refresh(sessionID) const info = data.session.get(sessionID) if (!info) { toast.show({ @@ -267,6 +263,12 @@ export function Session() { navigate({ type: "home" }) return } + if (!info.parentID) await data.session.refreshChildren(sessionID) + await Promise.all([ + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + data.session.form.refresh("global"), + ]) project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) @@ -944,7 +946,6 @@ export function Session() { onClose={() => setComposer("open", false)} /> - {null} 0}> @@ -953,6 +954,7 @@ export function Session() { {(form) => } + {null} { const harness = await setup() harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1") } }) - harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") }) + harness.emit({ id: "event-2", type: "permission.v2.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([formNotification, permissionNotification]) }) @@ -165,14 +161,14 @@ describe("internal notifications TUI plugin", () => { }) harness.emit({ id: "event-4", type: "form.created", data: { form: form("form-1") } }) - harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") }) - harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") }) + harness.emit({ id: "event-5", type: "permission.v2.asked", data: permission("permission-1") }) + harness.emit({ id: "event-6", type: "permission.v2.asked", data: permission("permission-1") }) harness.emit({ id: "event-7", - type: "permission.replied", + type: "permission.v2.replied", data: { sessionID: "session", requestID: "permission-1", reply: "once" }, }) - harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") }) + harness.emit({ id: "event-8", type: "permission.v2.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([ formNotification, @@ -185,9 +181,9 @@ describe("internal notifications TUI plugin", () => { test("notifies when an active session becomes idle and suppresses no-op idle", async () => { const harness = await setup() - harness.emit(stepEnded("event-1")) + harness.emit(executionSettled("event-1")) harness.emit(stepStarted("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionSettled("event-3")) expect(harness.notifications).toEqual([ { @@ -204,7 +200,7 @@ describe("internal notifications TUI plugin", () => { harness.emit({ id: "event-1", type: "form.created", data: { form: form("form-1", "subagent") } }) harness.emit(stepStarted("event-2", "subagent")) - harness.emit(stepEnded("event-3", "subagent")) + harness.emit(executionSettled("event-3", "subagent")) expect(harness.notifications).toEqual([ { @@ -227,7 +223,7 @@ describe("internal notifications TUI plugin", () => { harness.emit(stepStarted("event-1")) harness.emit(stepFailed("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionSettled("event-3")) expect(harness.notifications).toEqual([ {