diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts index e9af455c4a..20c87c7f74 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts @@ -7,28 +7,19 @@ import { $tipsEnabled, type ActiveTip, agentTipId, showTip } from '@/store/tips' import type { GatewayEventContext } from './types' -/** Desktop-surface bridge events: agent terminal streaming, tips, pane - * reveal, layouts and message reactions. The read-back REQUESTS the agent - * blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */ -export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { - const { event, payload, isActiveEvent } = ctx - - if (event.type === 'agent.terminal.output') { +const DESKTOP_BRIDGE_HANDLERS: Record void> = { + 'agent.terminal.output': ({ payload }) => { // Live chunk from a background process → its read-only agent terminal tab. writeAgentTerminalChunk(payload?.process_id ?? '', payload?.chunk ?? '') + }, - return true - } - - if (event.type === 'terminal.close') { + 'terminal.close': ({ payload }) => { // Agent closed its own read-only tab via the desktop-gated close_terminal tool. // The process is untouched — this only drops the view. closeAgentTerminalByProc(payload?.process_id ?? '') + }, - return true - } - - if (event.type === 'tip.show') { + 'tip.show': ({ payload, isActiveEvent }) => { // tip tool: point the accent bubble at something and say one line about // it. Fire-and-forget — a tip is not a question, and blocking the turn on // one would stall the sentence the agent is in the middle of, so there is @@ -51,11 +42,9 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { title: typeof payload?.title === 'string' ? payload.title : undefined }) } + }, - return true - } - - if (event.type === 'pane.reveal') { + 'pane.reveal': ({ payload, isActiveEvent }) => { // Agent revealed a pane via the desktop-gated focus_pane tool, in // response to an explicit user request. Active session only — a // background turn must never move the user's focus (desktop AGENTS.md: @@ -63,11 +52,9 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { if (isActiveEvent) { revealDesktopPane(payload?.pane ?? '') } + }, - return true - } - - if (event.type === 'layout.apply') { + 'layout.apply': ({ payload, isActiveEvent }) => { // Agent applied a layout preset via the desktop-gated apply_layout // tool. Same contract as pane.reveal: active session only, and the // preset resolves against the SAME layouts registry the picker reads, @@ -75,11 +62,9 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { if (isActiveEvent) { applyDesktopLayoutPreset(typeof payload?.preset === 'string' ? payload.preset : '') } + }, - return true - } - - if (event.type === 'message.reaction') { + 'message.reaction': ({ payload }) => { // The agent reacted to a message via the desktop-gated // react_to_message tool. Already persisted — this only paints it now // instead of at the next resume. Fresh ChatMessage object per change: @@ -124,9 +109,18 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { ) }) } + } +} - return true +/** Desktop-surface bridge events: agent terminal streaming, tips, pane + * reveal, layouts and message reactions. The read-back REQUESTS the agent + * blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */ +export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { + if (!Object.hasOwn(DESKTOP_BRIDGE_HANDLERS, ctx.event.type)) { + return false } - return false + DESKTOP_BRIDGE_HANDLERS[ctx.event.type](ctx) + + return true } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 79abac997d..d0741009f1 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -29,7 +29,7 @@ import { mcpTargets, toolLabels } from '@/lib/connector-tools' import { generatedImageFromResult } from '@/lib/generated-images' import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks' import { isTodoToolName } from '@/lib/todos' -import { isCardTool } from '@/lib/tool-render-class' +import { type CardToolName, isCardTool, isCardToolName } from '@/lib/tool-render-class' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $reasoningCollapsedByDefault, $showReasoning } from '@/store/reasoning-disclosure' @@ -77,6 +77,33 @@ const DelegateToolPart: FC = props => { ) } +const ClarifyToolPart: FC = props => { + // Stopped on this question, never answered: history. ClarifyTool reads + // the session's live clarify request, so a later turn's question would + // otherwise paint onto this row as a second live card. + if (settledWithoutResult(props)) { + return + } + + return ( + <> + + + + ) +} + +const ConnectionsToolPart: FC = props => + mcpTargets(props.toolName, props.args).length > 0 ? : + +const TOOL_CARDS: Record> = { + clarify: ClarifyToolPart, + delegate_task: DelegateToolPart, + image_generate: ImageGenerateTool, + manage_catalog: CatalogInstallTool, + manage_connections: ConnectionsToolPart +} + // A failure the user still has to see. The gateway's tool.complete carries the // failure inside `result`, never as the top-level error that sets isError, so // this reads the body like the run summary does. A non-zero exit_code counts @@ -115,40 +142,10 @@ const ChainToolFallback: FC = props => { return null } - if (props.toolName === 'delegate_task') { - return - } + if (isCardToolName(props.toolName)) { + const ToolCard = TOOL_CARDS[props.toolName] - if (props.toolName === 'image_generate') { - return - } - - if (props.toolName === 'clarify') { - // Stopped on this question, never answered: history. ClarifyTool reads - // the session's live clarify request, so a later turn's question would - // otherwise paint onto this row as a second live card. - if (settledWithoutResult(props)) { - return - } - - return ( - <> - - - - ) - } - - if (props.toolName === 'manage_catalog') { - return - } - - if (mcpTargets(props.toolName, props.args).length > 0) { - return - } - - if (props.toolName === 'manage_connections') { - return + return } if (toolLabels(props.args).length > 0) { diff --git a/apps/desktop/src/lib/tool-render-class.ts b/apps/desktop/src/lib/tool-render-class.ts index 409022abd7..895265a89e 100644 --- a/apps/desktop/src/lib/tool-render-class.ts +++ b/apps/desktop/src/lib/tool-render-class.ts @@ -28,18 +28,19 @@ export function isFileEditTool(toolName: string): boolean { // // Everything else is ephemeral activity — reads, searches, commands — which is // what a run summarizes and what the live ticker cycles through. -const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate', 'manage_catalog']) +const CARD_TOOL_NAMES = ['clarify', 'delegate_task', 'image_generate', 'manage_catalog', 'manage_connections'] as const + +export type CardToolName = (typeof CARD_TOOL_NAMES)[number] + +export function isCardToolName(toolName: string): toolName is CardToolName { + return (CARD_TOOL_NAMES as readonly string[]).includes(toolName) +} // Name the run splitter uses for a manage_connections part it has classified as a card. export const CONNECTION_CARD_KEY = 'manage_connections:card' export function isCardTool(toolName: string): boolean { - return ( - CARD_TOOL_NAMES.has(toolName) || - toolName === CONNECTION_CARD_KEY || - isFileEditTool(toolName) || - toolName === 'manage_connections' - ) + return isCardToolName(toolName) || toolName === CONNECTION_CARD_KEY || isFileEditTool(toolName) } // Activity tools that render nothing at all: `todo` parts are hoisted to a