diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d23898ba63..d05fbe5535 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -308,7 +308,7 @@ "nonTriviaTokens": 92 }, "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 26, + "importDeclarations": 25, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -324,7 +324,7 @@ "createAppShellChatActions" ], "dependencyPaths": { - "../preload/bridge-contract.js": 2, + "../preload/bridge-contract.js": 1, "./app-shell-copy.js": 1, "./app-shell-session-ui-state.js": 1, "./attachment-preflight.js": 1, @@ -350,8 +350,8 @@ "@maka/runtime/skill-invocation": 1, "@maka/ui": 1 }, - "importSpecifiers": 39, - "nonTriviaTokens": 4089 + "importSpecifiers": 38, + "nonTriviaTokens": 4086 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "nonTriviaTokens": 15686 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index ad52d98382..3f40151bf2 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -118,6 +118,7 @@ export function createActionsDeps() { transcriptRangeRef: { current: undefined }, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, + respondToUserForm: async () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, newChatModel: null, diff --git a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts new file mode 100644 index 0000000000..2c25b9fab6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { InteractionQueues } from '@maka/ui'; +import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createActionsDeps } from './app-shell-chat-actions-fixture.js'; + +function pendingForm(): InteractionQueues { + return { + 'session-1': [{ + type: 'form_request', + id: 'event-1', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }], + }; +} + +describe('AppShell form interaction response', () => { + it('retires the local prompt only after the platform accepts the answer', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session-1'; + let interactions = pendingForm(); + let submitted: unknown; + const actions = createAppShellChatActions({ + ...deps, + respondToUserForm: async (sessionId, response) => { + submitted = { sessionId, response }; + }, + setInteractionBySession: (update) => { + interactions = update(interactions); + }, + }); + + const response = { requestId: 'form-1', action: 'accept' as const, values: { confirm: true } }; + await actions.respondToUserForm(response); + + assert.deepEqual(submitted, { sessionId: 'session-1', response }); + assert.deepEqual(interactions['session-1'], []); + }); + + it('keeps the prompt answerable when the platform rejects the answer', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session-1'; + let interactions = pendingForm(); + let errors = 0; + const actions = createAppShellChatActions({ + ...deps, + respondToUserForm: async () => { + throw new Error('Host unavailable'); + }, + setInteractionBySession: (update) => { + interactions = update(interactions); + }, + toastApi: { error: () => { errors += 1; }, info: () => undefined }, + }); + + await actions.respondToUserForm({ requestId: 'form-1', action: 'cancel' }); + + assert.equal(interactions['session-1']?.[0]?.requestId, 'form-1'); + assert.equal(errors, 1); + }); +}); diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 907c24243d..36d2f7b3ba 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -124,6 +124,7 @@ async function mountRegion(): Promise<{ respondToSandboxBoundary: () => {}, respondToClientCapability: () => {}, respondToUserQuestion: () => {}, + respondToUserForm: () => {}, stop: () => {}, onSend: () => {}, onStop: () => {}, diff --git a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts new file mode 100644 index 0000000000..c6ec68741e --- /dev/null +++ b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { it } from 'node:test'; +import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/testing.js'; + +it('keeps companion forms pending until the Host acknowledgement arrives', () => { + let queues = applyCompanionInteractionEvent({}, 'fork-1', { + type: 'form_request', + id: 'form-event', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + assert.equal(queues['fork-1']?.[0]?.requestId, 'form-1'); + + queues = applyCompanionInteractionEvent(queues, 'fork-1', { + type: 'form_answer_ack', + id: 'form-ack', + turnId: 'turn-1', + ts: 2, + requestId: 'form-1', + toolUseId: 'tool-1', + }); + assert.deepEqual(queues['fork-1'], []); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 1c71e36785..7082dbb81e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -188,6 +188,69 @@ test('answers a Client Capability approval through the existing Interaction auth await observer.close(); }); +test("validates and forwards Desktop form responses to the pending Host interaction", async () => { + const pending = { + schemaVersion: 1 as const, + interactionId: "form-1", + sessionId: "session-1", + turnId: "turn-1", + runId: "run-1", + revision: 1 as const, + status: "pending" as const, + outcome: null, + request: { + kind: "form" as const, + toolUseId: "tool-1", + message: "Configure deployment", + requester: { name: "deploy" }, + fields: [{ kind: "integer" as const, name: "replicas", label: "Replicas", required: true }], + }, + }; + const observer = observerWithSnapshot({ interactions: { pending: [pending] } }); + const answers: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc({ + observer, + client: executionClient({ + answerInteraction: async (input) => { + answers.push(input); + return { + ...pending, + revision: 2, + status: "answered", + outcome: { + kind: "form_answer", + action: "accept", + values: { replicas: 3 }, + committedAt: 2, + }, + }; + }, + }), + }, ipc); + + await ipc.invoke("sessions:respondToUserForm", "session-1", { + requestId: "form-1", + action: "accept", + values: { replicas: 3 }, + }); + assert.deepEqual(answers, [{ + sessionId: "session-1", + interactionId: "form-1", + answer: { kind: "form", action: "accept", values: { replicas: 3 } }, + }]); + + await assert.rejects( + () => ipc.invoke("sessions:respondToUserForm", "session-1", { + requestId: "form-1", + action: "accept", + values: { replicas: Number.NaN }, + }), + ); + assert.equal(answers.length, 1); + await observer.close(); +}); + test("retries committed Branch and Revision copies with the renderer-owned identity", async () => { const committed = new Map(); const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 1b713542e9..ea76a37f39 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2672,6 +2672,57 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as await observer.close(); }); +test("publishes form answer acknowledgements for renderer queue retirement", async () => { + const pending = { + schemaVersion: 1 as const, + interactionId: "form-1", + sessionId: "session-1", + turnId: "turn-1", + runId: "run-1", + revision: 1 as const, + status: "pending" as const, + outcome: null, + request: { + kind: "form" as const, + toolUseId: "tool-1", + message: "Configure deployment", + requester: { name: "deploy" }, + fields: [{ kind: "boolean" as const, name: "confirm", label: "Confirm", required: true }], + }, + }; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot({ interactions: { pending: [pending] } }), + activeAssistantStreams: [], + transcript: Promise.resolve([]), + events: new AsyncFrameQueue(), + async close() {}, + }), + }, + emitSessionsChanged() {}, + now: () => 80, + }); + const target = eventTarget(2); + await observer.observe("session-1", "observer-1", target); + observer.publishInteractionAnswer({ + ...pending, + revision: 2, + status: "answered", + outcome: { kind: "form_answer", action: "accept", values: { confirm: true }, committedAt: 80 }, + }, pending); + + assert.deepEqual(target.events.at(-1), { + type: "form_answer_ack", + id: "host-interaction:form-1:2", + turnId: "turn-1", + ts: 80, + requestId: "form-1", + toolUseId: "tool-1", + }); + await observer.close(); +}); + test("projects Host queue revisions and newly delivered steering messages", async () => { const events = new AsyncFrameQueue(); const observer = new RuntimeHostSessionObserver({ diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index df1ac3b1c6..16e8f1de69 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -655,6 +655,48 @@ describe('single live-turn handoff', () => { assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'request-1'); }); + it('queues and retires a form at the Host answer acknowledgement', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const ref = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef: ref, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: liveTurns.set, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + }); + handlers.handleEvent('session-1', { + type: 'form_request', + id: 'form-event', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'form-1'); + + handlers.handleEvent('session-1', { + type: 'form_answer_ack', + id: 'form-ack', + turnId: 'turn-1', + ts: 2, + requestId: 'form-1', + toolUseId: 'tool-1', + }); + assert.deepEqual(interactions.get()['session-1'], []); + assert.equal(liveTurns.get()['session-1']?.terminal, undefined); + }); + it('hands an aborted projection over only after persisted messages cover it', async () => { const liveTurns = createStateSetter>({ 'session-1': { diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 75fa733055..1b141ffa3c 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -182,6 +182,7 @@ describe('createDesktopWorkbarServices', () => { await services.sideChat.respondToSandboxBoundary('fork', {} as never); await services.sideChat.respondToClientCapability('fork', {} as never); await services.sideChat.respondToUserQuestion('fork', {} as never); + await services.sideChat.respondToUserForm('fork', {} as never); services.sideChat.subscribeEvents('fork', eventHandler)(); assert.deepEqual( @@ -237,6 +238,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.respondToSandboxBoundary', 'sessions.respondToClientCapability', 'sessions.respondToUserQuestion', + 'sessions.respondToUserForm', 'sessions.subscribeEvents', ], ); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index acd6dab4aa..7cf76169bd 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -31,6 +31,7 @@ import { } from '@maka/core/session'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; import { type PermissionMode } from '@maka/core/permission'; +import { decodeInteractionFormResponse } from '@maka/core/interaction'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { AttachmentApprovalRegistry } from "./attachment-approval.js"; import { @@ -663,6 +664,28 @@ export function registerRuntimeHostSessionExecutionIpc( deps.observer.publishInteractionAnswer(answered, pending); }, ); + ipcMain.handle( + "sessions:respondToUserForm", + async (_event, sessionId: string, input: unknown) => { + const response = decodeInteractionFormResponse(input); + const pending = await requireInteraction( + deps.observer, + sessionId, + response.requestId, + ); + if (pending.request.kind !== "form") { + throw new Error("Interaction is not a form request"); + } + const answered = await deps.client.answerInteraction({ + sessionId, + interactionId: response.requestId, + answer: response.action === "accept" + ? { kind: "form", action: "accept", values: response.values } + : { kind: "form", action: response.action }, + }); + deps.observer.publishInteractionAnswer(answered, pending); + }, + ); ipcMain.handle("sessions:compact", async (_event, sessionId: string) => { const turnId = newId(); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index acf5e12377..08121b2954 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -554,6 +554,11 @@ export class RuntimeHostSessionObserver { type: "user_question_answer_ack", ...base, }); + } else if (answered.outcome.kind === "form_answer") { + this.#broadcast(answered.sessionId, { + type: "form_answer_ack", + ...base, + }); } else if (answered.outcome.kind === "sandbox_boundary_decision") { this.#broadcast(answered.sessionId, { type: "sandbox_boundary_decision_ack", diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d68e5c75c2..fe84b755f4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -53,6 +53,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; @@ -1218,6 +1219,7 @@ export interface MakaBridge { response: ClientCapabilityResponse, ): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; saveConversationToFile(input: { markdown: string; defaultName: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f5cbd6c44d..ce26162376 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -136,6 +136,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -2138,6 +2139,9 @@ const makaBridge = { respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response); }, + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise { + return invokeSessionRuntimeHost('sessions:respondToUserForm', sessionId, response); + }, /** * PR-CMD-PALETTE-SAVE-CONVERSATION-FILE-0: write the renderer-formatted * conversation markdown to a user-chosen file. Renderer owns the diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 7a877ebf25..6bf0d678de 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -19,7 +19,7 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { CollaborationMode } from '@maka/core/collaboration'; -import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; +import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -28,7 +28,6 @@ import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { @@ -92,6 +91,11 @@ type PendingNewChatModel = { } | null; type PendingNewChatThinkingLevel = ThinkingLevel | null; +type DesktopNewTaskTarget = DesktopBridge.DesktopNewTaskTarget; +type DesktopSessionSummary = DesktopBridge.DesktopSessionSummary; +type InteractionFormResponse = Parameters< + DesktopBridge.MakaBridge['sessions']['respondToUserForm'] +>[1]; type ToastApi = { error( @@ -142,6 +146,7 @@ export interface AppShellChatActions { ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; + respondToUserForm(response: InteractionFormResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; retryMessages(sessionId: string): Promise; } @@ -179,6 +184,7 @@ export function createAppShellChatActions(deps: { onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; + respondToUserForm: DesktopBridge.MakaBridge['sessions']['respondToUserForm']; showModelSetupToast: ( description: string, reason?: string, @@ -224,6 +230,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession, onInteractionChanged, onExecutionBoundaryChanged, + respondToUserForm: submitUserForm, showModelSetupToast, toastApi, newChatModel, @@ -689,45 +696,20 @@ export function createAppShellChatActions(deps: { } } - async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { + async function respondToInteraction( + response: Response, + submit: (sessionId: string, response: Response) => Promise, + onApplied?: (sessionId: string) => void, + ) { const sessionId = activeIdRef.current; if (!sessionId) return; try { - await window.maka.sessions.respondToSandboxBoundary(sessionId, response); + await submit(sessionId, response); onInteractionChanged?.(sessionId); - // #1611: the answer has been applied to the authoritative boundary, so - // the permission label must stop describing the pre-decision one. The - // ack event covers decisions settled on other surfaces; this covers the - // one the user just made here, without waiting for the round trip. - onExecutionBoundaryChanged?.(sessionId); + onApplied?.(sessionId); setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId), ); - } catch (error) { - // Same fire-and-forget call site as stop(), wrap so a failed - // permission response (main process busy / session dropped) - // surfaces instead of dying as UnhandledPromiseRejection. - if (activeIdRef.current !== sessionId) return; - if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); - } else { - toastApi.error( - copy.responseFailedTitle, - localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), - undefined, - { sessionId }, - ); - } - } - } - - async function respondToUserQuestion(response: UserQuestionResponse) { - const sessionId = activeIdRef.current; - if (!sessionId) return; - try { - await window.maka.sessions.respondToUserQuestion(sessionId, response); - onInteractionChanged?.(sessionId); - setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId)); } catch (error) { if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { @@ -806,8 +788,15 @@ export function createAppShellChatActions(deps: { return { send, enqueueMessage, - respondToSandboxBoundary, - respondToUserQuestion, + respondToSandboxBoundary: (response) => + respondToInteraction( + response, + window.maka.sessions.respondToSandboxBoundary, + onExecutionBoundaryChanged, + ), + respondToUserQuestion: (response) => + respondToInteraction(response, window.maka.sessions.respondToUserQuestion), + respondToUserForm: (response) => respondToInteraction(response, submitUserForm), refreshMessages, retryMessages, }; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index cf3491ca7f..90335ba8c8 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -365,9 +365,7 @@ export function createAppShellSessionEventHandlers(options: { }); break; case 'message_admission': - if (event.outcome === 'retracted') { - removeTransientMessage?.(sessionId, event.messageId); - } + if (event.outcome === 'retracted') removeTransientMessage?.(sessionId, event.messageId); break; case 'steering_message': // The live Turn projection now renders this same messageId in place. @@ -379,9 +377,7 @@ export function createAppShellSessionEventHandlers(options: { const queue = current[sessionId]; if (!queue?.entries.some((entry) => entry.messageId === event.messageId)) return current; const entries = queue.entries.filter((entry) => entry.messageId !== event.messageId); - if (entries.length > 0) { - return { ...current, [sessionId]: { ...queue, entries } }; - } + if (entries.length > 0) return { ...current, [sessionId]: { ...queue, entries } }; const next = { ...current }; delete next[sessionId]; return next; @@ -393,6 +389,7 @@ export function createAppShellSessionEventHandlers(options: { case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': + case 'form_request': onInteractionChanged?.(sessionId); break; // The runtime drops its owner on this ack, not on the tool result that @@ -400,6 +397,7 @@ export function createAppShellSessionEventHandlers(options: { // same point its boundary sibling settles on, below. case 'user_question_answer_ack': case 'client_capability_decision_ack': + case 'form_answer_ack': onInteractionChanged?.(sessionId); break; case 'sandbox_boundary_decision_ack': @@ -446,9 +444,8 @@ export function createAppShellSessionEventHandlers(options: { case 'complete': { onInteractionChanged?.(sessionId); setInteractionBySession((current) => clearInteractions(current, sessionId)); - if (event.contextCompactionOutcome) { + if (event.contextCompactionOutcome) onContextCompactionOutcome?.(sessionId, event.turnId, event.contextCompactionOutcome); - } if (event.stopReason === 'end_turn' || event.stopReason === 'max_tokens') { const body = [...(before?.steps ?? [])].reverse().find((step) => step.text?.text)?.text?.text; notifyRunEnded?.({ kind: 'completed', sessionId, body }); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0a685bbab4..3f3a212dd7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -797,9 +797,8 @@ function AppShellContent({ const owner = sessions.find((session) => session.id === draft.draftSessionId); if (source && owner && !source.isArchived && !owner.isArchived) return; composerRef.current?.clearDraft(draft.draftSessionId); - if (draft.sourceSessionId !== draft.draftSessionId) { + if (draft.sourceSessionId !== draft.draftSessionId) composerRef.current?.clearDraft(draft.sourceSessionId); - } if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); else void abandonTurnRevisionCopyAttempt(draft); commitRevisionDraft(null); @@ -1019,12 +1018,7 @@ function AppShellContent({ // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - } else { - await window.maka.sessions.setCollaborationMode( - sessionId, - active ? 'plan' : 'agent', - ); - } + } else await window.maka.sessions.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); return true; } @@ -1142,11 +1136,8 @@ function AppShellContent({ composerRef.current?.appendText(shellCopy.useSkillPrompt(skillName)); composerRef.current?.focus(); }; - if (activeIdRef.current) { - window.requestAnimationFrame(seed); - return; - } - void createSession().then(() => window.requestAnimationFrame(seed)); + if (activeIdRef.current) window.requestAnimationFrame(seed); + else void createSession().then(() => window.requestAnimationFrame(seed)); }, [shellCopy], ); @@ -1789,6 +1780,7 @@ function AppShellContent({ enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, + respondToUserForm, refreshMessages, retryMessages, } = useStableActions(createAppShellChatActions, { @@ -1812,6 +1804,7 @@ function AppShellContent({ setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, + respondToUserForm: workbar.commands.respondToUserForm, showModelSetupToast, toastApi, newChatModel: newChatModel ?? null, @@ -1892,9 +1885,8 @@ function AppShellContent({ function settleNewTaskImageNoticeOwner(sourceSessionId?: string) { const createdSessionId = activeIdRef.current; - if (!sourceSessionId && createdSessionId) { + if (!sourceSessionId && createdSessionId) imageNoticeLifecycle.transfer(NEW_TASK_PENDING_KEY, createdSessionId); - } } async function enqueueFollowUp( @@ -2262,9 +2254,8 @@ function AppShellContent({ showModelSetupToast, toastApi, notifyRunEnded: ({ kind, sessionId, body }) => { - if (kind === 'completed' && activeIdRef.current === sessionId) { + if (kind === 'completed' && activeIdRef.current === sessionId) setPetCompletionNonce((current) => current + 1); - } const title = sessionsRef.current.find((session) => session.id === sessionId)?.name; // Best-effort: swallow any main-side failure so a missed banner // never surfaces as an unhandled promise rejection. @@ -2568,9 +2559,8 @@ function AppShellContent({ activeId, ); function handleTranscriptReadingAnchorChange(turnId?: string) { - if (activeId && activeUnavailableTranscriptRestore) { + if (activeId && activeUnavailableTranscriptRestore) sessionUiController.setTranscriptRestoreUnavailable(activeId, undefined); - } transcriptReadingPosition.captureAnchor({ sessionId: activeId, currentSessionId: activeIdRef.current, @@ -2588,9 +2578,7 @@ function AppShellContent({ try { if (target === 'earlier') { await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); - } else { - await controller.loadLatest(); - } + } else await controller.loadLatest(); } catch (error) { if ( activeIdRef.current !== sessionId || @@ -2929,6 +2917,7 @@ function AppShellContent({ respondToSandboxBoundary={respondToSandboxBoundary} respondToClientCapability={workbar.commands.respondToClientCapability} respondToUserQuestion={respondToUserQuestion} + respondToUserForm={respondToUserForm} stop={stop} directoryComposerProps={directoryComposerProps} directoryPickerEnabled={!!( diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 1a70a0e022..a2a2eb7f8a 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -25,6 +25,7 @@ import { Composer, type ComposerInteraction, ComposerGoalProjectionConsumer, + FormInteractionPrompt, SandboxBoundaryPrompt, UserQuestionPrompt, } from '@maka/ui'; @@ -64,7 +65,7 @@ interface BoundaryUnreadableNotice { /** * The composer region of the chat surface (issue #1043): the composer - * interaction slot (permission / user-question prompts) plus the always-mounted + * interaction slot (boundary / question / form prompts) plus the always-mounted * Composer itself. * * AppShell renders this as a stable sibling of the section switch, so it is @@ -108,6 +109,7 @@ interface ChatComposerRegionProps respondToSandboxBoundary: ComponentProps['onRespond']; respondToClientCapability: ComponentProps['onRespond']; respondToUserQuestion: ComponentProps['onRespond']; + respondToUserForm: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; /** @@ -186,6 +188,7 @@ export function ChatComposerRegion({ respondToSandboxBoundary, respondToClientCapability, respondToUserQuestion, + respondToUserForm, stop, boundaryUnreadableNotice, latestRequestUsageTokens, @@ -199,6 +202,7 @@ export function ChatComposerRegion({ const activeClientCapability = activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; + const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; const activeModelChoice = composerRest.activeModel ? composerRest.modelChoices?.find( (choice) => @@ -316,6 +320,12 @@ export function ChatComposerRegion({ stopPending={activeId ? stopPendingBySession[activeId] === true : false} /> )} + {activeForm && ( + + )} {(goalProjection) => ( diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 77a7fbad4f..59662743ab 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -28,6 +28,7 @@ import { } from 'react'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { QuoteRef } from '@maka/core/events'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SessionSummary } from '@maka/core/session'; import { Composer, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; @@ -76,6 +77,7 @@ export interface WorkbarControllerCommands { ): void; openSideChatWithQuote(quote: QuoteRef): void; respondToClientCapability(response: ClientCapabilityResponse): Promise; + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; toggleRight(): void; } @@ -655,8 +657,20 @@ export function useWorkbarController( ); const commands = useMemo( - () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }), - [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight], + () => ({ + openTool, + openSideChatWithQuote, + respondToClientCapability, + respondToUserForm: sideChat.respondToUserForm, + toggleRight, + }), + [ + openSideChatWithQuote, + openTool, + respondToClientCapability, + sideChat.respondToUserForm, + toggleRight, + ], ); const activeSideChatTabIds = useMemo( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index ca077e1a56..f95e7462ec 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -44,6 +44,7 @@ import type { import type { SessionTrace } from '@maka/core/session-trace'; import type { SessionTodoItem } from '@maka/core/session-todo'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { Result } from '@maka/core/result'; import type { ContextCompactResult, @@ -269,6 +270,10 @@ export interface SideChatSessionPort { sessionId: string, response: UserQuestionResponse, ): Promise; + respondToUserForm( + sessionId: string, + response: InteractionFormResponse, + ): Promise; subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 6604a4f050..f13752a75d 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -150,6 +150,7 @@ export function createFakeWorkbarServices( respondToSandboxBoundary: async () => undefined, respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, + respondToUserForm: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); return noopSubscription(); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 734279c28a..4ea839941e 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -454,7 +454,7 @@ export function companionRunEventEffect( /** * Route a companion event into its interaction queue, mirroring the main shell: - * boundary / question requests enqueue, their acks / tool results dequeue, and + * boundary / question / form requests enqueue, their acks / tool results dequeue, and * a terminal event clears the queue. */ export function applyCompanionInteractionEvent( @@ -466,9 +466,12 @@ export function applyCompanionInteractionEvent( case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': + case 'form_request': return enqueueInteraction(queues, sessionId, event); case 'sandbox_boundary_decision_ack': case 'client_capability_decision_ack': + case 'user_question_answer_ack': + case 'form_answer_ack': return dequeueInteractionByRequestId(queues, sessionId, event.requestId); case 'tool_result': return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 4ca276f5ed..fe04f827d9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -25,6 +25,7 @@ import { Composer, ClientCapabilityPrompt, finalAssistantReplyText, + FormInteractionPrompt, SandboxBoundaryPrompt, UserQuestionPrompt, useToast, @@ -237,7 +238,8 @@ export function QuoteCompanionPanel(props: { const activeInteraction = companion.activeSandboxBoundary ?? companion.activeClientCapability ?? - companion.activeQuestion; + companion.activeQuestion ?? + companion.activeForm; const deriveTurnPresentation = useCallback< NonNullable['deriveTurnPresentation']> >( @@ -275,7 +277,8 @@ export function QuoteCompanionPanel(props: { )} {(companion.activeSandboxBoundary || companion.activeClientCapability || - companion.activeQuestion) && ( + companion.activeQuestion || + companion.activeForm) && (
{companion.activeSandboxBoundary && ( void companion.stop()} /> )} + {companion.activeForm && ( + + )}
)} Promise; /** Returns whether the send was accepted; false leaves the draft + staged @@ -186,6 +189,7 @@ export interface UseQuoteCompanionResult { respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise; respondToClientCapability: (response: ClientCapabilityResponse) => Promise; respondToUserQuestion: (response: UserQuestionResponse) => Promise; + respondToUserForm: (response: InteractionFormResponse) => Promise; } /** The last streamed assistant message id of a turn — the settlement anchor. */ @@ -1197,6 +1201,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [mountedRef, sideChat], ); + const respondToUserForm = useCallback( + async (response: InteractionFormResponse): Promise => { + const id = companionIdRef.current; + if (!mountedRef.current || !id) return; + try { + await sideChat.respondToUserForm(id, response); + } catch { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + } + }, + [mountedRef, sideChat], + ); + // Only the companion's own turns render; the forked parent history stays as // hidden model context. const messages = allMessages.filter( @@ -1226,6 +1243,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; + const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; return { companionSession: companion, @@ -1242,6 +1260,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeSandboxBoundary, activeClientCapability, activeQuestion, + activeForm, compact, send, steer, @@ -1251,5 +1270,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan respondToSandboxBoundary, respondToClientCapability, respondToUserQuestion, + respondToUserForm, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 23480b7124..9e6ddbf21f 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -148,6 +148,8 @@ export function createDesktopWorkbarServices( bridge.sessions.respondToClientCapability(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), + respondToUserForm: (sessionId, response) => + bridge.sessions.respondToUserForm(sessionId, response), subscribeEvents: (sessionId, handler, onSeeded, onSeedError) => bridge.sessions.subscribeEvents(sessionId, handler, onSeeded, undefined, onSeedError), subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler), diff --git a/apps/desktop/src/renderer/styles/interaction-prompts.css b/apps/desktop/src/renderer/styles/interaction-prompts.css index 4b5a21d8ef..31b0e3b335 100644 --- a/apps/desktop/src/renderer/styles/interaction-prompts.css +++ b/apps/desktop/src/renderer/styles/interaction-prompts.css @@ -129,6 +129,71 @@ grid-column: 4; } +.maka-form-interaction-header p, +.maka-form-interaction-field p { + margin: 0; +} + +.maka-form-interaction-header p { + font: var(--maka-text-supporting); + color: var(--text-secondary); +} + +.maka-form-interaction-fields { + display: grid; + gap: var(--space-2); + max-height: min(420px, 50vh); + padding-right: var(--space-1); + overflow-y: auto; +} + +.maka-form-interaction-field { + display: grid; + gap: var(--space-1); + padding: var(--space-2); + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-control); +} + +.maka-form-interaction-field-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + color: var(--text-primary); +} + +.maka-form-interaction-field-heading > :first-child { + font: var(--maka-text-body-medium); +} + +.maka-form-interaction-field-heading > :last-child, +.maka-form-interaction-field-description { + font: var(--maka-text-supporting); + color: var(--text-secondary); +} + +.maka-form-interaction-checkboxes { + display: grid; + gap: var(--space-1); +} + +.maka-form-interaction-error { + font: var(--maka-text-supporting); + color: var(--destructive-text); +} + +.maka-form-interaction-actions, +.maka-form-interaction-primary-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.maka-form-interaction-actions { + justify-content: space-between; +} + .maka-interaction-header { min-width: 0; } @@ -175,4 +240,13 @@ .maka-question-submit { grid-column: auto; } + + .maka-form-interaction-actions, + .maka-form-interaction-primary-actions { + width: 100%; + } + + .maka-form-interaction-primary-actions { + justify-content: flex-end; + } } diff --git a/apps/desktop/stories/form-interaction.stories.tsx b/apps/desktop/stories/form-interaction.stories.tsx new file mode 100644 index 0000000000..9456211636 --- /dev/null +++ b/apps/desktop/stories/form-interaction.stories.tsx @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { FormRequestEvent } from '@maka/core/events'; +import { FormInteractionPrompt } from '@maka/ui'; + +// Fidelity convention (#1433): every story below names the real app path +// that reaches it. See apps/desktop/stories/FIDELITY.md. +const meta = { + title: 'Product/Form Interaction', + component: FormInteractionPrompt, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+
+
+ +
+
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const REQUEST: FormRequestEvent = { + type: 'form_request', + id: 'form-event', + ts: Date.now(), + turnId: 'form-turn', + requestId: 'form-request', + toolUseId: 'form-tool', + message: '配置生产环境发布', + requester: { name: 'create_release', source: 'Acme Deploy MCP' }, + fields: [ + { + kind: 'string', + name: 'version', + label: '版本号', + description: '将显示在发布记录和通知中。', + required: true, + default: 'v2.4.0', + minLength: 2, + }, + { + kind: 'integer', + name: 'replicas', + label: '实例数量', + required: true, + default: 3, + minimum: 1, + maximum: 20, + }, + { + kind: 'single_select', + name: 'channel', + label: '发布通道', + required: true, + default: 'stable', + options: [ + { value: 'stable', label: 'Stable' }, + { value: 'canary', label: 'Canary' }, + ], + }, + { + kind: 'boolean', + name: 'notify', + label: '发送发布通知', + description: '可选;启用后通知项目成员。', + required: false, + }, + ], +}; + +// Real path: a running tool requests structured input → Runtime Host parks the +// exact invocation → Desktop replaces the composer with this form. Accept, +// Decline, and Cancel all answer that same Host-owned interaction. +export const PendingDeploymentForm: Story = { + args: { request: REQUEST, onRespond: () => {} }, +}; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 43d14bb21b..71329da3c1 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -929,6 +929,7 @@ function bridge(options: { respondToSandboxBoundary: async () => undefined, respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, + respondToUserForm: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); return unsubscribe(); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 2c1d211cb1..f63f82f804 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 245 files — blocker 0, reimplementation 0, polish 1, aligned 244. +**Totals:** 246 files — blocker 0, reimplementation 0, polish 1, aligned 245. ## Exclusions (explicit) @@ -227,6 +227,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned | | `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Skeleton, StackItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned | | `packages/ui/src/directory-reference-chip.tsx` | ui-composition | Token, Tooltip | aligned — uses Astryx (Token, Tooltip) | aligned | +| `packages/ui/src/form-interaction-prompt.tsx` | ui-composition | Button, CheckboxInput, RadioList, RadioListItem, TextInput | aligned — uses Astryx (Button, CheckboxInput, RadioList, RadioListItem, TextInput) | aligned | | `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/inline-reference.tsx` | ui-composition | ChatTokenizedText | aligned — uses Astryx (ChatTokenizedText) | aligned | | `packages/ui/src/inline-rename-input.tsx` | ui-composition | TextInput | aligned — uses Astryx (TextInput) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 40063d9469..de1163f504 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -198,6 +198,7 @@ packages/ui/src/composer-message-queue.tsx packages/ui/src/composer.tsx packages/ui/src/daily-review-panel.tsx packages/ui/src/directory-reference-chip.tsx +packages/ui/src/form-interaction-prompt.tsx packages/ui/src/icons.tsx packages/ui/src/inline-reference.tsx packages/ui/src/inline-rename-input.tsx diff --git a/packages/ui/src/__tests__/form-interaction-prompt.test.tsx b/packages/ui/src/__tests__/form-interaction-prompt.test.tsx new file mode 100644 index 0000000000..37b6379298 --- /dev/null +++ b/packages/ui/src/__tests__/form-interaction-prompt.test.tsx @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { FormRequestEvent } from '@maka/core/events'; +import { FormInteractionPrompt } from '../form-interaction-prompt.js'; +import { LocaleProvider } from '../locale-context.js'; + +const request: FormRequestEvent = { + type: 'form_request', + id: 'event-1', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure release', + requester: { name: 'release' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true }, + { kind: 'integer', name: 'replicas', label: 'Replicas', required: true, minimum: 997, maximum: 997 }, + { kind: 'string', name: 'when', label: 'When', required: true, format: 'date-time' }, + { kind: 'string', name: 'notes', label: 'Notes', required: false }, + ], +}; + +test('same-request recovery preserves drafts and renders accessible constraints', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const render = async (next: FormRequestEvent) => { + await act(() => root.render( + + undefined} /> + , + )); + }; + + try { + await render(request); + const includeNotes = container.querySelector('input[type="checkbox"]'); + assert.ok(includeNotes); + assert.equal(includeNotes.checked, false); + await act(() => { + includeNotes.checked = true; + includeNotes.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + await render({ + ...request, + fields: request.fields.map((field) => ({ + ...field, + ...(field.kind === 'single_select' || field.kind === 'multi_select' + ? { options: field.options.map((option) => ({ ...option })) } + : {}), + })), + }); + + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, true); + assert.match(container.textContent ?? '', /997/); + assert.match(container.textContent ?? '', /date-time/); + for (const field of container.querySelectorAll('[aria-describedby]')) { + const describedBy = field.getAttribute('aria-describedby'); + if (describedBy) assert.ok(document.getElementById(describedBy)); + } + + await render({ ...request, requestId: 'form-2' }); + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, false); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts index 6d2090c595..b6a21cc4fe 100644 --- a/packages/ui/src/__tests__/interaction-queue.test.ts +++ b/packages/ui/src/__tests__/interaction-queue.test.ts @@ -163,9 +163,22 @@ describe('composer interaction queue', () => { assert.equal(activeInteractionFor(reconciled, 's')?.requestId, 'missed'); }); - test('does not hide the composer for a form until the form surface is installed', () => { - const queues = reconcileInteractions({}, 's', [form('form-1')]); + test('form requests enter and leave the shared queue', () => { + let queues = reduceInteractionQueues({}, 's', form('form-1')); + assert.equal(activeInteractionFor(queues, 's')?.type, 'form_request'); + queues = reduceInteractionQueues(queues, 's', { + type: 'form_answer_ack', + id: 'evt_form_ack', + turnId: 'turn_1', + ts: 1, + requestId: 'form-1', + toolUseId: 'call_form-1', + }); assert.equal(activeInteractionFor(queues, 's'), undefined); + + queues = reconcileInteractions({}, 's', [form('rehydrated-form')]); + + assert.equal(activeInteractionFor(queues, 's')?.requestId, 'rehydrated-form'); }); }); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index e9b6f6dc91..4fd5f49acc 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -237,6 +237,26 @@ export interface ConversationCopy { submit: string; next: string; }; + forms: { + requester: (name: string) => string; + requesterWithSource: (name: string, source: string) => string; + required: string; + optional: string; + include: (label: string) => string; + enabled: (label: string) => string; + enterValue: string; + enterNumber: string; + constraintSeparator: string; + lengthConstraint: (minimum: number | undefined, maximum: number | undefined) => string; + numberConstraint: (minimum: number | undefined, maximum: number | undefined) => string; + itemConstraint: (minimum: number | undefined, maximum: number | undefined) => string; + formatConstraint: Record<'email' | 'uri' | 'date' | 'date-time', string>; + invalid: string; + cancel: string; + decline: string; + accept: string; + submitting: string; + }; mentions: { noFiles: string; noSkills: string; @@ -518,6 +538,7 @@ const CONVERSATION_COPY = { allowSession: '本任务允许', }, questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' }, + forms: { requester: (name) => `由 ${name} 请求`, requesterWithSource: (name, source) => `由 ${name} 请求 · ${source}`, required: '必填', optional: '选填', include: (label) => `提供:${label}`, enabled: (label) => `启用:${label}`, enterValue: '输入内容', enterNumber: '输入数字', constraintSeparator: ';', lengthConstraint: (minimum, maximum) => minimum === undefined ? `最多 ${maximum} 个字符` : maximum === undefined ? `至少 ${minimum} 个字符` : `长度 ${minimum}–${maximum} 个字符`, numberConstraint: (minimum, maximum) => minimum === undefined ? `最大值 ${maximum}` : maximum === undefined ? `最小值 ${minimum}` : `范围 ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `最多选择 ${maximum} 项` : maximum === undefined ? `至少选择 ${minimum} 项` : `选择 ${minimum}–${maximum} 项`, formatConstraint: { email: '格式:email', uri: '格式:URI', date: '格式:date(YYYY-MM-DD)', 'date-time': '格式:date-time(RFC 3339)' }, invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' }, mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' }, workspace: { choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用', @@ -692,6 +713,7 @@ const CONVERSATION_COPY = { allowSession: 'Allow for this task', }, questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' }, + forms: { requester: (name) => `Requested by ${name}`, requesterWithSource: (name, source) => `Requested by ${name} · ${source}`, required: 'Required', optional: 'Optional', include: (label) => `Provide ${label}`, enabled: (label) => `Enable ${label}`, enterValue: 'Enter a value', enterNumber: 'Enter a number', constraintSeparator: ' · ', lengthConstraint: (minimum, maximum) => minimum === undefined ? `At most ${maximum} characters` : maximum === undefined ? `At least ${minimum} characters` : `${minimum}–${maximum} characters`, numberConstraint: (minimum, maximum) => minimum === undefined ? `Maximum ${maximum}` : maximum === undefined ? `Minimum ${minimum}` : `Range ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `Select at most ${maximum}` : maximum === undefined ? `Select at least ${minimum}` : `Select ${minimum}–${maximum}`, formatConstraint: { email: 'Format: email', uri: 'Format: URI', date: 'Format: date (YYYY-MM-DD)', 'date-time': 'Format: date-time (RFC 3339)' }, invalid: 'Provide a value that meets the requirements.', cancel: 'Cancel', decline: 'Decline', accept: 'Submit', submitting: 'Submitting…' }, mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' }, workspace: { choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable', diff --git a/packages/ui/src/form-interaction-prompt-state.test.ts b/packages/ui/src/form-interaction-prompt-state.test.ts new file mode 100644 index 0000000000..202a029196 --- /dev/null +++ b/packages/ui/src/form-interaction-prompt-state.test.ts @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { FormRequestEvent } from '@maka/core/events'; +import { + buildInteractionFormResponse, + createInteractionFormDrafts, + interactionFormFieldDraftIsValid, +} from './form-interaction-prompt-state.js'; + +const request: FormRequestEvent = { + id: 'event-form-1', + type: 'form_request', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Create the release', + requester: { name: 'release_form', source: 'Acme MCP' }, + fields: [ + { name: 'name', label: 'Name', kind: 'string', required: true, minLength: 2 }, + { name: 'retries', label: 'Retries', kind: 'integer', required: false, minimum: 0, default: 2 }, + { name: 'notify', label: 'Notify', kind: 'boolean', required: false }, + { name: 'channel', label: 'Channel', kind: 'single_select', required: true, options: [{ value: 'stable', label: 'Stable' }] }, + { name: 'owners', label: 'Owners', kind: 'multi_select', required: false, minItems: 1, options: [{ value: 'a', label: 'A' }] }, + ], +}; + +test('form drafts preserve defaults and distinguish omitted optional booleans from false', () => { + assert.deepEqual(createInteractionFormDrafts(request.fields), [ + { included: true, value: '' }, + { included: true, value: '2' }, + { included: false, value: false }, + { included: true, value: '' }, + { included: false, value: [] }, + ]); +}); + +test('accepted response parses numbers and omits excluded optional fields', () => { + const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft })); + drafts[0] = { included: true, value: 'v1' }; + drafts[3] = { included: true, value: 'stable' }; + assert.deepEqual(buildInteractionFormResponse(request, drafts), { + requestId: 'form-1', + action: 'accept', + values: { name: 'v1', retries: 2, channel: 'stable' }, + }); +}); + +test('number fields preserve and accept finite fractional values', () => { + const numberRequest: FormRequestEvent = { + ...request, + requestId: 'form-number', + fields: [ + { + name: 'threshold', + label: 'Threshold', + kind: 'number', + required: true, + default: 1.5, + }, + ], + }; + const drafts = createInteractionFormDrafts(numberRequest.fields); + assert.deepEqual(drafts, [{ included: true, value: '1.5' }]); + assert.deepEqual(buildInteractionFormResponse(numberRequest, drafts), { + requestId: 'form-number', + action: 'accept', + values: { threshold: 1.5 }, + }); +}); + +test('explicit optional false remains an accepted value', () => { + const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft })); + drafts[0] = { included: true, value: 'v1' }; + drafts[2] = { included: true, value: false }; + drafts[3] = { included: true, value: 'stable' }; + const response = buildInteractionFormResponse(request, drafts); + assert.equal(response?.action, 'accept'); + if (response?.action !== 'accept') assert.fail('expected an accepted response'); + assert.deepEqual(response.values.notify, false); +}); + +test('invalid integer, required value, and multi-select bounds block acceptance', () => { + const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft })); + drafts[0] = { included: true, value: 'v1' }; + drafts[1] = { included: true, value: '2.5' }; + drafts[3] = { included: true, value: 'stable' }; + assert.equal(interactionFormFieldDraftIsValid(request.fields[1]!, drafts[1]!), false); + assert.equal(buildInteractionFormResponse(request, drafts), null); + + drafts[1] = { included: true, value: '2' }; + drafts[4] = { included: true, value: [] }; + assert.equal(buildInteractionFormResponse(request, drafts), null); +}); + +test('protocol field names remain own data properties', () => { + const reservedNameRequest: FormRequestEvent = { + ...request, + fields: [{ name: '__proto__', label: 'Prototype', kind: 'string', required: true }], + }; + const response = buildInteractionFormResponse( + reservedNameRequest, + [{ included: true, value: 'unchanged' }], + ); + assert.equal(response?.action, 'accept'); + if (response?.action !== 'accept') assert.fail('expected an accepted response'); + assert.equal(Object.hasOwn(response.values, '__proto__'), true); + assert.equal(response.values.__proto__, 'unchanged'); +}); diff --git a/packages/ui/src/form-interaction-prompt-state.ts b/packages/ui/src/form-interaction-prompt-state.ts new file mode 100644 index 0000000000..376760932a --- /dev/null +++ b/packages/ui/src/form-interaction-prompt-state.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + isInteractionFormFieldValueValid, + type InteractionFormField, + type InteractionFormResponse, + type InteractionFormValue, +} from '@maka/core/interaction'; +import type { FormRequestEvent } from '@maka/core/events'; + +export interface InteractionFormFieldDraft { + /** Optional fields need an explicit presence bit so omitted and false/empty stay distinct. */ + readonly included: boolean; + readonly value: string | boolean | readonly string[]; +} + +export function createInteractionFormDrafts( + fields: readonly InteractionFormField[], +): InteractionFormFieldDraft[] { + return fields.map((field) => ({ + included: field.required || field.default !== undefined, + value: initialDraftValue(field), + })); +} + +export function interactionFormFieldDraftIsValid( + field: InteractionFormField, + draft: InteractionFormFieldDraft, +): boolean { + if (!draft.included) return !field.required; + const value = interactionFormDraftValue(field, draft); + return value !== undefined && isInteractionFormFieldValueValid(field, value); +} + +export function buildInteractionFormResponse( + request: FormRequestEvent, + drafts: readonly InteractionFormFieldDraft[], +): InteractionFormResponse | null { + const entries: Array<[string, InteractionFormValue]> = []; + for (const [index, field] of request.fields.entries()) { + const draft = drafts[index]; + if (!draft || !interactionFormFieldDraftIsValid(field, draft)) return null; + if (!draft.included) continue; + const value = interactionFormDraftValue(field, draft); + if (value === undefined) return null; + entries.push([field.name, value]); + } + return { requestId: request.requestId, action: 'accept', values: Object.fromEntries(entries) }; +} + +function initialDraftValue(field: InteractionFormField): InteractionFormFieldDraft['value'] { + if (field.default !== undefined) { + if (field.kind === 'number' || field.kind === 'integer') return String(field.default); + return field.default; + } + if (field.kind === 'boolean') return false; + if (field.kind === 'multi_select') return []; + return ''; +} + +function interactionFormDraftValue( + field: InteractionFormField, + draft: InteractionFormFieldDraft, +): InteractionFormValue | undefined { + if (field.kind === 'number' || field.kind === 'integer') { + if (typeof draft.value !== 'string' || draft.value.trim().length === 0) return undefined; + const value = Number(draft.value); + return Number.isFinite(value) ? value : undefined; + } + if (field.kind === 'boolean') return typeof draft.value === 'boolean' ? draft.value : undefined; + if (field.kind === 'multi_select') return Array.isArray(draft.value) ? draft.value : undefined; + return typeof draft.value === 'string' ? draft.value : undefined; +} diff --git a/packages/ui/src/form-interaction-prompt.tsx b/packages/ui/src/form-interaction-prompt.tsx new file mode 100644 index 0000000000..fcc2786b53 --- /dev/null +++ b/packages/ui/src/form-interaction-prompt.tsx @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useId, useRef, useState } from 'react'; +import type { FormRequestEvent } from '@maka/core/events'; +import type { InteractionFormField, InteractionFormResponse } from '@maka/core/interaction'; +import { Button, CheckboxInput, RadioList, RadioListItem, TextInput } from '@astryxdesign/core'; +import { getConversationCopy } from './conversation-copy.js'; +import { + buildInteractionFormResponse, + createInteractionFormDrafts, + interactionFormFieldDraftIsValid, + type InteractionFormFieldDraft, +} from './form-interaction-prompt-state.js'; +import { useUiLocale } from './locale-context.js'; +import { useMountedRef } from './use-mounted-ref.js'; + +export function FormInteractionPrompt(props: { + request: FormRequestEvent; + onRespond(response: InteractionFormResponse): void | Promise; +}) { + return ; +} + +function ActiveFormInteractionPrompt(props: { + request: FormRequestEvent; + onRespond(response: InteractionFormResponse): void | Promise; +}) { + const copy = getConversationCopy(useUiLocale()).forms; + const titleId = useId(); + const [drafts, setDrafts] = useState( + () => createInteractionFormDrafts(props.request.fields), + ); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [responsePending, setResponsePending] = useState(false); + const responsePendingRef = useRef(false); + const mountedRef = useMountedRef(); + + function updateDraft(index: number, next: InteractionFormFieldDraft) { + setDrafts((current) => current.map((draft, candidateIndex) => candidateIndex === index ? next : draft)); + } + + async function respond(response: InteractionFormResponse) { + if (responsePendingRef.current) return; + responsePendingRef.current = true; + setResponsePending(true); + try { + await props.onRespond(response); + } finally { + responsePendingRef.current = false; + if (mountedRef.current) setResponsePending(false); + } + } + + function accept() { + const response = buildInteractionFormResponse(props.request, drafts); + if (!response) { + setSubmitAttempted(true); + return; + } + void respond(response); + } + + const requester = props.request.requester.source + ? copy.requesterWithSource(props.request.requester.name, props.request.requester.source) + : copy.requester(props.request.requester.name); + + return ( +
+
+
+

