From 2c604365cb00f9ccadfbf57bd73fd629d9e4141b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 00:04:04 +0800 Subject: [PATCH 1/9] 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 + .../quote-companion-interactions.test.ts | 47 ++++ ...me-host-session-execution-ipc-main.test.ts | 63 +++++ .../runtime-host-session-observer.test.ts | 51 ++++ .../main/__tests__/streaming-handoff.test.ts | 42 ++++ .../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 + .../src/renderer/app-shell-chat-actions.ts | 25 ++ .../src/renderer/app-shell-session-events.ts | 2 + apps/desktop/src/renderer/app-shell.tsx | 4 + .../src/renderer/chat-composer-region.tsx | 13 +- .../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 +- .../tools/side-chat/use-quote-companion.ts | 22 +- .../desktop/create-workbar-services.ts | 2 + .../renderer/styles/interaction-prompts.css | 74 ++++++ .../stories/form-interaction.stories.tsx | 103 ++++++++ packages/ui/src/conversation-copy.ts | 17 ++ .../src/form-interaction-prompt-state.test.ts | 128 ++++++++++ .../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(-) create mode 100644 apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts create mode 100644 apps/desktop/stories/form-interaction.stories.tsx create mode 100644 packages/ui/src/form-interaction-prompt-state.test.ts create mode 100644 packages/ui/src/form-interaction-prompt-state.ts create mode 100644 packages/ui/src/form-interaction-prompt.tsx diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 907c24243d..dc8eeb54d7 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -124,6 +124,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 1c71e36785..7082dbb81e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -188,6 +188,69 @@ test('answers a Client Capability approval through the existing Interaction auth await observer.close(); }); +test("validates and forwards Desktop form responses to the pending Host interaction", async () => { + const pending = { + schemaVersion: 1 as const, + interactionId: "form-1", + sessionId: "session-1", + turnId: "turn-1", + runId: "run-1", + revision: 1 as const, + status: "pending" as const, + outcome: null, + request: { + kind: "form" as const, + toolUseId: "tool-1", + message: "Configure deployment", + requester: { name: "deploy" }, + fields: [{ kind: "integer" as const, name: "replicas", label: "Replicas", required: true }], + }, + }; + const observer = observerWithSnapshot({ interactions: { pending: [pending] } }); + const answers: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc({ + observer, + client: executionClient({ + answerInteraction: async (input) => { + answers.push(input); + return { + ...pending, + revision: 2, + status: "answered", + outcome: { + kind: "form_answer", + action: "accept", + values: { replicas: 3 }, + committedAt: 2, + }, + }; + }, + }), + }, ipc); + + await ipc.invoke("sessions:respondToUserForm", "session-1", { + requestId: "form-1", + action: "accept", + values: { replicas: 3 }, + }); + assert.deepEqual(answers, [{ + sessionId: "session-1", + interactionId: "form-1", + answer: { kind: "form", action: "accept", values: { replicas: 3 } }, + }]); + + await assert.rejects( + () => ipc.invoke("sessions:respondToUserForm", "session-1", { + requestId: "form-1", + action: "accept", + values: { replicas: Number.NaN }, + }), + ); + assert.equal(answers.length, 1); + await observer.close(); +}); + test("retries committed Branch and Revision copies with the renderer-owned identity", async () => { const committed = new Map(); const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 1b713542e9..ea76a37f39 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2672,6 +2672,57 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as await observer.close(); }); +test("publishes form answer acknowledgements for renderer queue retirement", async () => { + const pending = { + schemaVersion: 1 as const, + interactionId: "form-1", + sessionId: "session-1", + turnId: "turn-1", + runId: "run-1", + revision: 1 as const, + status: "pending" as const, + outcome: null, + request: { + kind: "form" as const, + toolUseId: "tool-1", + message: "Configure deployment", + requester: { name: "deploy" }, + fields: [{ kind: "boolean" as const, name: "confirm", label: "Confirm", required: true }], + }, + }; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot({ interactions: { pending: [pending] } }), + activeAssistantStreams: [], + transcript: Promise.resolve([]), + events: new AsyncFrameQueue(), + async close() {}, + }), + }, + emitSessionsChanged() {}, + now: () => 80, + }); + const target = eventTarget(2); + await observer.observe("session-1", "observer-1", target); + observer.publishInteractionAnswer({ + ...pending, + revision: 2, + status: "answered", + outcome: { kind: "form_answer", action: "accept", values: { confirm: true }, committedAt: 80 }, + }, pending); + + assert.deepEqual(target.events.at(-1), { + type: "form_answer_ack", + id: "host-interaction:form-1:2", + turnId: "turn-1", + ts: 80, + requestId: "form-1", + toolUseId: "tool-1", + }); + await observer.close(); +}); + test("projects Host queue revisions and newly delivered steering messages", async () => { const events = new AsyncFrameQueue(); const observer = new RuntimeHostSessionObserver({ diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index df1ac3b1c6..16e8f1de69 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -655,6 +655,48 @@ describe('single live-turn handoff', () => { assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'request-1'); }); + it('queues and retires a form at the Host answer acknowledgement', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const ref = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef: ref, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: liveTurns.set, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + }); + handlers.handleEvent('session-1', { + type: 'form_request', + id: 'form-event', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'form-1'); + + handlers.handleEvent('session-1', { + type: 'form_answer_ack', + id: 'form-ack', + turnId: 'turn-1', + ts: 2, + requestId: 'form-1', + toolUseId: 'tool-1', + }); + assert.deepEqual(interactions.get()['session-1'], []); + assert.equal(liveTurns.get()['session-1']?.terminal, undefined); + }); + it('hands an aborted projection over only after persisted messages cover it', async () => { const liveTurns = createStateSetter>({ 'session-1': { diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 75fa733055..1b141ffa3c 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -182,6 +182,7 @@ describe('createDesktopWorkbarServices', () => { await services.sideChat.respondToSandboxBoundary('fork', {} as never); await services.sideChat.respondToClientCapability('fork', {} as never); await services.sideChat.respondToUserQuestion('fork', {} as never); + await services.sideChat.respondToUserForm('fork', {} as never); services.sideChat.subscribeEvents('fork', eventHandler)(); assert.deepEqual( @@ -237,6 +238,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.respondToSandboxBoundary', 'sessions.respondToClientCapability', 'sessions.respondToUserQuestion', + 'sessions.respondToUserForm', 'sessions.subscribeEvents', ], ); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index acd6dab4aa..7cf76169bd 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -31,6 +31,7 @@ import { } from '@maka/core/session'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; import { type PermissionMode } from '@maka/core/permission'; +import { decodeInteractionFormResponse } from '@maka/core/interaction'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { AttachmentApprovalRegistry } from "./attachment-approval.js"; import { @@ -663,6 +664,28 @@ export function registerRuntimeHostSessionExecutionIpc( deps.observer.publishInteractionAnswer(answered, pending); }, ); + ipcMain.handle( + "sessions:respondToUserForm", + async (_event, sessionId: string, input: unknown) => { + const response = decodeInteractionFormResponse(input); + const pending = await requireInteraction( + deps.observer, + sessionId, + response.requestId, + ); + if (pending.request.kind !== "form") { + throw new Error("Interaction is not a form request"); + } + const answered = await deps.client.answerInteraction({ + sessionId, + interactionId: response.requestId, + answer: response.action === "accept" + ? { kind: "form", action: "accept", values: response.values } + : { kind: "form", action: response.action }, + }); + deps.observer.publishInteractionAnswer(answered, pending); + }, + ); ipcMain.handle("sessions:compact", async (_event, sessionId: string) => { const turnId = newId(); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index acf5e12377..08121b2954 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -554,6 +554,11 @@ export class RuntimeHostSessionObserver { type: "user_question_answer_ack", ...base, }); + } else if (answered.outcome.kind === "form_answer") { + this.#broadcast(answered.sessionId, { + type: "form_answer_ack", + ...base, + }); } else if (answered.outcome.kind === "sandbox_boundary_decision") { this.#broadcast(answered.sessionId, { type: "sandbox_boundary_decision_ack", diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d68e5c75c2..fe84b755f4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -53,6 +53,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; @@ -1218,6 +1219,7 @@ export interface MakaBridge { response: ClientCapabilityResponse, ): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; saveConversationToFile(input: { markdown: string; defaultName: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f5cbd6c44d..ce26162376 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -136,6 +136,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -2138,6 +2139,9 @@ const makaBridge = { respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response); }, + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise { + return invokeSessionRuntimeHost('sessions:respondToUserForm', sessionId, response); + }, /** * PR-CMD-PALETTE-SAVE-CONVERSATION-FILE-0: write the renderer-formatted * conversation markdown to a user-chosen file. Renderer owns the diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 7a877ebf25..0edee9b711 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; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; + respondToUserForm(response: InteractionFormResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; retryMessages(sessionId: string): Promise; } @@ -743,6 +745,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 { try { if (activeIdRef.current !== sessionId) return false; @@ -808,6 +832,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 0a685bbab4..08a8e4039e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -824,6 +824,7 @@ function AppShellContent({ [sessions, onboarding.snapshot?.sessionSendOutcomes], ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); + const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; const activeSession = activeCatalogSession; const sessionSettingIntent = useSessionSettingIntent({ catalogRevision, @@ -1789,6 +1790,7 @@ function AppShellContent({ enqueueMessage, respondToSandboxBoundary, respondToUserQuestion, + respondToUserForm, refreshMessages, retryMessages, } = useStableActions(createAppShellChatActions, { @@ -2929,6 +2931,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 1a70a0e022..af62e3b02f 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['onRespond']; respondToClientCapability: ComponentProps['onRespond']; respondToUserQuestion: ComponentProps['onRespond']; + activeForm: ComponentProps['request'] | undefined; + respondToUserForm: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; /** @@ -186,6 +189,8 @@ export function ChatComposerRegion({ respondToSandboxBoundary, respondToClientCapability, respondToUserQuestion, + activeForm, + respondToUserForm, stop, boundaryUnreadableNotice, latestRequestUsageTokens, @@ -316,6 +321,12 @@ export function ChatComposerRegion({ stopPending={activeId ? stopPendingBySession[activeId] === true : false} /> )} + {activeForm && ( + + )} {(goalProjection) => ( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index ca077e1a56..f95e7462ec 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -44,6 +44,7 @@ import type { import type { SessionTrace } from '@maka/core/session-trace'; import type { SessionTodoItem } from '@maka/core/session-todo'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { Result } from '@maka/core/result'; import type { ContextCompactResult, @@ -269,6 +270,10 @@ export interface SideChatSessionPort { sessionId: string, response: UserQuestionResponse, ): Promise; + respondToUserForm( + sessionId: string, + response: InteractionFormResponse, + ): Promise; subscribeEvents( sessionId: string, handler: (event: SessionEvent) => void, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 6604a4f050..f13752a75d 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -150,6 +150,7 @@ export function createFakeWorkbarServices( respondToSandboxBoundary: async () => undefined, respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, + respondToUserForm: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); return noopSubscription(); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 734279c28a..4ea839941e 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -454,7 +454,7 @@ export function companionRunEventEffect( /** * Route a companion event into its interaction queue, mirroring the main shell: - * boundary / question requests enqueue, their acks / tool results dequeue, and + * boundary / question / form requests enqueue, their acks / tool results dequeue, and * a terminal event clears the queue. */ export function applyCompanionInteractionEvent( @@ -466,9 +466,12 @@ export function applyCompanionInteractionEvent( case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': + case 'form_request': return enqueueInteraction(queues, sessionId, event); case 'sandbox_boundary_decision_ack': case 'client_capability_decision_ack': + case 'user_question_answer_ack': + case 'form_answer_ack': return dequeueInteractionByRequestId(queues, sessionId, event.requestId); case 'tool_result': return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 4ca276f5ed..fe04f827d9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -25,6 +25,7 @@ import { Composer, ClientCapabilityPrompt, finalAssistantReplyText, + FormInteractionPrompt, SandboxBoundaryPrompt, UserQuestionPrompt, useToast, @@ -237,7 +238,8 @@ export function QuoteCompanionPanel(props: { const activeInteraction = companion.activeSandboxBoundary ?? companion.activeClientCapability ?? - companion.activeQuestion; + companion.activeQuestion ?? + companion.activeForm; const deriveTurnPresentation = useCallback< NonNullable['deriveTurnPresentation']> >( @@ -275,7 +277,8 @@ export function QuoteCompanionPanel(props: { )} {(companion.activeSandboxBoundary || companion.activeClientCapability || - companion.activeQuestion) && ( + companion.activeQuestion || + companion.activeForm) && (
{companion.activeSandboxBoundary && ( void companion.stop()} /> )} + {companion.activeForm && ( + + )}
)} Promise; /** Returns whether the send was accepted; false leaves the draft + staged @@ -186,6 +189,7 @@ export interface UseQuoteCompanionResult { respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise; respondToClientCapability: (response: ClientCapabilityResponse) => Promise; respondToUserQuestion: (response: UserQuestionResponse) => Promise; + respondToUserForm: (response: InteractionFormResponse) => Promise; } /** The last streamed assistant message id of a turn — the settlement anchor. */ @@ -1197,6 +1201,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [mountedRef, sideChat], ); + const respondToUserForm = useCallback( + async (response: InteractionFormResponse): Promise => { + const id = companionIdRef.current; + if (!mountedRef.current || !id) return; + try { + await sideChat.respondToUserForm(id, response); + } catch { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + } + }, + [mountedRef, sideChat], + ); + // Only the companion's own turns render; the forked parent history stays as // hidden model context. const messages = allMessages.filter( @@ -1226,6 +1243,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; + const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; return { companionSession: companion, @@ -1242,6 +1260,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan activeSandboxBoundary, activeClientCapability, activeQuestion, + activeForm, compact, send, steer, @@ -1251,5 +1270,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan respondToSandboxBoundary, respondToClientCapability, respondToUserQuestion, + respondToUserForm, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 23480b7124..9e6ddbf21f 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -148,6 +148,8 @@ export function createDesktopWorkbarServices( bridge.sessions.respondToClientCapability(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), + respondToUserForm: (sessionId, response) => + bridge.sessions.respondToUserForm(sessionId, response), subscribeEvents: (sessionId, handler, onSeeded, onSeedError) => bridge.sessions.subscribeEvents(sessionId, handler, onSeeded, undefined, onSeedError), subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler), diff --git a/apps/desktop/src/renderer/styles/interaction-prompts.css b/apps/desktop/src/renderer/styles/interaction-prompts.css index 4b5a21d8ef..31b0e3b335 100644 --- a/apps/desktop/src/renderer/styles/interaction-prompts.css +++ b/apps/desktop/src/renderer/styles/interaction-prompts.css @@ -129,6 +129,71 @@ grid-column: 4; } +.maka-form-interaction-header p, +.maka-form-interaction-field p { + margin: 0; +} + +.maka-form-interaction-header p { + font: var(--maka-text-supporting); + color: var(--text-secondary); +} + +.maka-form-interaction-fields { + display: grid; + gap: var(--space-2); + max-height: min(420px, 50vh); + padding-right: var(--space-1); + overflow-y: auto; +} + +.maka-form-interaction-field { + display: grid; + gap: var(--space-1); + padding: var(--space-2); + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-control); +} + +.maka-form-interaction-field-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + color: var(--text-primary); +} + +.maka-form-interaction-field-heading > :first-child { + font: var(--maka-text-body-medium); +} + +.maka-form-interaction-field-heading > :last-child, +.maka-form-interaction-field-description { + font: var(--maka-text-supporting); + color: var(--text-secondary); +} + +.maka-form-interaction-checkboxes { + display: grid; + gap: var(--space-1); +} + +.maka-form-interaction-error { + font: var(--maka-text-supporting); + color: var(--destructive-text); +} + +.maka-form-interaction-actions, +.maka-form-interaction-primary-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.maka-form-interaction-actions { + justify-content: space-between; +} + .maka-interaction-header { min-width: 0; } @@ -175,4 +240,13 @@ .maka-question-submit { grid-column: auto; } + + .maka-form-interaction-actions, + .maka-form-interaction-primary-actions { + width: 100%; + } + + .maka-form-interaction-primary-actions { + justify-content: flex-end; + } } diff --git a/apps/desktop/stories/form-interaction.stories.tsx b/apps/desktop/stories/form-interaction.stories.tsx new file mode 100644 index 0000000000..9456211636 --- /dev/null +++ b/apps/desktop/stories/form-interaction.stories.tsx @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { FormRequestEvent } from '@maka/core/events'; +import { FormInteractionPrompt } from '@maka/ui'; + +// Fidelity convention (#1433): every story below names the real app path +// that reaches it. See apps/desktop/stories/FIDELITY.md. +const meta = { + title: 'Product/Form Interaction', + component: FormInteractionPrompt, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+
+
+ +
+
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const REQUEST: FormRequestEvent = { + type: 'form_request', + id: 'form-event', + ts: Date.now(), + turnId: 'form-turn', + requestId: 'form-request', + toolUseId: 'form-tool', + message: '配置生产环境发布', + requester: { name: 'create_release', source: 'Acme Deploy MCP' }, + fields: [ + { + kind: 'string', + name: 'version', + label: '版本号', + description: '将显示在发布记录和通知中。', + required: true, + default: 'v2.4.0', + minLength: 2, + }, + { + kind: 'integer', + name: 'replicas', + label: '实例数量', + required: true, + default: 3, + minimum: 1, + maximum: 20, + }, + { + kind: 'single_select', + name: 'channel', + label: '发布通道', + required: true, + default: 'stable', + options: [ + { value: 'stable', label: 'Stable' }, + { value: 'canary', label: 'Canary' }, + ], + }, + { + kind: 'boolean', + name: 'notify', + label: '发送发布通知', + description: '可选;启用后通知项目成员。', + required: false, + }, + ], +}; + +// Real path: a running tool requests structured input → Runtime Host parks the +// exact invocation → Desktop replaces the composer with this form. Accept, +// Decline, and Cancel all answer that same Host-owned interaction. +export const PendingDeploymentForm: Story = { + args: { request: REQUEST, onRespond: () => {} }, +}; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index e9b6f6dc91..57866ea715 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: '不可用', @@ -692,6 +708,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; +}) { + const copy = getConversationCopy(useUiLocale()).forms; + const titleId = useId(); + const [drafts, setDrafts] = useState( + () => createInteractionFormDrafts(props.request.fields), + ); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [responsePending, setResponsePending] = useState(false); + const responsePendingRef = useRef(false); + const 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 ( +
+
+
+

