You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/01 15:24:46 UTC

(maka) branch feat/4364-desktop-form-interactions updated (5bf55c9e59 -> addd05bd82)

This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a change to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git


    omit 5bf55c9e59 fix(desktop): retain form interaction in composer union
    omit 48c7e1ef80 chore(desktop): refresh renderer architecture ledger
    omit 6d3e00bb7b fix(desktop): preserve and explain form drafts
    omit 93317d0048 test(desktop): complete the Workbar form port fake
    omit 4c85458ff3 chore(ui): register the form interaction surface
    omit 7ae4c92bbe fix(desktop): keep form responses behind feature boundaries
    omit 783918297a feat(desktop): answer structured form interactions
     add a12633d225 fix(desktop): accept form interactions in composer region
     new 2aabb45e02 feat(desktop): answer structured form interactions
     new e9c13ee6f1 fix(desktop): keep form responses behind feature boundaries
     new 09a92444b5 chore(ui): register the form interaction surface
     new 804597c7a1 test(desktop): complete the Workbar form port fake
     new 69ed51c328 fix(desktop): preserve and explain form drafts
     new addd05bd82 chore(desktop): refresh renderer architecture ledger

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (5bf55c9e59)
            \
             N -- N -- N   refs/heads/feat/4364-desktop-form-interactions (addd05bd82)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 apps/desktop/src/renderer/chat-composer-region.tsx | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 02/06: fix(desktop): keep form responses behind feature boundaries

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit e9c13ee6f1606c9978f25599fc7553fab1fc3b5b
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 02:26:43 2026 +0800

    fix(desktop): keep form responses behind feature boundaries
    
    Route main-surface form answers through the existing Desktop platform adapter, expose test-only companion helpers from the Workbar testing entry, and keep legacy AppShell debt monotonic while sharing response settlement behavior.
    
    Add focused coverage for answer acceptance and failure retention.
    
    Part of #4364.
---
 apps/desktop/renderer-architecture.json            |  4 +-
 .../__tests__/app-shell-chat-actions-fixture.ts    |  1 +
 .../__tests__/app-shell-form-interaction.test.ts   | 86 ++++++++++++++++++++++
 .../chat-composer-region-draft-handoff.test.ts     |  1 -
 .../__tests__/quote-companion-interactions.test.ts |  2 +-
 .../desktop/src/renderer/app-shell-chat-actions.ts | 84 ++++++---------------
 .../src/renderer/app-shell-session-events.ts       |  7 +-
 apps/desktop/src/renderer/app-shell.tsx            |  3 +-
 apps/desktop/src/renderer/chat-composer-region.tsx |  6 +-
 .../workbar/controller/use-workbar-controller.ts   | 18 ++++-
 10 files changed, 136 insertions(+), 76 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index db13adebe6..5a317e9c20 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -342,7 +342,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,