{props.request.message}

+

{requester}

+
+ +
+ {props.request.fields.map((field, index) => { + const draft = drafts[index]; + if (!draft) return null; + const invalid = submitAttempted && !interactionFormFieldDraftIsValid(field, draft); + const constraint = formFieldConstraint(field, copy); + const constraintId = constraint ? `${titleId}-field-${index}-constraint` : undefined; + return ( +
+
+ {field.label} + {field.required ? copy.required : copy.optional} +
+ {field.description ?

{field.description}

: null} + {constraint ? ( +

+ {constraint} +

+ ) : null} + {!field.required ? ( + updateDraft(index, { ...draft, included })} + /> + ) : null} + {draft.included ? renderFormControl({ + field, + draft, + disabled: responsePending, + copy, + onChange: (value) => updateDraft(index, { ...draft, value }), + }) : null} + {invalid ?

{copy.invalid}

: null} +
+ ); + })} +
+ +
+
+
+
+
+
+
+
+ ); +} + +function formFieldConstraint( + field: InteractionFormField, + copy: ReturnType['forms'], +): string | undefined { + const parts: string[] = []; + if (field.kind === 'string') { + if (field.minLength !== undefined || field.maxLength !== undefined) { + parts.push(copy.lengthConstraint(field.minLength, field.maxLength)); + } + if (field.format !== undefined) parts.push(copy.formatConstraint[field.format]); + } else if (field.kind === 'number' || field.kind === 'integer') { + if (field.minimum !== undefined || field.maximum !== undefined) { + parts.push(copy.numberConstraint(field.minimum, field.maximum)); + } + } else if (field.kind === 'multi_select') { + if (field.minItems !== undefined || field.maxItems !== undefined) { + parts.push(copy.itemConstraint(field.minItems, field.maxItems)); + } + } + return parts.length === 0 ? undefined : parts.join(copy.constraintSeparator); +} + +function renderFormControl(props: { + field: InteractionFormField; + draft: InteractionFormFieldDraft; + disabled: boolean; + copy: ReturnType['forms']; + onChange(value: InteractionFormFieldDraft['value']): void; +}) { + const { field, draft } = props; + if (field.kind === 'boolean') { + return ( + + ); + } + if (field.kind === 'single_select') { + return ( + + {field.options.map((option) => ( + + ))} + + ); + } + if (field.kind === 'multi_select') { + const selected = Array.isArray(draft.value) ? draft.value : []; + return ( +
+ {field.options.map((option) => ( + props.onChange( + checked ? [...selected, option.value] : selected.filter((value) => value !== option.value), + )} + /> + ))} +
+ ); + } + return ( + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2ebac40575..9a31cf091e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -58,6 +58,8 @@ export * from './model-picker.js'; export * from './interaction-queue.js'; export * from './user-question-prompt.js'; export * from './user-question-prompt-state.js'; +export * from './form-interaction-prompt.js'; +export * from './form-interaction-prompt-state.js'; export * from './redact.js'; export * from './thinking-stream.js'; export * from './session-todo-panel.js'; diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts index 25c101ba55..9bae2872f6 100644 --- a/packages/ui/src/interaction-queue.ts +++ b/packages/ui/src/interaction-queue.ts @@ -20,6 +20,7 @@ import type { ActiveInteractionRequestEvent, ClientCapabilityRequestEvent, + FormRequestEvent, SandboxBoundaryRequestEvent, SessionEvent, UserQuestionRequestEvent, @@ -29,14 +30,16 @@ import type { export type ComposerInteraction = | SandboxBoundaryRequestEvent | ClientCapabilityRequestEvent - | UserQuestionRequestEvent; + | UserQuestionRequestEvent + | FormRequestEvent; export type InteractionQueues = Record; function isComposerInteraction(event: ActiveInteractionRequestEvent): event is ComposerInteraction { return ( event.type === 'sandbox_boundary_request' || event.type === 'client_capability_request' || - event.type === 'user_question_request' + event.type === 'user_question_request' || + event.type === 'form_request' ); } @@ -84,10 +87,12 @@ export function reduceInteractionQueues( case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': + case 'form_request': return enqueueInteraction(queues, sessionId, event); case 'sandbox_boundary_decision_ack': case 'client_capability_decision_ack': case 'user_question_answer_ack': + case 'form_answer_ack': return dequeueInteractionByRequestId(queues, sessionId, event.requestId); case 'tool_result': return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId);