| @@ -1,84 +1,69 @@ |
| 1 | /** | 1 | /** |
| 2 | * TaskMessage — the live-updating Discord message for a running task. | 2 | * TaskMessage — self-contained live-updating Discord message for a task. |
| 3 | * | 3 | * |
| 4 | * Shows: | 4 | * Subscribes to the server's SSE stream via Eden Treaty on mount, |
| 5 | * - Current status (thinking, generating code, running, etc.) | 5 | * reduces TaskEvents into local state, and re-renders reactively. |
| 6 | * - Code blocks when code is generated | 6 | * |
| 7 | * - Tool call results | 7 | * The command handler just mounts this component and walks away: |
| 8 | * - Approval buttons when approval is required | 8 | * <TaskMessage taskId={id} prompt={prompt} api={api} /> |
| 9 | * - Final agent response when complete | | |
| 10 | */ | 9 | */ |
| 11 | | 10 | |
| | 11 | import { useState, useEffect } from "react"; |
| 12 | import { | 12 | import { |
| 13 | Container, | 13 | Container, |
| 14 | TextDisplay, | 14 | TextDisplay, |
| 15 | Separator, | 15 | Separator, |
| 16 | Section, | | |
| 17 | ActionRow, | 16 | ActionRow, |
| 18 | Button, | 17 | Button, |
| 19 | Loading, | 18 | Loading, |
| 20 | useInstance, | 19 | useInstance, |
| 21 | } from "@openassistant/reacord"; | 20 | } from "@openassistant/reacord"; |
| 22 | import type { ButtonInteraction } from "discord.js"; | 21 | import type { Client as ApiClient } from "@openassistant/server/client"; |
| | 22 | import { unwrap } from "@openassistant/server/client"; |
| 23 | import type { TaskEvent } from "@openassistant/core/events"; | 23 | import type { TaskEvent } from "@openassistant/core/events"; |
| 24 | | 24 | |
| 25 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 26 | // Types | 26 | // State |
| 27 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 28 | | 28 | |
| 29 | export interface PendingApproval { | 29 | interface PendingApproval { |
| 30 | readonly id: string; | 30 | readonly id: string; |
| 31 | readonly toolPath: string; | 31 | readonly toolPath: string; |
| 32 | readonly preview: { title: string; details?: string }; | 32 | readonly preview: { title: string; details?: string }; |
| 33 | } | 33 | } |
| 34 | | 34 | |
| 35 | export interface TaskState { | 35 | interface TaskState { |
| 36 | readonly status: "running" | "completed" | "failed" | "cancelled"; | 36 | readonly status: "running" | "completed" | "failed" | "cancelled"; |
| 37 | readonly statusMessage: string; | 37 | readonly statusMessage: string; |
| 38 | readonly codeBlocks: string[]; | | |
| 39 | readonly toolResults: string[]; | 38 | readonly toolResults: string[]; |
| 40 | readonly pendingApprovals: PendingApproval[]; | 39 | readonly pendingApprovals: PendingApproval[]; |
| 41 | readonly agentMessage: string | null; | 40 | readonly agentMessage: string | null; |
| 42 | readonly error: string | null; | 41 | readonly error: string | null; |
| 43 | } | 42 | } |
| 44 | | 43 | |
| 45 | export function initialTaskState(): TaskState { | 44 | const INITIAL_STATE: TaskState = { |
| 46 | return { | 45 | status: "running", |
| 47 | status: "running", | 46 | statusMessage: "Thinking...", |
| 48 | statusMessage: "Thinking...", | 47 | toolResults: [], |
| 49 | codeBlocks: [], | 48 | pendingApprovals: [], |
| 50 | toolResults: [], | 49 | agentMessage: null, |
| 51 | pendingApprovals: [], | 50 | error: null, |
| 52 | agentMessage: null, | 51 | }; |
| 53 | error: null, | | |
| 54 | }; | | |
| 55 | } | | |
| 56 | | 52 | |
| 57 | /** | 53 | function reduceEvent(state: TaskState, event: TaskEvent): TaskState { |
| 58 | * Reduce a TaskEvent into the current TaskState. | | |
| 59 | */ | | |
| 60 | export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { | | |
| 61 | switch (event.type) { | 54 | switch (event.type) { |
| 62 | case "status": | 55 | case "status": |
| 63 | return { ...state, statusMessage: event.message }; | 56 | return { ...state, statusMessage: event.message }; |
| 64 | | 57 | |
| 65 | case "code_generated": | 58 | case "code_generated": |
| 66 | return { | 59 | return { ...state, statusMessage: "Running code..." }; |
| 67 | ...state, | | |
| 68 | codeBlocks: [...state.codeBlocks, event.code], | | |
| 69 | statusMessage: "Running code...", | | |
| 70 | }; | | |
| 71 | | 60 | |
| 72 | case "approval_request": | 61 | case "approval_request": |
| 73 | return { | 62 | return { |
| 74 | ...state, | 63 | ...state, |
| 75 | pendingApprovals: [ | 64 | pendingApprovals: [ |
| 76 | ...state.pendingApprovals, | 65 | ...state.pendingApprovals, |
| 77 | { | 66 | { id: event.id, toolPath: event.toolPath, preview: event.preview }, |
| 78 | id: event.id, | | |
| 79 | toolPath: event.toolPath, | | |
| 80 | preview: event.preview, | | |
| 81 | }, | | |
| 82 | ], | 67 | ], |
| 83 | statusMessage: "Waiting for approval...", | 68 | statusMessage: "Waiting for approval...", |
| 84 | }; | 69 | }; |
| @@ -87,17 +72,14 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 2 unmodified lines |
| 87 | return { | 72 | return { |
| 88 | ...state, | 73 | ...state, |
| 89 | pendingApprovals: state.pendingApprovals.filter((a) => a.id !== event.id), | 74 | pendingApprovals: state.pendingApprovals.filter((a) => a.id !== event.id), |
| 90 | statusMessage: event.decision === "approved" ? "Approved, continuing..." : "Denied, continuing...", | 75 | statusMessage: event.decision === "approved" ? "Approved, continuing..." : "Denied.", |
| 91 | }; | 76 | }; |
| 92 | | 77 | |
| 93 | case "tool_result": { | 78 | case "tool_result": { |
| 94 | const r = event.receipt; | 79 | const r = event.receipt; |
| 95 | const status = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c"; | 80 | const icon = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c"; |
| 96 | const line = `${status} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`; | 81 | const line = `${icon} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`; |
| 97 | return { | 82 | return { ...state, toolResults: [...state.toolResults, line] }; |
| 98 | ...state, | | |
| 99 | toolResults: [...state.toolResults, line], | | |
| 100 | }; | | |
| 101 | } | 83 | } |
| 102 | | 84 | |
| 103 | case "agent_message": | 85 | case "agent_message": |
| @@ -111,63 +93,87 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 7 unmodified lines |
| 111 | } | 93 | } |
| 112 | } | 94 | } |
| 113 | | 95 | |
| 114 | // --------------------------------------------------------------------------- | | |
| 115 | // Status indicator | | |
| 116 | // --------------------------------------------------------------------------- | | |
| 117 | | | |
| 118 | function statusEmoji(status: TaskState["status"]): string { | | |
| 119 | switch (status) { | | |
| 120 | case "running": return "\u23f3"; | | |
| 121 | case "completed": return "\u2705"; | | |
| 122 | case "failed": return "\u274c"; | | |
| 123 | case "cancelled": return "\u26d4"; | | |
| 124 | } | | |
| 125 | } | | |
| 126 | | | |
| 127 | // --------------------------------------------------------------------------- | 96 | // --------------------------------------------------------------------------- |
| 128 | // Component | 97 | // Component |
| 129 | // --------------------------------------------------------------------------- | 98 | // --------------------------------------------------------------------------- |
| 130 | | 99 | |
| 131 | export interface TaskMessageProps { | 100 | export interface TaskMessageProps { |
| | 101 | readonly taskId: string; |
| 132 | readonly prompt: string; | 102 | readonly prompt: string; |
| 133 | readonly state: TaskState; | 103 | readonly api: ApiClient; |
| 134 | readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void; | | |
| 135 | readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void; | | |
| 136 | } | 104 | } |
| 137 | | 105 | |
| 138 | export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessageProps) { | 106 | export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { |
| 139 | const instance = useInstance(); | 107 | const instance = useInstance(); |
| | 108 | const [state, setState] = useState<TaskState>(INITIAL_STATE); |
| | 109 | |
| | 110 | // Subscribe to SSE stream on mount |
| | 111 | useEffect(() => { |
| | 112 | let cancelled = false; |
| | 113 | |
| | 114 | const subscribe = async () => { |
| | 115 | try { |
| | 116 | const { data: stream, error } = await api.api.tasks({ id: taskId }).events.get(); |
| | 117 | |
| | 118 | if (error || !stream) { |
| | 119 | if (!cancelled) { |
| | 120 | setState((s) => ({ ...s, status: "failed", error: "Failed to connect to event stream", statusMessage: "Failed" })); |
| | 121 | } |
| | 122 | return; |
| | 123 | } |
| | 124 | |
| | 125 | for await (const sse of stream as AsyncIterable<{ event: string; data: TaskEvent }>) { |
| | 126 | if (cancelled) break; |
| | 127 | setState((s) => reduceEvent(s, sse.data)); |
| | 128 | } |
| | 129 | } catch (err) { |
| | 130 | if (!cancelled) { |
| | 131 | setState((s) => ({ |
| | 132 | ...s, |
| | 133 | status: "failed", |
| | 134 | error: err instanceof Error ? err.message : String(err), |
| | 135 | statusMessage: "Failed", |
| | 136 | })); |
| | 137 | } |
| | 138 | } |
| | 139 | }; |
| | 140 | |
| | 141 | subscribe(); |
| | 142 | |
| | 143 | return () => { |
| | 144 | cancelled = true; |
| | 145 | }; |
| | 146 | }, [taskId]); |
| | 147 | |
| | 148 | // Deactivate buttons once done |
| | 149 | useEffect(() => { |
| | 150 | if (state.status !== "running") { |
| | 151 | const timer = setTimeout(() => instance.deactivate(), 5000); |
| | 152 | return () => clearTimeout(timer); |
| | 153 | } |
| | 154 | }, [state.status]); |
| 140 | | 155 | |
| 141 | // Deactivate interactive components when task is done | | |
| 142 | const isDone = state.status !== "running"; | 156 | const isDone = state.status !== "running"; |
| | 157 | const accentColor = isDone |
| | 158 | ? state.status === "completed" ? 0x57f287 : 0xed4245 |
| | 159 | : 0x5865f2; |
| 143 | | 160 | |
| 144 | return ( | 161 | return ( |
| 145 | <Container accentColor={isDone ? (state.status === "completed" ? 0x57f287 : 0xed4245) : 0x5865f2}> | 162 | <Container accentColor={accentColor}> |
| 146 | {/* Header */} | | |
| 147 | <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay> | 163 | <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay> |
| 148 | <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay> | 164 | <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay> |
| 149 | | 165 | |
| 150 | {/* Tool results */} | | |
| 151 | {state.toolResults.length > 0 && ( | 166 | {state.toolResults.length > 0 && ( |
| 152 | <> | 167 | <> |
| 153 | <Separator /> | 168 | <Separator /> |
| 154 | <TextDisplay> | 169 | <TextDisplay>{state.toolResults.slice(-10).join("\n")}</TextDisplay> |
| 155 | {state.toolResults.slice(-10).join("\n")} | | |
| 156 | </TextDisplay> | | |
| 157 | </> | 170 | </> |
| 158 | )} | 171 | )} |
| 159 | | 172 | |
| 160 | {/* Pending approvals */} | | |
| 161 | {state.pendingApprovals.map((approval) => ( | 173 | {state.pendingApprovals.map((approval) => ( |
| 162 | <ApprovalSection | 174 | <ApprovalButtons key={approval.id} approval={approval} api={api} /> |
| 163 | key={approval.id} | | |
| 164 | approval={approval} | | |
| 165 | onApprove={onApprove} | | |
| 166 | onDeny={onDeny} | | |
| 167 | /> | | |
| 168 | ))} | 175 | ))} |
| 169 | | 176 | |
| 170 | {/* Error */} | | |
| 171 | {state.error && ( | 177 | {state.error && ( |
| 172 | <> | 178 | <> |
| 173 | <Separator /> | 179 | <Separator /> |
| @@ -175,7 +181,6 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line |
| 175 | </> | 181 | </> |
| 176 | )} | 182 | )} |
| 177 | | 183 | |
| 178 | {/* Final response */} | | |
| 179 | {state.agentMessage && ( | 184 | {state.agentMessage && ( |
| 180 | <> | 185 | <> |
| 181 | <Separator /> | 186 | <Separator /> |
| @@ -187,8 +192,7 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 5 unmodified lines |
| 187 | </> | 192 | </> |
| 188 | )} | 193 | )} |
| 189 | | 194 | |
| 190 | {/* Loading indicator while running */} | 195 | {state.status === "running" && state.pendingApprovals.length === 0 && ( |
| 191 | {state.status === "running" && !state.pendingApprovals.length && ( | | |
| 192 | <Loading /> | 196 | <Loading /> |
| 193 | )} | 197 | )} |
| 194 | </Container> | 198 | </Container> |
| @@ -196,36 +200,46 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line |
| 196 | } | 200 | } |
| 197 | | 201 | |
| 198 | // --------------------------------------------------------------------------- | 202 | // --------------------------------------------------------------------------- |
| 199 | // Approval sub-component | 203 | // Approval buttons |
| 200 | // --------------------------------------------------------------------------- | 204 | // --------------------------------------------------------------------------- |
| 201 | | 205 | |
| 202 | interface ApprovalSectionProps { | 206 | function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) { |
| 203 | readonly approval: PendingApproval; | 207 | const [resolved, setResolved] = useState(false); |
| 204 | readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void; | 208 | |
| 205 | readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void; | 209 | const handle = async (decision: "approved" | "denied") => { |
| 206 | } | 210 | setResolved(true); |
| | 211 | try { |
| | 212 | await unwrap(api.api.approvals({ callId: approval.id }).post({ decision })); |
| | 213 | } catch (err) { |
| | 214 | console.error(`[approval ${approval.id}]`, err); |
| | 215 | } |
| | 216 | }; |
| 207 | | 217 | |
| 208 | function ApprovalSection({ approval, onApprove, onDeny }: ApprovalSectionProps) { | | |
| 209 | return ( | 218 | return ( |
| 210 | <> | 219 | <> |
| 211 | <Separator /> | 220 | <Separator /> |
| 212 | <TextDisplay> | 221 | <TextDisplay> |
| 213 | {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`} | 222 | {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`} |
| 214 | </TextDisplay> | 223 | </TextDisplay> |
| 215 | <ActionRow> | 224 | {!resolved && ( |
| 216 | <Button | 225 | <ActionRow> |
| 217 | label="Approve" | 226 | <Button label="Approve" style="success" onClick={() => handle("approved")} /> |
| 218 | style="success" | 227 | <Button label="Deny" style="danger" onClick={() => handle("denied")} /> |
| 219 | emoji="\u2705" | 228 | </ActionRow> |
| 220 | onClick={(interaction) => onApprove?.(approval.id, interaction)} | 229 | )} |
| 221 | /> | | |
| 222 | <Button | | |
| 223 | label="Deny" | | |
| 224 | style="danger" | | |
| 225 | emoji="\u274c" | | |
| 226 | onClick={(interaction) => onDeny?.(approval.id, interaction)} | | |
| 227 | /> | | |
| 228 | </ActionRow> | | |
| 229 | </> | 230 | </> |
| 230 | ); | 231 | ); |
| 231 | } | 232 | } |
| | 233 | |
| | 234 | // --------------------------------------------------------------------------- |
| | 235 | // Helpers |
| | 236 | // --------------------------------------------------------------------------- |
| | 237 | |
| | 238 | function statusEmoji(status: TaskState["status"]): string { |
| | 239 | switch (status) { |
| | 240 | case "running": return "\u23f3"; |
| | 241 | case "completed": return "\u2705"; |
| | 242 | case "failed": return "\u274c"; |
| | 243 | case "cancelled": return "\u26d4"; |
| | 244 | } |
| | 245 | } |