@@ -360,7 +360,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,
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 26394dad8f..2763228463 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
@@ -123,7 +123,6 @@ async function mountRegion(): Promise<{
               respondToSandboxBoundary: () => {},
               respondToClientCapability: () => {},
               respondToUserQuestion: () => {},
-              activeForm: undefined,
               respondToUserForm: () => {},
               stop: () => {},
               onSend: () => {},
diff --git a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
index 5dfd987348..c6ec68741e 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
@@ -19,7 +19,7 @@
 
 import { strict as assert } from 'node:assert';
 import { it } from 'node:test';
-import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/tools/side-chat/quote-companion-core.js';
+import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/testing.js';
 
 it('keeps companion forms pending until the Host acknowledgement arrives', () => {
   let queues = applyCompanionInteractionEvent({}, 'fork-1', {
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 0222f61caa..24b1f6cbcd 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,9 +28,7 @@ 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 type { InteractionFormResponse } from '@maka/core/interaction';
 import { DEFAULT_SESSION_NAME } from '@maka/core/session-name';
 import {
   armLiveTurn,
@@ -93,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(
@@ -181,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,
@@ -226,6 +230,7 @@ export function createAppShellChatActions(deps: {
     setInteractionBySession,
     onInteractionChanged,
     onExecutionBoundaryChanged,
+    respondToUserForm: submitUserForm,
     showModelSetupToast,
     toastApi,
     newChatModel,
@@ -721,67 +726,20 @@ export function createAppShellChatActions(deps: {
     }
   }
 
-  async function respondToSandboxBoundary(response: SandboxBoundaryResponse) {
+  async function respondToInteraction<Response extends { requestId: string }>(
+    response: Response,
+    submit: (sessionId: string, response: Response) => Promise<void>,
+    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)) {
-        showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId });
-      } else {
-        toastApi.error(
-          copy.responseFailedTitle,
-          localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale),
-          undefined,
-          { sessionId },
-        );
-      }
-    }
-  }
-
-  async function respondToUserForm(response: InteractionFormResponse) {
-    const sessionId = activeIdRef.current;
-    if (!sessionId) return;
-    try {
-      await window.maka.sessions.respondToUserForm(sessionId, response);
-      onInteractionChanged?.(sessionId);
-      setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId));
     } catch (error) {
       if (activeIdRef.current !== sessionId) return;
       if (isSessionWorkspaceUnavailableError(error)) {
@@ -860,9 +818,15 @@ export function createAppShellChatActions(deps: {
   return {
     send,
     enqueueMessage,
-    respondToSandboxBoundary,
-    respondToUserQuestion,
-    respondToUserForm,
+    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 62c3d478c6..ba9721d77a 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.
@@ -448,9 +446,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 a6e1c3af6f..3a28016f5f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -826,7 +826,6 @@ function AppShellContent({
     [sessions, onboarding.snapshot?.sessionSendOutcomes],
   );
   const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
-  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
   const activeSession = activeCatalogSession;
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
@@ -1848,6 +1847,7 @@ function AppShellContent({
     setInteractionBySession: sessionUiController.setInteractionBySession,
     onInteractionChanged: markInteractionChanged,
     onExecutionBoundaryChanged: reloadActiveExecutionBoundary,
+    respondToUserForm: workbar.commands.respondToUserForm,
     showModelSetupToast,
     toastApi,
     newChatModel: newChatModel ?? null,
@@ -2970,7 +2970,6 @@ function AppShellContent({
                   respondToSandboxBoundary={respondToSandboxBoundary}
                   respondToClientCapability={workbar.commands.respondToClientCapability}
                   respondToUserQuestion={respondToUserQuestion}
-                  activeForm={activeForm}
                   respondToUserForm={respondToUserForm}
                   stop={stop}
                   directoryComposerProps={directoryComposerProps}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 1ab6c139ae..929bd7d17d 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -109,7 +109,6 @@ interface ChatComposerRegionProps
   respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
   respondToClientCapability: ComponentProps<typeof ClientCapabilityPrompt>['onRespond'];
   respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
-  activeForm: ComponentProps<typeof FormInteractionPrompt>['request'] | undefined;
   respondToUserForm: ComponentProps<typeof FormInteractionPrompt>['onRespond'];
   stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
   boundaryUnreadableNotice?: BoundaryUnreadableNotice;
@@ -132,7 +131,6 @@ export function ChatComposerRegion({
   respondToSandboxBoundary,
   respondToClientCapability,
   respondToUserQuestion,
-  activeForm,
   respondToUserForm,
   stop,
   boundaryUnreadableNotice,
@@ -145,7 +143,9 @@ export function ChatComposerRegion({
     activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
   const activeClientCapability =
     activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
-  const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeQuestion =
+    activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
   const previousNewTaskDraftKey = useRef(newTaskDraftKey);
   useLayoutEffect(() => {
     const previous = previousNewTaskDraftKey.current;
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 688248f0a2..e31146d0d8 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<void>;
+  respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void>;
   toggleRight(): void;
 }
 
@@ -663,8 +665,20 @@ export function useWorkbarController(
   );
 
   const commands = useMemo<WorkbarControllerCommands>(
-    () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }),
-    [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight],
+    () => ({
+      openTool,
+      openSideChatWithQuote,
+      respondToClientCapability,
+      respondToUserForm: sideChat.respondToUserForm,
+      toggleRight,
+    }),
+    [
+      openSideChatWithQuote,
+      openTool,
+      respondToClientCapability,
+      sideChat.respondToUserForm,
+      toggleRight,
+    ],
   );
 
   const activeSideChatTabIds = useMemo(


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 01/06: feat(desktop): answer structured form interactions

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 2aabb45e023411a4a45fea22df2f77be48b61839
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 00:04:04 2026 +0800

    feat(desktop): answer structured form interactions
    
    Render the Host-owned primitive form contract in the shared composer slot for both the main conversation and Side Chat. Preserve optional omission separately from explicit values, validate through the shared core contract, and expose cancel, decline, and accept without introducing surface-owned continuation state.
    
    Decode renderer responses at the Desktop IPC boundary, settle them through Runtime Host, and retire prompts only after the authoritative answer succeeds or its acknowledgement is projected. Cover all six field kinds, malformed responses, reconnect hydration, and both Desktop surfaces.
    
    Part of #4364.
    
    Generated-by: OpenAI Codex
---
 .../chat-composer-region-draft-handoff.test.ts     |   2 +
 .../__tests__/quote-companion-interactions.test.ts |  47 +++++
 ...runtime-host-session-execution-ipc-main.test.ts |  63 ++++++
 .../runtime-host-session-observer.test.ts          |  51 +++++
 .../src/main/__tests__/streaming-handoff.test.ts   |  42 ++++
 .../__tests__/workbar-services-adapter.test.ts     |   2 +
 .../runtime-host-session-execution-ipc-main.ts     |  23 +++
 .../src/main/runtime-host-session-observer.ts      |   5 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |   4 +
 .../desktop/src/renderer/app-shell-chat-actions.ts |  25 +++
 .../src/renderer/app-shell-session-events.ts       |   2 +
 apps/desktop/src/renderer/app-shell.tsx            |   4 +
 apps/desktop/src/renderer/chat-composer-region.tsx |  13 +-
 .../desktop/src/renderer/features/workbar/ports.ts |   5 +
 .../src/renderer/features/workbar/testing.ts       |   1 +
 .../tools/side-chat/quote-companion-core.ts        |   5 +-
 .../tools/side-chat/quote-companion-panel.tsx      |  13 +-
 .../workbar/tools/side-chat/use-quote-companion.ts |  22 +-
 .../platform/desktop/create-workbar-services.ts    |   2 +
 .../src/renderer/styles/interaction-prompts.css    |  74 +++++++
 apps/desktop/stories/form-interaction.stories.tsx  | 103 ++++++++++
 packages/ui/src/conversation-copy.ts               |  17 ++
 .../ui/src/form-interaction-prompt-state.test.ts   | 128 ++++++++++++
 packages/ui/src/form-interaction-prompt-state.ts   |  90 +++++++++
 packages/ui/src/form-interaction-prompt.tsx        | 225 +++++++++++++++++++++
 packages/ui/src/index.ts                           |   2 +
 27 files changed, 967 insertions(+), 5 deletions(-)

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 a6f0302ac7..26394dad8f 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
@@ -123,6 +123,8 @@ async function mountRegion(): Promise<{
               respondToSandboxBoundary: () => {},
               respondToClientCapability: () => {},
               respondToUserQuestion: () => {},
+              activeForm: undefined,
+              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..5dfd987348
--- /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/tools/side-chat/quote-companion-core.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 c5b865ed70..76a2a80bf1 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<string, SessionCatalogProjection>();
   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 b4f7c03a9c..275ef8aeb9 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
@@ -2497,6 +2497,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<Record<string, LiveTurnProjection>>({
+      'session-1': armLiveTurn('turn-1'),
+    });
+    const ref = { current: liveTurns.get() };
+    const interactions = createStateSetter<InteractionQueues>({});
+    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<Record<string, LiveTurnProjection>>({
       '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 43d806fa21..641668eca7 100644
--- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
@@ -181,6 +181,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(
@@ -235,6 +236,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 5699b58242..3bb468062e 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 {
@@ -656,6 +657,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 95f2c2a3bf..c69877f7b7 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -52,6 +52,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';
@@ -1182,6 +1183,7 @@ export interface MakaBridge {
       response: ClientCapabilityResponse,
     ): Promise<void>;
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void>;
+    respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void>;
     saveConversationToFile(input: {
       markdown: string;
       defaultName: string;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 226ce44a52..b587a80463 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -134,6 +134,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';
@@ -2075,6 +2076,9 @@ const makaBridge = {
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
       return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response);
     },
+    respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void> {
+      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 275740b9c4..0222f61caa 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -30,6 +30,7 @@ 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 type { InteractionFormResponse } from '@maka/core/interaction';
 import { DEFAULT_SESSION_NAME } from '@maka/core/session-name';
 import {
   armLiveTurn,
@@ -142,6 +143,7 @@ export interface AppShellChatActions {
   ): Promise<boolean>;
   respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void>;
   respondToUserQuestion(response: UserQuestionResponse): Promise<void>;
+  respondToUserForm(response: InteractionFormResponse): Promise<void>;
   refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise<boolean>;
   retryMessages(sessionId: string): Promise<void>;
 }
@@ -773,6 +775,28 @@ export function createAppShellChatActions(deps: {
     }
   }
 
+  async function respondToUserForm(response: InteractionFormResponse) {
+    const sessionId = activeIdRef.current;
+    if (!sessionId) return;
+    try {
+      await window.maka.sessions.respondToUserForm(sessionId, response);
+      onInteractionChanged?.(sessionId);
+      setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId));
+    } catch (error) {
+      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 refreshMessages(sessionId: string, options: RefreshMessagesOptions = {}): Promise<boolean> {
     try {
       if (activeIdRef.current !== sessionId) return false;
@@ -838,6 +862,7 @@ export function createAppShellChatActions(deps: {
     enqueueMessage,
     respondToSandboxBoundary,
     respondToUserQuestion,
+    respondToUserForm,
     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..62c3d478c6 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -393,6 +393,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 +401,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':
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index c882b04f64..a6e1c3af6f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -826,6 +826,7 @@ function AppShellContent({
     [sessions, onboarding.snapshot?.sessionSendOutcomes],
   );
   const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
+  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
   const activeSession = activeCatalogSession;
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
@@ -1823,6 +1824,7 @@ function AppShellContent({
     enqueueMessage,
     respondToSandboxBoundary,
     respondToUserQuestion,
+    respondToUserForm,
     refreshMessages,
     retryMessages,
   } = useStableActions(createAppShellChatActions, {
@@ -2968,6 +2970,8 @@ function AppShellContent({
                   respondToSandboxBoundary={respondToSandboxBoundary}
                   respondToClientCapability={workbar.commands.respondToClientCapability}
                   respondToUserQuestion={respondToUserQuestion}
+                  activeForm={activeForm}
+                  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 c3ffcbd76e..1ab6c139ae 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,8 @@ interface ChatComposerRegionProps
   respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
   respondToClientCapability: ComponentProps<typeof ClientCapabilityPrompt>['onRespond'];
   respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
+  activeForm: ComponentProps<typeof FormInteractionPrompt>['request'] | undefined;
+  respondToUserForm: ComponentProps<typeof FormInteractionPrompt>['onRespond'];
   stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
   boundaryUnreadableNotice?: BoundaryUnreadableNotice;
   directoryComposerProps: Pick<
@@ -129,6 +132,8 @@ export function ChatComposerRegion({
   respondToSandboxBoundary,
   respondToClientCapability,
   respondToUserQuestion,
+  activeForm,
+  respondToUserForm,
   stop,
   boundaryUnreadableNotice,
   directoryComposerProps,
@@ -244,6 +249,12 @@ export function ChatComposerRegion({
             stopPending={activeId ? stopPendingBySession[activeId] === true : false}
           />
         )}
+        {activeForm && (
+          <FormInteractionPrompt
+            request={activeForm}
+            onRespond={respondToUserForm}
+          />
+        )}
       </div>
       <ComposerGoalProjectionConsumer>
         {(goalProjection) => (
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 37c4c87fc7..524c1e05fa 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 { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
 import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
@@ -265,6 +266,10 @@ export interface SideChatSessionPort {
     sessionId: string,
     response: UserQuestionResponse,
   ): Promise<void>;
+  respondToUserForm(
+    sessionId: string,
+    response: InteractionFormResponse,
+  ): Promise<void>;
   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 8bf5c6bb0a..24bdf0d6b3 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -138,6 +138,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 a12512b0fc..674d117469 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
@@ -428,7 +428,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(
@@ -440,9 +440,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 8f3f0dfbb3..91b5378b65 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 {
   ChatSurfaceLayout,
   Composer,
   ClientCapabilityPrompt,
+  FormInteractionPrompt,
   SandboxBoundaryPrompt,
   UserQuestionPrompt,
   useToast,
@@ -180,7 +181,8 @@ export function QuoteCompanionPanel(props: {
   const activeInteraction =
     companion.activeSandboxBoundary ??
     companion.activeClientCapability ??
-    companion.activeQuestion;
+    companion.activeQuestion ??
+    companion.activeForm;
   const deriveTurnPresentation = useCallback<
     NonNullable<ComponentProps<typeof ChatView>['deriveTurnPresentation']>
   >(
@@ -221,7 +223,8 @@ export function QuoteCompanionPanel(props: {
             )}
             {(companion.activeSandboxBoundary ||
               companion.activeClientCapability ||
-              companion.activeQuestion) && (
+              companion.activeQuestion ||
+              companion.activeForm) && (
               <div className="maka-composer-interaction-slot">
                 {companion.activeSandboxBoundary && (
                   <SandboxBoundaryPrompt
@@ -242,6 +245,12 @@ export function QuoteCompanionPanel(props: {
                     onStop={() => void companion.stop()}
                   />
                 )}
+                {companion.activeForm && (
+                  <FormInteractionPrompt
+                    request={companion.activeForm}
+                    onRespond={companion.respondToUserForm}
+                  />
+                )}
               </div>
             )}
             <Composer
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
index e65526dfb6..a4d57e6908 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
@@ -30,6 +30,7 @@ import {
 import type {
   SandboxBoundaryRequestEvent,
   ClientCapabilityRequestEvent,
+  FormRequestEvent,
   QuoteRef,
   SessionEvent,
   UserQuestionRequestEvent,
@@ -41,6 +42,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session';
 import type { UiLocale } from '@maka/core/ui-locale';
 import type { ChatModelChoice } from '@maka/core/chat-model-choice';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import { useWorkbarServices } from '../../services-context.js';
 import type { WorkbarIngestInput } from '../../ports.js';
 import {
@@ -137,10 +139,11 @@ export interface UseQuoteCompanionResult {
   error: string | null;
   /** The model the companion inherited from the source (shown read-only). */
   activeModel: { llmConnectionSlug: string; model: string } | undefined;
-  /** Pending sandbox-boundary / user-question prompt raised by the companion's run. */
+  /** Pending sandbox-boundary / question / form prompt raised by the companion's run. */
   activeSandboxBoundary: SandboxBoundaryRequestEvent | undefined;
   activeClientCapability: ClientCapabilityRequestEvent | undefined;
   activeQuestion: UserQuestionRequestEvent | undefined;
+  activeForm: FormRequestEvent | undefined;
   /** Returns whether the send was accepted; false leaves the draft + staged
    *  quotes in place so the user can retry. */
   send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise<boolean>;
@@ -152,6 +155,7 @@ export interface UseQuoteCompanionResult {
   respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise<void>;
   respondToClientCapability: (response: ClientCapabilityResponse) => Promise<void>;
   respondToUserQuestion: (response: UserQuestionResponse) => Promise<void>;
+  respondToUserForm: (response: InteractionFormResponse) => Promise<void>;
 }
 
 /** The last streamed assistant message id of a turn — the settlement anchor. */
@@ -1042,6 +1046,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     [mountedRef, sideChat],
   );
 
+  const respondToUserForm = useCallback(
+    async (response: InteractionFormResponse): Promise<void> => {
+      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(
@@ -1064,6 +1081,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,
@@ -1082,6 +1100,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     activeSandboxBoundary,
     activeClientCapability,
     activeQuestion,
+    activeForm,
     send,
     steer,
     setPermissionMode,
@@ -1090,5 +1109,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 31fdc82832..56e1c6530b 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -164,6 +164,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) => (
+      <div
+        className="maka-panel maka-panel-detail"
+        style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}
+      >
+        <div className="maka-detail-with-artifacts">
+          <div className="mainColumn" style={{ justifyContent: 'flex-end' }}>
+            <Story />
+          </div>
+        </div>
+      </div>
+    ),
+  ],
+} satisfies Meta<typeof FormInteractionPrompt>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+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/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 1046134007..ebd4e6127c 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -237,6 +237,21 @@ 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;
+    invalid: string;
+    cancel: string;
+    decline: string;
+    accept: string;
+    submitting: string;
+  };
   mentions: {
     noFiles: string;
     noSkills: string;
@@ -518,6 +533,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: '输入数字', invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' },
     mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
     workspace: {
       choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
@@ -676,6 +692,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', 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..c9bc39d12a
--- /dev/null
+++ b/packages/ui/src/form-interaction-prompt.tsx
@@ -0,0 +1,225 @@
+/*
+ * 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 { useEffect, 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<void>;
+}) {
+  const copy = getConversationCopy(useUiLocale()).forms;
+  const titleId = useId();
+  const [drafts, setDrafts] = useState<InteractionFormFieldDraft[]>(
+    () => createInteractionFormDrafts(props.request.fields),
+  );
+  const [submitAttempted, setSubmitAttempted] = useState(false);
+  const [responsePending, setResponsePending] = useState(false);
+  const responsePendingRef = useRef(false);
+  const activeRequestIdRef = useRef(props.request.requestId);
+  const mountedRef = useMountedRef();
+
+  useEffect(() => {
+    activeRequestIdRef.current = props.request.requestId;
+    setDrafts(createInteractionFormDrafts(props.request.fields));
+    setSubmitAttempted(false);
+    responsePendingRef.current = false;
+    setResponsePending(false);
+  }, [props.request.fields, props.request.requestId]);
+
+  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;
+    const requestId = props.request.requestId;
+    responsePendingRef.current = true;
+    setResponsePending(true);
+    try {
+      await props.onRespond(response);
+    } finally {
+      if (activeRequestIdRef.current === requestId) {
+        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 (
+    <section
+      className="maka-composer-interaction maka-form-interaction-prompt composer"
+      role="region"
+      aria-labelledby={titleId}
+    >
+      <div className="maka-composer-interaction-inner agents-parchment-paper-surface">
+        <header className="maka-interaction-header maka-form-interaction-header">
+          <h2 className="maka-interaction-title" id={titleId}>{props.request.message}</h2>
+          <p>{requester}</p>
+        </header>
+
+        <div className="maka-form-interaction-fields">
+          {props.request.fields.map((field, index) => {
+            const draft = drafts[index];
+            if (!draft) return null;
+            const invalid = submitAttempted && !interactionFormFieldDraftIsValid(field, draft);
+            return (
+              <div className="maka-form-interaction-field" key={field.name}>
+                <div className="maka-form-interaction-field-heading">
+                  <span>{field.label}</span>
+                  <span>{field.required ? copy.required : copy.optional}</span>
+                </div>
+                {field.description ? <p className="maka-form-interaction-field-description">{field.description}</p> : null}
+                {!field.required ? (
+                  <CheckboxInput
+                    label={copy.include(field.label)}
+                    value={draft.included}
+                    isDisabled={responsePending}
+                    onChange={(included) => updateDraft(index, { ...draft, included })}
+                  />
+                ) : null}
+                {draft.included ? renderFormControl({
+                  field,
+                  draft,
+                  disabled: responsePending,
+                  copy,
+                  onChange: (value) => updateDraft(index, { ...draft, value }),
+                }) : null}
+                {invalid ? <p className="maka-form-interaction-error" role="alert">{copy.invalid}</p> : null}
+              </div>
+            );
+          })}
+        </div>
+
+        <footer className="maka-interaction-actions maka-form-interaction-actions">
+          <div>
+            <Button
+              variant="ghost"
+              isDisabled={responsePending}
+              onClick={() => void respond({ requestId: props.request.requestId, action: 'cancel' })}
+              label={copy.cancel}
+            />
+          </div>
+          <div className="maka-form-interaction-primary-actions">
+            <Button
+              variant="ghost"
+              isDisabled={responsePending}
+              onClick={() => void respond({ requestId: props.request.requestId, action: 'decline' })}
+              label={copy.decline}
+            />
+            <Button
+              variant="primary"
+              isDisabled={responsePending}
+              onClick={accept}
+              label={responsePending ? copy.submitting : copy.accept}
+            />
+          </div>
+        </footer>
+      </div>
+    </section>
+  );
+}
+
+function renderFormControl(props: {
+  field: InteractionFormField;
+  draft: InteractionFormFieldDraft;
+  disabled: boolean;
+  copy: ReturnType<typeof getConversationCopy>['forms'];
+  onChange(value: InteractionFormFieldDraft['value']): void;
+}) {
+  const { field, draft } = props;
+  if (field.kind === 'boolean') {
+    return (
+      <CheckboxInput
+        label={props.copy.enabled(field.label)}
+        value={draft.value === true}
+        isDisabled={props.disabled}
+        onChange={props.onChange}
+      />
+    );
+  }
+  if (field.kind === 'single_select') {
+    return (
+      <RadioList
+        label={field.label}
+        isLabelHidden
+        value={typeof draft.value === 'string' ? draft.value : ''}
+        isDisabled={props.disabled}
+        onChange={props.onChange}
+      >
+        {field.options.map((option) => (
+          <RadioListItem key={option.value} value={option.value} label={option.label} />
+        ))}
+      </RadioList>
+    );
+  }
+  if (field.kind === 'multi_select') {
+    const selected = Array.isArray(draft.value) ? draft.value : [];
+    return (
+      <div className="maka-form-interaction-checkboxes" role="group" aria-label={field.label}>
+        {field.options.map((option) => (
+          <CheckboxInput
+            key={option.value}
+            label={option.label}
+            value={selected.includes(option.value)}
+            isDisabled={props.disabled}
+            onChange={(checked) => props.onChange(
+              checked ? [...selected, option.value] : selected.filter((value) => value !== option.value),
+            )}
+          />
+        ))}
+      </div>
+    );
+  }
+  return (
+    <TextInput
+      label={field.label}
+      isLabelHidden
+      value={typeof draft.value === 'string' ? draft.value : ''}
+      isDisabled={props.disabled}
+      placeholder={field.kind === 'string' ? props.copy.enterValue : props.copy.enterNumber}
+      onChange={props.onChange}
+      width="100%"
+    />
+  );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 33e05d252f..97616ec410 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -57,6 +57,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';


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 03/06: chore(ui): register the form interaction surface

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 09a92444b5f5e23610776ea5464671daf0800581
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 02:29:38 2026 +0800

    chore(ui): register the form interaction surface
    
    Refresh the generated Astryx surface inventory for the aligned structured-form prompt.
    
    Part of #4364.
---
 docs/astryx-surface-file-inventory.md    | 1 +
 docs/astryx-surface-file-inventory.paths | 1 +
 2 files changed, 2 insertions(+)

diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index f6ed5c7566..f2a36d087c 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -216,6 +216,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 1371e38522..395eabcb34 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -187,6 +187,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


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 05/06: fix(desktop): preserve and explain form drafts

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 69ed51c328a1f34ceebce58793913f2ce03c4ac6
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 15:59:21 2026 +0800

    fix(desktop): preserve and explain form drafts
---
 .../src/__tests__/form-interaction-prompt.test.tsx | 100 +++++++++++++++++++++
 packages/ui/src/conversation-copy.ts               |   9 +-
 packages/ui/src/form-interaction-prompt.tsx        |  62 +++++++++----
 3 files changed, 153 insertions(+), 18 deletions(-)

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('<div id="root"></div>');
+  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(
+      <LocaleProvider locale="en">
+        <FormInteractionPrompt request={next} onRespond={() => undefined} />
+      </LocaleProvider>,
+    ));
+  };
+
+  try {
+    await render(request);
+    const includeNotes = container.querySelector<HTMLInputElement>('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<HTMLInputElement>('input[type="checkbox"]')?.checked, true);
+    assert.match(container.textContent ?? '', /997/);
+    assert.match(container.textContent ?? '', /date-time/);
+    for (const field of container.querySelectorAll<HTMLElement>('[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<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
+  } finally {
+    await act(() => root.unmount());
+    Object.assign(globalThis, original);
+  }
+});
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index ebd4e6127c..8682eb8a98 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -246,6 +246,11 @@ export interface ConversationCopy {
     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;
@@ -533,7 +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: '输入数字', invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' },
+    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) => mi [...]
     mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
     workspace: {
       choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
@@ -692,7 +697,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', invalid: 'Provide a value that meets the requirements.', cancel: 'Cancel', decline: 'Decline', accept: 'Submit', submitting: 'Submitting…' },
+    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} chara [...]
     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.tsx b/packages/ui/src/form-interaction-prompt.tsx
index c9bc39d12a..fcc2786b53 100644
--- a/packages/ui/src/form-interaction-prompt.tsx
+++ b/packages/ui/src/form-interaction-prompt.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { useEffect, useId, useRef, useState } from 'react';
+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';
@@ -34,6 +34,13 @@ import { useMountedRef } from './use-mounted-ref.js';
 export function FormInteractionPrompt(props: {
   request: FormRequestEvent;
   onRespond(response: InteractionFormResponse): void | Promise<void>;
+}) {
+  return <ActiveFormInteractionPrompt key={props.request.requestId} {...props} />;
+}
+
+function ActiveFormInteractionPrompt(props: {
+  request: FormRequestEvent;
+  onRespond(response: InteractionFormResponse): void | Promise<void>;
 }) {
   const copy = getConversationCopy(useUiLocale()).forms;
   const titleId = useId();
@@ -43,33 +50,21 @@ export function FormInteractionPrompt(props: {
   const [submitAttempted, setSubmitAttempted] = useState(false);
   const [responsePending, setResponsePending] = useState(false);
   const responsePendingRef = useRef(false);
-  const activeRequestIdRef = useRef(props.request.requestId);
   const mountedRef = useMountedRef();
 
-  useEffect(() => {
-    activeRequestIdRef.current = props.request.requestId;
-    setDrafts(createInteractionFormDrafts(props.request.fields));
-    setSubmitAttempted(false);
-    responsePendingRef.current = false;
-    setResponsePending(false);
-  }, [props.request.fields, props.request.requestId]);
-
   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;
-    const requestId = props.request.requestId;
     responsePendingRef.current = true;
     setResponsePending(true);
     try {
       await props.onRespond(response);
     } finally {
-      if (activeRequestIdRef.current === requestId) {
-        responsePendingRef.current = false;
-        if (mountedRef.current) setResponsePending(false);
-      }
+      responsePendingRef.current = false;
+      if (mountedRef.current) setResponsePending(false);
     }
   }
 
@@ -103,13 +98,26 @@ export function FormInteractionPrompt(props: {
             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 (
-              <div className="maka-form-interaction-field" key={field.name}>
+              <div
+                className="maka-form-interaction-field"
+                key={field.name}
+                role="group"
+                aria-label={field.label}
+                aria-describedby={constraintId}
+              >
                 <div className="maka-form-interaction-field-heading">
                   <span>{field.label}</span>
                   <span>{field.required ? copy.required : copy.optional}</span>
                 </div>
                 {field.description ? <p className="maka-form-interaction-field-description">{field.description}</p> : null}
+                {constraint ? (
+                  <p className="maka-form-interaction-field-description" id={constraintId}>
+                    {constraint}
+                  </p>
+                ) : null}
                 {!field.required ? (
                   <CheckboxInput
                     label={copy.include(field.label)}
@@ -160,6 +168,28 @@ export function FormInteractionPrompt(props: {
   );
 }
 
+function formFieldConstraint(
+  field: InteractionFormField,
+  copy: ReturnType<typeof getConversationCopy>['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;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 06/06: chore(desktop): refresh renderer architecture ledger

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit addd05bd82223263b73103efff7cd5302a7ede4f
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 23:00:19 2026 +0800

    chore(desktop): refresh renderer architecture ledger
---
 apps/desktop/renderer-architecture.json | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 5a317e9c20..e069c85df7 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -386,8 +386,8 @@
           "@maka/runtime/skill-invocation": 1,
           "@maka/ui": 1
         },
-        "importSpecifiers": 39,
-        "nonTriviaTokens": 4278
+        "importSpecifiers": 38,
+        "nonTriviaTokens": 4275
       },
       "src/renderer/app-shell-chrome-actions.tsx": {
         "importDeclarations": 5,
@@ -711,7 +711,7 @@
           "@maka/ui": 1
         },
         "importSpecifiers": 21,
-        "nonTriviaTokens": 2974
+        "nonTriviaTokens": 2976
       },
       "src/renderer/app-shell-session-settings-actions.ts": {
         "importDeclarations": 9,
@@ -1060,7 +1060,7 @@
           "react": 1
         },
         "importSpecifiers": 186,
-        "nonTriviaTokens": 15825
+        "nonTriviaTokens": 15840
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 04/06: test(desktop): complete the Workbar form port fake

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 804597c7a137625f892795e47ec7feacede45c7f
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 02:32:53 2026 +0800

    test(desktop): complete the Workbar form port fake
    
    Keep the Session Workbar Storybook services aligned with the required structured-form response capability.
    
    Part of #4364.
---
 apps/desktop/stories/session-workbar.stories.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 9c5246717b..c756467632 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -912,6 +912,7 @@ function bridge(options: {
       respondToSandboxBoundary: async () => undefined,
       respondToClientCapability: async () => undefined,
       respondToUserQuestion: async () => undefined,
+      respondToUserForm: async () => undefined,
       subscribeEvents: (_sessionId, _handler, onSeeded) => {
         onSeeded?.();
         return unsubscribe();


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org