{props.request.message}

+

{requester}

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

{field.description}

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

{copy.invalid}

: null} +
+ ); + })} +
+ +
+
+
+
+
+
+
+
+ ); +} + +function renderFormControl(props: { + field: InteractionFormField; + draft: InteractionFormFieldDraft; + disabled: boolean; + copy: ReturnType['forms']; + onChange(value: InteractionFormFieldDraft['value']): void; +}) { + const { field, draft } = props; + if (field.kind === 'boolean') { + return ( + + ); + } + if (field.kind === 'single_select') { + return ( + + {field.options.map((option) => ( + + ))} + + ); + } + if (field.kind === 'multi_select') { + const selected = Array.isArray(draft.value) ? draft.value : []; + return ( +
+ {field.options.map((option) => ( + props.onChange( + checked ? [...selected, option.value] : selected.filter((value) => value !== option.value), + )} + /> + ))} +
+ ); + } + return ( + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2ebac40575..9a31cf091e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -58,6 +58,8 @@ export * from './model-picker.js'; export * from './interaction-queue.js'; export * from './user-question-prompt.js'; export * from './user-question-prompt-state.js'; +export * from './form-interaction-prompt.js'; +export * from './form-interaction-prompt-state.js'; export * from './redact.js'; export * from './thinking-stream.js'; export * from './session-todo-panel.js'; From b27fb990a02233e8209fa89459b0e2f0ad901c59 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 02:26:43 +0800 Subject: [PATCH 2/9] 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 +- .../app-shell-chat-actions-fixture.ts | 1 + .../app-shell-form-interaction.test.ts | 86 +++++++++++++++++++ ...chat-composer-region-draft-handoff.test.ts | 1 - .../quote-companion-interactions.test.ts | 2 +- .../src/renderer/app-shell-chat-actions.ts | 84 ++++++------------ .../src/renderer/app-shell-session-events.ts | 7 +- apps/desktop/src/renderer/app-shell.tsx | 3 +- .../src/renderer/chat-composer-region.tsx | 3 +- .../controller/use-workbar-controller.ts | 18 +++- 10 files changed, 134 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d23898ba63..21a1e072e7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -308,7 +308,7 @@ "nonTriviaTokens": 92 }, "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 26, + "importDeclarations": 25, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -324,7 +324,7 @@ "createAppShellChatActions" ], "dependencyPaths": { - "../preload/bridge-contract.js": 2, + "../preload/bridge-contract.js": 1, "./app-shell-copy.js": 1, "./app-shell-session-ui-state.js": 1, "./attachment-preflight.js": 1, 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 dc8eeb54d7..36d2f7b3ba 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -124,7 +124,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 0edee9b711..6bf0d678de 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -19,7 +19,7 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { CollaborationMode } from '@maka/core/collaboration'; -import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; +import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -28,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, @@ -691,67 +696,20 @@ export function createAppShellChatActions(deps: { } } - async function respondToSandboxBoundary(response: SandboxBoundaryResponse) { + async function respondToInteraction( + response: Response, + submit: (sessionId: string, response: Response) => Promise, + onApplied?: (sessionId: string) => void, + ) { const sessionId = activeIdRef.current; if (!sessionId) return; try { - await window.maka.sessions.respondToSandboxBoundary(sessionId, response); + await submit(sessionId, response); onInteractionChanged?.(sessionId); - // #1611: the answer has been applied to the authoritative boundary, so - // the permission label must stop describing the pre-decision one. The - // ack event covers decisions settled on other surfaces; this covers the - // one the user just made here, without waiting for the round trip. - onExecutionBoundaryChanged?.(sessionId); + onApplied?.(sessionId); setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId), ); - } catch (error) { - // Same fire-and-forget call site as stop(), wrap so a failed - // permission response (main process busy / session dropped) - // surfaces instead of dying as UnhandledPromiseRejection. - if (activeIdRef.current !== sessionId) return; - if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); - } else { - toastApi.error( - copy.responseFailedTitle, - localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), - undefined, - { sessionId }, - ); - } - } - } - - async function respondToUserQuestion(response: UserQuestionResponse) { - const sessionId = activeIdRef.current; - if (!sessionId) return; - try { - await window.maka.sessions.respondToUserQuestion(sessionId, response); - onInteractionChanged?.(sessionId); - setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId)); - } catch (error) { - if (activeIdRef.current !== sessionId) return; - if (isSessionWorkspaceUnavailableError(error)) { - 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)) { @@ -830,9 +788,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 08a8e4039e..1c2c944c32 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -824,7 +824,6 @@ function AppShellContent({ [sessions, onboarding.snapshot?.sessionSendOutcomes], ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); - const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; const activeSession = activeCatalogSession; const sessionSettingIntent = useSessionSettingIntent({ catalogRevision, @@ -1814,6 +1813,7 @@ function AppShellContent({ setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, + respondToUserForm: workbar.commands.respondToUserForm, showModelSetupToast, toastApi, newChatModel: newChatModel ?? null, @@ -2931,7 +2931,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 af62e3b02f..a2a2eb7f8a 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['onRespond']; respondToClientCapability: ComponentProps['onRespond']; respondToUserQuestion: ComponentProps['onRespond']; - activeForm: ComponentProps['request'] | undefined; respondToUserForm: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; @@ -189,7 +188,6 @@ export function ChatComposerRegion({ respondToSandboxBoundary, respondToClientCapability, respondToUserQuestion, - activeForm, respondToUserForm, stop, boundaryUnreadableNotice, @@ -204,6 +202,7 @@ export function ChatComposerRegion({ const activeClientCapability = activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; + const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; const activeModelChoice = composerRest.activeModel ? composerRest.modelChoices?.find( (choice) => diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 77a7fbad4f..59662743ab 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -28,6 +28,7 @@ import { } from 'react'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { QuoteRef } from '@maka/core/events'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SessionSummary } from '@maka/core/session'; import { Composer, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; @@ -76,6 +77,7 @@ export interface WorkbarControllerCommands { ): void; openSideChatWithQuote(quote: QuoteRef): void; respondToClientCapability(response: ClientCapabilityResponse): Promise; + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; toggleRight(): void; } @@ -655,8 +657,20 @@ export function useWorkbarController( ); const commands = useMemo( - () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }), - [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight], + () => ({ + openTool, + openSideChatWithQuote, + respondToClientCapability, + respondToUserForm: sideChat.respondToUserForm, + toggleRight, + }), + [ + openSideChatWithQuote, + openTool, + respondToClientCapability, + sideChat.respondToUserForm, + toggleRight, + ], ); const activeSideChatTabIds = useMemo( From 9dac9be3b2982f2a333783608461fe2d08a1cd34 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 02:29:38 +0800 Subject: [PATCH 3/9] 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 2c1d211cb1..146156fa16 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -227,6 +227,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned | | `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Skeleton, StackItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned | | `packages/ui/src/directory-reference-chip.tsx` | ui-composition | Token, Tooltip | aligned — uses Astryx (Token, Tooltip) | aligned | +| `packages/ui/src/form-interaction-prompt.tsx` | ui-composition | Button, CheckboxInput, RadioList, RadioListItem, TextInput | aligned — uses Astryx (Button, CheckboxInput, RadioList, RadioListItem, TextInput) | aligned | | `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/inline-reference.tsx` | ui-composition | ChatTokenizedText | aligned — uses Astryx (ChatTokenizedText) | aligned | | `packages/ui/src/inline-rename-input.tsx` | ui-composition | TextInput | aligned — uses Astryx (TextInput) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 40063d9469..de1163f504 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -198,6 +198,7 @@ packages/ui/src/composer-message-queue.tsx packages/ui/src/composer.tsx packages/ui/src/daily-review-panel.tsx packages/ui/src/directory-reference-chip.tsx +packages/ui/src/form-interaction-prompt.tsx packages/ui/src/icons.tsx packages/ui/src/inline-reference.tsx packages/ui/src/inline-rename-input.tsx From d08adcf7b02354f2c55801720f863f50feaea7f9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 02:32:53 +0800 Subject: [PATCH 4/9] 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 43d14bb21b..71329da3c1 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -929,6 +929,7 @@ function bridge(options: { respondToSandboxBoundary: async () => undefined, respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, + respondToUserForm: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); return unsubscribe(); From ddc1e0ea2dd50e8b6ea2a1476d3e0975c1252578 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 15:59:21 +0800 Subject: [PATCH 5/9] fix(desktop): preserve and explain form drafts --- .../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(-) create mode 100644 packages/ui/src/__tests__/form-interaction-prompt.test.tsx diff --git a/packages/ui/src/__tests__/form-interaction-prompt.test.tsx b/packages/ui/src/__tests__/form-interaction-prompt.test.tsx new file mode 100644 index 0000000000..37b6379298 --- /dev/null +++ b/packages/ui/src/__tests__/form-interaction-prompt.test.tsx @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { FormRequestEvent } from '@maka/core/events'; +import { FormInteractionPrompt } from '../form-interaction-prompt.js'; +import { LocaleProvider } from '../locale-context.js'; + +const request: FormRequestEvent = { + type: 'form_request', + id: 'event-1', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure release', + requester: { name: 'release' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true }, + { kind: 'integer', name: 'replicas', label: 'Replicas', required: true, minimum: 997, maximum: 997 }, + { kind: 'string', name: 'when', label: 'When', required: true, format: 'date-time' }, + { kind: 'string', name: 'notes', label: 'Notes', required: false }, + ], +}; + +test('same-request recovery preserves drafts and renders accessible constraints', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const render = async (next: FormRequestEvent) => { + await act(() => root.render( + + undefined} /> + , + )); + }; + + try { + await render(request); + const includeNotes = container.querySelector('input[type="checkbox"]'); + assert.ok(includeNotes); + assert.equal(includeNotes.checked, false); + await act(() => { + includeNotes.checked = true; + includeNotes.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + await render({ + ...request, + fields: request.fields.map((field) => ({ + ...field, + ...(field.kind === 'single_select' || field.kind === 'multi_select' + ? { options: field.options.map((option) => ({ ...option })) } + : {}), + })), + }); + + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, true); + assert.match(container.textContent ?? '', /997/); + assert.match(container.textContent ?? '', /date-time/); + for (const field of container.querySelectorAll('[aria-describedby]')) { + const describedBy = field.getAttribute('aria-describedby'); + if (describedBy) assert.ok(document.getElementById(describedBy)); + } + + await render({ ...request, requestId: 'form-2' }); + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, false); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 57866ea715..4fd5f49acc 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) => minimum === undefined ? `最大值 ${maximum}` : maximum === undefined ? `最小值 ${minimum}` : `范围 ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `最多选择 ${maximum} 项` : maximum === undefined ? `至少选择 ${minimum} 项` : `选择 ${minimum}–${maximum} 项`, formatConstraint: { email: '格式:email', uri: '格式:URI', date: '格式:date(YYYY-MM-DD)', 'date-time': '格式:date-time(RFC 3339)' }, invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' }, mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' }, workspace: { choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用', @@ -708,7 +713,7 @@ const CONVERSATION_COPY = { allowSession: 'Allow for this task', }, questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' }, - forms: { requester: (name) => `Requested by ${name}`, requesterWithSource: (name, source) => `Requested by ${name} · ${source}`, required: 'Required', optional: 'Optional', include: (label) => `Provide ${label}`, enabled: (label) => `Enable ${label}`, enterValue: 'Enter a value', enterNumber: 'Enter a number', 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} characters` : `${minimum}–${maximum} characters`, numberConstraint: (minimum, maximum) => minimum === undefined ? `Maximum ${maximum}` : maximum === undefined ? `Minimum ${minimum}` : `Range ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `Select at most ${maximum}` : maximum === undefined ? `Select at least ${minimum}` : `Select ${minimum}–${maximum}`, formatConstraint: { email: 'Format: email', uri: 'Format: URI', date: 'Format: date (YYYY-MM-DD)', 'date-time': 'Format: date-time (RFC 3339)' }, invalid: 'Provide a value that meets the requirements.', cancel: 'Cancel', decline: 'Decline', accept: 'Submit', submitting: 'Submitting…' }, mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' }, workspace: { choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable', diff --git a/packages/ui/src/form-interaction-prompt.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; +}) { + return ; +} + +function ActiveFormInteractionPrompt(props: { + request: FormRequestEvent; + onRespond(response: InteractionFormResponse): void | Promise; }) { 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 ( -
+
{field.label} {field.required ? copy.required : copy.optional}
{field.description ?

{field.description}

: null} + {constraint ? ( +

+ {constraint} +

+ ) : null} {!field.required ? ( ['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; From 434d41a4e150e2eeab4842f9f5fe994e5e1ef24e Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 21:55:26 +0800 Subject: [PATCH 6/9] fix(desktop): retain form interaction in composer union The parent narrows the composer queue to the request kinds a surface without form support can render, so a pending form can no longer hide the composer behind an empty prompt. This branch installs the Desktop form surface, so the shared queue accepts form_request again and retires it on form_answer_ack, matching the boundary/question siblings. Replace the parent coverage that asserted forms stay out of the queue with enter/leave and rehydration coverage for the form kind. --- .../ui/src/__tests__/interaction-queue.test.ts | 17 +++++++++++++++-- packages/ui/src/interaction-queue.ts | 9 +++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts index 6d2090c595..b6a21cc4fe 100644 --- a/packages/ui/src/__tests__/interaction-queue.test.ts +++ b/packages/ui/src/__tests__/interaction-queue.test.ts @@ -163,9 +163,22 @@ describe('composer interaction queue', () => { assert.equal(activeInteractionFor(reconciled, 's')?.requestId, 'missed'); }); - test('does not hide the composer for a form until the form surface is installed', () => { - const queues = reconcileInteractions({}, 's', [form('form-1')]); + test('form requests enter and leave the shared queue', () => { + let queues = reduceInteractionQueues({}, 's', form('form-1')); + assert.equal(activeInteractionFor(queues, 's')?.type, 'form_request'); + queues = reduceInteractionQueues(queues, 's', { + type: 'form_answer_ack', + id: 'evt_form_ack', + turnId: 'turn_1', + ts: 1, + requestId: 'form-1', + toolUseId: 'call_form-1', + }); assert.equal(activeInteractionFor(queues, 's'), undefined); + + queues = reconcileInteractions({}, 's', [form('rehydrated-form')]); + + assert.equal(activeInteractionFor(queues, 's')?.requestId, 'rehydrated-form'); }); }); diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts index 25c101ba55..9bae2872f6 100644 --- a/packages/ui/src/interaction-queue.ts +++ b/packages/ui/src/interaction-queue.ts @@ -20,6 +20,7 @@ import type { ActiveInteractionRequestEvent, ClientCapabilityRequestEvent, + FormRequestEvent, SandboxBoundaryRequestEvent, SessionEvent, UserQuestionRequestEvent, @@ -29,14 +30,16 @@ import type { export type ComposerInteraction = | SandboxBoundaryRequestEvent | ClientCapabilityRequestEvent - | UserQuestionRequestEvent; + | UserQuestionRequestEvent + | FormRequestEvent; export type InteractionQueues = Record; function isComposerInteraction(event: ActiveInteractionRequestEvent): event is ComposerInteraction { return ( event.type === 'sandbox_boundary_request' || event.type === 'client_capability_request' || - event.type === 'user_question_request' + event.type === 'user_question_request' || + event.type === 'form_request' ); } @@ -84,10 +87,12 @@ export function reduceInteractionQueues( case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': + case 'form_request': return enqueueInteraction(queues, sessionId, event); case 'sandbox_boundary_decision_ack': case 'client_capability_decision_ack': case 'user_question_answer_ack': + case 'form_answer_ack': return dequeueInteractionByRequestId(queues, sessionId, event.requestId); case 'tool_result': return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId); From 4ca012411de773e1f3c542547e847b6d86bf0617 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 10:22:59 +0800 Subject: [PATCH 7/9] fix(desktop): keep AppShell token debt monotonic after form wiring The composer form responder wiring adds tokens to two debt-tracked legacy files. Collapse single-statement conditionals to braceless form so both files stay at or below the parent baseline and refresh the renderer architecture ledger. --- apps/desktop/renderer-architecture.json | 10 +++---- .../src/renderer/app-shell-session-events.ts | 4 +-- apps/desktop/src/renderer/app-shell.tsx | 30 +++++-------------- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 21a1e072e7..3e8dbf99b1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -350,8 +350,8 @@ "@maka/runtime/skill-invocation": 1, "@maka/ui": 1 }, - "importSpecifiers": 39, - "nonTriviaTokens": 4089 + "importSpecifiers": 38, + "nonTriviaTokens": 4275 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, @@ -788,7 +788,7 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 102, + "importDeclarations": 103, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -970,7 +970,7 @@ "@maka/core/onboarding-milestone": 1, "@maka/core/orchestration": 1, "@maka/core/project": 1, - "@maka/core/session": 1, + "@maka/core/session": 2, "@maka/core/session-revisions": 1, "@maka/core/settings": 1, "@maka/core/slash-command-catalog": 2, @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "nonTriviaTokens": 15691 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index ba9721d77a..90335ba8c8 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -377,9 +377,7 @@ export function createAppShellSessionEventHandlers(options: { const queue = current[sessionId]; if (!queue?.entries.some((entry) => entry.messageId === event.messageId)) return current; const entries = queue.entries.filter((entry) => entry.messageId !== event.messageId); - if (entries.length > 0) { - return { ...current, [sessionId]: { ...queue, entries } }; - } + if (entries.length > 0) return { ...current, [sessionId]: { ...queue, entries } }; const next = { ...current }; delete next[sessionId]; return next; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1c2c944c32..3f3a212dd7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -797,9 +797,8 @@ function AppShellContent({ const owner = sessions.find((session) => session.id === draft.draftSessionId); if (source && owner && !source.isArchived && !owner.isArchived) return; composerRef.current?.clearDraft(draft.draftSessionId); - if (draft.sourceSessionId !== draft.draftSessionId) { + if (draft.sourceSessionId !== draft.draftSessionId) composerRef.current?.clearDraft(draft.sourceSessionId); - } if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); else void abandonTurnRevisionCopyAttempt(draft); commitRevisionDraft(null); @@ -1019,12 +1018,7 @@ function AppShellContent({ // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - } else { - await window.maka.sessions.setCollaborationMode( - sessionId, - active ? 'plan' : 'agent', - ); - } + } else await window.maka.sessions.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); return true; } @@ -1142,11 +1136,8 @@ function AppShellContent({ composerRef.current?.appendText(shellCopy.useSkillPrompt(skillName)); composerRef.current?.focus(); }; - if (activeIdRef.current) { - window.requestAnimationFrame(seed); - return; - } - void createSession().then(() => window.requestAnimationFrame(seed)); + if (activeIdRef.current) window.requestAnimationFrame(seed); + else void createSession().then(() => window.requestAnimationFrame(seed)); }, [shellCopy], ); @@ -1894,9 +1885,8 @@ function AppShellContent({ function settleNewTaskImageNoticeOwner(sourceSessionId?: string) { const createdSessionId = activeIdRef.current; - if (!sourceSessionId && createdSessionId) { + if (!sourceSessionId && createdSessionId) imageNoticeLifecycle.transfer(NEW_TASK_PENDING_KEY, createdSessionId); - } } async function enqueueFollowUp( @@ -2264,9 +2254,8 @@ function AppShellContent({ showModelSetupToast, toastApi, notifyRunEnded: ({ kind, sessionId, body }) => { - if (kind === 'completed' && activeIdRef.current === sessionId) { + if (kind === 'completed' && activeIdRef.current === sessionId) setPetCompletionNonce((current) => current + 1); - } const title = sessionsRef.current.find((session) => session.id === sessionId)?.name; // Best-effort: swallow any main-side failure so a missed banner // never surfaces as an unhandled promise rejection. @@ -2570,9 +2559,8 @@ function AppShellContent({ activeId, ); function handleTranscriptReadingAnchorChange(turnId?: string) { - if (activeId && activeUnavailableTranscriptRestore) { + if (activeId && activeUnavailableTranscriptRestore) sessionUiController.setTranscriptRestoreUnavailable(activeId, undefined); - } transcriptReadingPosition.captureAnchor({ sessionId: activeId, currentSessionId: activeIdRef.current, @@ -2590,9 +2578,7 @@ function AppShellContent({ try { if (target === 'earlier') { await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); - } else { - await controller.loadLatest(); - } + } else await controller.loadLatest(); } catch (error) { if ( activeIdRef.current !== sessionId || From 91056aa00e3d3feb92644fbcfc4a05ef0fea8616 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 22:09:00 +0800 Subject: [PATCH 8/9] chore(desktop): refresh renderer architecture ledger after rebase --- 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 3e8dbf99b1..d05fbe5535 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -351,7 +351,7 @@ "@maka/ui": 1 }, "importSpecifiers": 38, - "nonTriviaTokens": 4275 + "nonTriviaTokens": 4086 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, @@ -788,7 +788,7 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 103, + "importDeclarations": 102, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -970,7 +970,7 @@ "@maka/core/onboarding-milestone": 1, "@maka/core/orchestration": 1, "@maka/core/project": 1, - "@maka/core/session": 2, + "@maka/core/session": 1, "@maka/core/session-revisions": 1, "@maka/core/settings": 1, "@maka/core/slash-command-catalog": 2, @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15691 + "nonTriviaTokens": 15686 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, From 0fbc4588db2967cbc74a85153ec2a19b6886ee42 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 01:59:58 +0800 Subject: [PATCH 9/9] chore(desktop): refresh astryx surface inventory after main rebase --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 146156fa16..f63f82f804 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 245 files — blocker 0, reimplementation 0, polish 1, aligned 244. +**Totals:** 246 files — blocker 0, reimplementation 0, polish 1, aligned 245. ## Exclusions (explicit)