forked from bots-garden/ori
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 1 | /** |
| 2 | * Pure state logic of the chat: a reducer folding server messages and | |
| 3 | * connection changes into the UI state. Keeping it a plain function makes the | |
| 4 | * whole conversation flow testable without React or a WebSocket. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 8 | AcpAvailableCommand, |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 9 | AcpContentBlock, |
| 10 | AcpPlanEntry, | |
| 11 | AcpPermissionOption, | |
| 12 | AcpToolCallContent, | |
| 13 | AcpToolCallLocation, | |
| 14 | ServerMessage, | |
| 15 | } from "./protocol"; | |
| 16 | ||
| 17 | /** The UI's view of one tool call, folded from tool_call / tool_call_update. */ | |
| 18 | export interface ToolCall { | |
| 19 | toolCallId: string; | |
| 20 | title: string; | |
| 21 | toolKind: string; | |
| 22 | status: string; | |
| 23 | contents: AcpToolCallContent[]; | |
| 24 | locations: AcpToolCallLocation[]; | |
| 25 | } | |
| 26 | ||
| 27 | /** One entry of the conversation thread. */ | |
| 28 | export type ThreadItem = | |
| 29 | | { kind: "user"; text: string } | |
| 30 | | { kind: "agent"; text: string; closed: boolean } | |
| 31 | | { kind: "thought"; text: string; closed: boolean } | |
| 32 | | { kind: "tool"; call: ToolCall } | |
| 33 | | { kind: "error"; text: string }; | |
| 34 | ||
| 35 | /** A permission request awaiting the user's decision. */ | |
| 36 | export interface PermissionRequestState { | |
| 37 | requestId: string; | |
| 38 | title: string; | |
| 39 | options: AcpPermissionOption[]; | |
| 40 | } | |
| 41 | ||
| 42 | export type ConnectionStatus = "connecting" | "online" | "offline"; | |
| 43 | ||
| 44 | /** The whole state the UI renders from. */ | |
| 45 | export interface ChatState { | |
| 46 | connection: ConnectionStatus; | |
| 47 | sessionId: string | null; | |
| 48 | turnActive: boolean; | |
| 49 | thread: ThreadItem[]; | |
| 50 | plan: AcpPlanEntry[]; | |
| 51 | permissions: PermissionRequestState[]; | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 52 | /** Slash commands the agent announced (available_commands_update). */ |
| 53 | commands: AcpAvailableCommand[]; | |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 54 | } |
| 55 | ||
| 56 | export const initialState: ChatState = { | |
| 57 | connection: "connecting", | |
| 58 | sessionId: null, | |
| 59 | turnActive: false, | |
| 60 | thread: [], | |
| 61 | plan: [], | |
| 62 | permissions: [], | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 63 | commands: [], |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 64 | }; |
| 65 | ||
| 66 | /** Everything that can change the state. */ | |
| 67 | export type ChatEvent = | |
| 68 | | { type: "server"; message: ServerMessage } | |
| 69 | | { type: "connection"; status: ConnectionStatus }; | |
| 70 | ||
| 71 | /** | |
| 72 | * reduce returns the next state for an event. It never mutates its input. | |
| 73 | * | |
| 74 | * @example | |
| 75 | * let state = initialState; | |
| 76 | * state = reduce(state, { type: "server", message: { type: "hello", sessionId: "s1" } }); | |
| 77 | * state.sessionId; // "s1" | |
| 78 | */ | |
| 79 | export function reduce(state: ChatState, event: ChatEvent): ChatState { | |
| 80 | if (event.type === "connection") { | |
| 81 | return { ...state, connection: event.status }; | |
| 82 | } | |
| 83 | // Unknown or not-yet-handled message types must never break the UI. | |
| 84 | const handler = serverHandlers[event.message.type]; | |
| 85 | return handler ? handler(state, event.message) : state; | |
| 86 | } | |
| 87 | ||
| 88 | type MessageHandler = (state: ChatState, msg: ServerMessage) => ChatState; | |
| 89 | ||
| 90 | /** One handler per server message type; unknown types fall through in reduce. */ | |
| 91 | const serverHandlers: Record<string, MessageHandler> = { | |
| 92 | // A hello opens every (re)connection, right before the full history | |
| 93 | // replay: resetting everything here makes reconnects idempotent. | |
| 94 | hello: (state, msg) => ({ | |
| 95 | ...state, | |
| 96 | connection: "online", | |
| 97 | sessionId: msg.sessionId ?? null, | |
| 98 | turnActive: msg.turnActive ?? false, | |
| 99 | thread: [], | |
| 100 | plan: [], | |
| 101 | permissions: [], | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 102 | commands: [], |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 103 | }), |
| 104 | user_message: (state, msg) => | |
| 105 | withItem(state, { kind: "user", text: msg.text ?? "" }), | |
| 106 | turn_started: (state) => ({ ...state, turnActive: true }), | |
| 107 | turn_ended: (state) => ({ | |
| 108 | ...state, | |
| 109 | turnActive: false, | |
| 110 | thread: closeStreamItems(state.thread), | |
| 111 | }), | |
| 112 | session_update: reduceUpdate, | |
| 113 | permission_request: addPermission, | |
| 114 | permission_resolved: (state, msg) => ({ | |
| 115 | ...state, | |
| 116 | permissions: state.permissions.filter((p) => p.requestId !== msg.requestId), | |
| 117 | }), | |
| 118 | error: (state, msg) => | |
| 119 | withItem(state, { kind: "error", text: msg.message ?? "unknown error" }), | |
| 120 | }; | |
| 121 | ||
| 122 | /** | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 123 | * One handler per ACP update kind; the rest (current_mode_update, ...) are |
| 124 | * not rendered yet, and ignoring them keeps the UI forward-compatible. | |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 125 | */ |
| 126 | const updateHandlers: Record<string, MessageHandler> = { | |
| 127 | agent_message_chunk: (state, msg) => | |
| 128 | appendStreamText(state, "agent", blockText(msg.update?.content)), | |
| 129 | agent_thought_chunk: (state, msg) => | |
| 130 | appendStreamText(state, "thought", blockText(msg.update?.content)), | |
| 131 | tool_call: upsertToolCall, | |
| 132 | tool_call_update: upsertToolCall, | |
| 133 | plan: (state, msg) => ({ ...state, plan: msg.update?.entries ?? [] }), | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 134 | available_commands_update: (state, msg) => ({ |
| 135 | ...state, | |
| 136 | commands: msg.update?.availableCommands ?? [], | |
| 137 | }), | |
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 138 | }; |
| 139 | ||
| 140 | function reduceUpdate(state: ChatState, msg: ServerMessage): ChatState { | |
| 141 | const handler = msg.update | |
| 142 | ? updateHandlers[msg.update.sessionUpdate] | |
| 143 | : undefined; | |
| 144 | return handler ? handler(state, msg) : state; | |
| 145 | } | |
| 146 | ||
| 147 | /** blockText extracts the text of a single content block, if any. */ | |
| 148 | function blockText( | |
| 149 | content: AcpContentBlock | AcpToolCallContent[] | undefined, | |
| 150 | ): string { | |
| 151 | if (!content || Array.isArray(content)) { | |
| 152 | return ""; | |
| 153 | } | |
| 154 | return content.text ?? ""; | |
| 155 | } | |
| 156 | ||
| 157 | function withItem(state: ChatState, item: ThreadItem): ChatState { | |
| 158 | return { ...state, thread: [...state.thread, item] }; | |
| 159 | } | |
| 160 | ||
| 161 | /** Streamed chunks accumulate into the last open item of the same kind. */ | |
| 162 | function appendStreamText( | |
| 163 | state: ChatState, | |
| 164 | kind: "agent" | "thought", | |
| 165 | text: string, | |
| 166 | ): ChatState { | |
| 167 | const thread = [...state.thread]; | |
| 168 | const last = thread[thread.length - 1]; | |
| 169 | if (last && last.kind === kind && !last.closed) { | |
| 170 | thread[thread.length - 1] = { ...last, text: last.text + text }; | |
| 171 | return { ...state, thread }; | |
| 172 | } | |
| 173 | thread.push({ kind, text, closed: false }); | |
| 174 | return { ...state, thread }; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * upsertToolCall folds a tool_call or tool_call_update into the thread: | |
| 179 | * updates merge only the fields they carry, and an update for an unknown id | |
| 180 | * creates the entry (the adapter may resume a call after a reconnect). | |
| 181 | */ | |
| 182 | function upsertToolCall(state: ChatState, msg: ServerMessage): ChatState { | |
| 183 | const update = msg.update; | |
| 184 | if (!update || !update.toolCallId) { | |
| 185 | return state; | |
| 186 | } | |
| 187 | ||
| 188 | const contents = Array.isArray(update.content) ? update.content : undefined; | |
| 189 | const thread = [...state.thread]; | |
| 190 | const index = thread.findIndex( | |
| 191 | (item) => | |
| 192 | item.kind === "tool" && item.call.toolCallId === update.toolCallId, | |
| 193 | ); | |
| 194 | ||
| 195 | if (index >= 0) { | |
| 196 | const existing = thread[index] as Extract<ThreadItem, { kind: "tool" }>; | |
| 197 | thread[index] = { | |
| 198 | kind: "tool", | |
| 199 | call: { | |
| 200 | ...existing.call, | |
| 201 | title: update.title ?? existing.call.title, | |
| 202 | toolKind: update.kind ?? existing.call.toolKind, | |
| 203 | status: update.status ?? existing.call.status, | |
| 204 | contents: contents ?? existing.call.contents, | |
| 205 | locations: update.locations ?? existing.call.locations, | |
| 206 | }, | |
| 207 | }; | |
| 208 | return { ...state, thread }; | |
| 209 | } | |
| 210 | ||
| 211 | thread.push({ | |
| 212 | kind: "tool", | |
| 213 | call: { | |
| 214 | toolCallId: update.toolCallId, | |
| 215 | title: update.title ?? "Tool call", | |
| 216 | toolKind: update.kind ?? "other", | |
| 217 | status: update.status ?? "pending", | |
| 218 | contents: contents ?? [], | |
| 219 | locations: update.locations ?? [], | |
| 220 | }, | |
| 221 | }); | |
| 222 | return { ...state, thread }; | |
| 223 | } | |
| 224 | ||
| 225 | function addPermission(state: ChatState, msg: ServerMessage): ChatState { | |
| 226 | if ( | |
| 227 | !msg.requestId || | |
| 228 | state.permissions.some((p) => p.requestId === msg.requestId) | |
| 229 | ) { | |
| 230 | return state; | |
| 231 | } | |
| 232 | return { | |
| 233 | ...state, | |
| 234 | permissions: [ | |
| 235 | ...state.permissions, | |
| 236 | { | |
| 237 | requestId: msg.requestId, | |
| 238 | title: msg.request?.toolCall?.title ?? "The agent asks for permission", | |
| 239 | options: msg.request?.options ?? [], | |
| 240 | }, | |
| 241 | ], | |
| 242 | }; | |
| 243 | } | |
| 244 | ||
| 245 | /** turn_ended closes every streaming item so the next turn starts fresh. */ | |
| 246 | function closeStreamItems(thread: ThreadItem[]): ThreadItem[] { | |
| 247 | return thread.map((item) => | |
| 248 | item.kind === "agent" || item.kind === "thought" | |
| 249 | ? { ...item, closed: true } | |
| 250 | : item, | |
| 251 | ); | |
| 252 | } |