refactor(desktop): route tool cards and bridge events through lookup tables (#127828)
* refactor(desktop): route tool cards through a table keyed on the card-tool names The name-keyed if ladder in ChainToolFallback becomes TOOL_CARDS, typed Record<CardToolName, FC>. CardToolName comes from the same list isCardTool reads, so a card added to one list and not the other fails the build. Behaviour is unchanged. * refactor(desktop): dispatch desktop bridge events through a handler table The six-branch event.type ladder becomes DESKTOP_BRIDGE_HANDLERS, one row per event with its body and comments unchanged. Behaviour is unchanged. * chore: retrigger CI (zero-job dispatch failure, auto-heal)
This commit is contained in:
@@ -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<string, (ctx: GatewayEventContext) => 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
|
||||
}
|
||||
|
||||
@@ -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<TimelineToolCallProps> = props => {
|
||||
)
|
||||
}
|
||||
|
||||
const ClarifyToolPart: FC<TimelineToolCallProps> = 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 <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
|
||||
<ClarifyTool {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionsToolPart: FC<TimelineToolCallProps> = props =>
|
||||
mcpTargets(props.toolName, props.args).length > 0 ? <McpSetupTool {...props} /> : <ConnectorTool {...props} />
|
||||
|
||||
const TOOL_CARDS: Record<CardToolName, FC<TimelineToolCallProps>> = {
|
||||
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<TimelineToolCallProps> = props => {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.toolName === 'delegate_task') {
|
||||
return <DelegateToolPart {...props} />
|
||||
}
|
||||
if (isCardToolName(props.toolName)) {
|
||||
const ToolCard = TOOL_CARDS[props.toolName]
|
||||
|
||||
if (props.toolName === 'image_generate') {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
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 <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
|
||||
<ClarifyTool {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.toolName === 'manage_catalog') {
|
||||
return <CatalogInstallTool {...props} />
|
||||
}
|
||||
|
||||
if (mcpTargets(props.toolName, props.args).length > 0) {
|
||||
return <McpSetupTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'manage_connections') {
|
||||
return <ConnectorTool {...props} />
|
||||
return <ToolCard {...props} />
|
||||
}
|
||||
|
||||
if (toolLabels(props.args).length > 0) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user