From 33a5b60a9849253359140ec54dffb3599b4387c5 Mon Sep 17 00:00:00 2001 From: Slava Trofimov Date: Mon, 1 Jun 2026 17:47:26 -0400 Subject: [PATCH] improve desktop chat history and monitoring --- desktop/src/main/index.ts | 6 +- .../src/renderer/src/components/ChatView.tsx | 194 ++++++++++++++++-- desktop/src/renderer/src/styles.css | 57 ++--- src/octopal/gateway/ws.py | 100 ++++++++- src/octopal/runtime/octo/message_runtime.py | 3 + src/octopal/runtime/octo/router.py | 52 +++++ src/octopal/runtime/octo/worker_dispatch.py | 12 +- tests/test_gateway_ws_resolution.py | 72 ++++++- tests/test_octo_tool_loop.py | 50 +++++ 9 files changed, 483 insertions(+), 63 deletions(-) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 3025c9ff..c08ee865 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1984,9 +1984,13 @@ ipcMain.handle( ipcMain.handle( "desktop:chat-approval-response", async (_event, intentId: string, approved: boolean) => { + const normalizedIntentId = String(intentId || "").trim(); + if (!normalizedIntentId) { + throw new Error("Approval request id is missing."); + } sendDesktopChatPayload({ type: "approval_response", - intent_id: String(intentId || ""), + intent_id: normalizedIntentId, approved: Boolean(approved), }); return { ok: true }; diff --git a/desktop/src/renderer/src/components/ChatView.tsx b/desktop/src/renderer/src/components/ChatView.tsx index 3603d568..f162098f 100644 --- a/desktop/src/renderer/src/components/ChatView.tsx +++ b/desktop/src/renderer/src/components/ChatView.tsx @@ -29,6 +29,7 @@ type ChatItem = { text: string; createdAt: string; meta?: Record; + technical?: boolean; attachments?: DesktopChatAttachment[]; intentId?: string; raw?: DesktopChatEvent; @@ -50,6 +51,14 @@ function recordValue(value: unknown): Record { : {}; } +function recordArray(value: unknown): Record[] { + return Array.isArray(value) + ? value + .filter((item) => item !== null && typeof item === "object" && !Array.isArray(item)) + .map((item) => item as Record) + : []; +} + function eventMeta(event: DesktopChatEvent): Record { const meta = recordValue(event.meta); return Object.keys(meta).length > 0 ? meta : recordValue(event.payload); @@ -120,19 +129,78 @@ function eventChannel(event: DesktopChatEvent): string { ); } +function isTechnicalEvent(event: DesktopChatEvent): boolean { + const type = stringValue(event.type); + return type === "progress"; +} + function isWebSocketTakeoverNotice(text: string): boolean { return text .toLowerCase() .includes("another websocket client connected and took over this session"); } +function workerSnapshotName(worker: Record): string { + return ( + stringValue(worker.template_id) || + stringValue(worker.worker_template_id) || + stringValue(worker.template_name) || + stringValue(worker.name) || + stringValue(worker.id, "worker") + ); +} + +function workerSnapshotText(worker: Record): string { + const name = workerSnapshotName(worker); + const status = stringValue(worker.status, "unknown").toLowerCase(); + if (status === "running") { + return `${name} worker is running.`; + } + if (status === "waiting_for_children") { + return `${name} worker is waiting for child workers.`; + } + if (status === "awaiting_instruction") { + return `${name} worker is awaiting instruction.`; + } + if (["started", "completed", "failed", "stopped"].includes(status)) { + return `${name} worker ${status}.`; + } + return `${name} worker status: ${status}.`; +} + +function chatItemFromWorkerSnapshot( + worker: Record, + index: number, +): ChatItem { + const createdAt = + stringValue(worker.updated_at) || + stringValue(worker.created_at) || + new Date().toISOString(); + const workerId = stringValue(worker.id, `worker-${index}`); + const status = stringValue(worker.status, "unknown"); + return { + id: `worker-${workerId}-${status}-${createdAt}`, + kind: "event", + type: "worker_snapshot", + role: "system", + direction: "event", + channel: "runtime", + text: workerSnapshotText(worker), + createdAt, + meta: worker, + technical: true, + }; +} + function chatItemFromEvent( event: DesktopChatEvent, index: number, ): ChatItem | null { const type = stringValue(event.type, "event"); const createdAt = stringValue(event.created_at) || new Date().toISOString(); - const baseId = `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`; + const eventId = stringValue(event.id); + const baseId = + eventId || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`; const text = eventText(event); if (type === "chat_message" || type === "message") { @@ -167,7 +235,7 @@ function chatItemFromEvent( }; } - if (["workers_snapshot", "pong", "typing"].includes(type)) { + if (["workers_snapshot", "pong", "typing", "worker_event"].includes(type)) { return null; } @@ -175,7 +243,7 @@ function chatItemFromEvent( return null; } - if (["progress", "worker_event", "file", "warning", "error"].includes(type)) { + if (["progress", "file", "warning", "error"].includes(type)) { return { id: baseId, kind: "event", @@ -186,6 +254,7 @@ function chatItemFromEvent( text, createdAt, meta: eventMeta(event), + technical: isTechnicalEvent(event), attachments: type === "file" ? fileAttachmentFromEvent(event) : undefined, raw: event, }; @@ -194,6 +263,35 @@ function chatItemFromEvent( return null; } +function chatItemsFromEvent( + event: DesktopChatEvent, + index: number, +): ChatItem[] { + const type = stringValue(event.type); + if (type === "chat_history") { + return recordArray(event.messages) + .map((message, messageIndex) => + chatItemFromEvent(message as DesktopChatEvent, index + messageIndex), + ) + .filter((item): item is ChatItem => item !== null); + } + if (type === "workers_snapshot") { + return recordArray(event.workers).map((worker, workerIndex) => + chatItemFromWorkerSnapshot(worker, index + workerIndex), + ); + } + const item = chatItemFromEvent(event, index); + return item ? [item] : []; +} + +function mergeUniqueItems(current: ChatItem[], next: ChatItem[]): ChatItem[] { + if (next.length === 0) { + return current; + } + const nextIds = new Set(next.map((item) => item.id)); + return [...current.filter((item) => !nextIds.has(item.id)), ...next].slice(-300); +} + function localUserMessage( text: string, attachments: DesktopChatAttachment[], @@ -231,6 +329,10 @@ function isDuplicateLocalEcho(item: ChatItem, event: DesktopChatEvent): boolean ); } +function approvalResolution(item: ChatItem): string { + return stringValue(item.meta?.resolved); +} + function formatTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -322,19 +424,61 @@ export function ChatView({ active, installDir }: ChatViewProps) { return; } + if (stringValue(event.type) === "approval_result") { + const intentId = stringValue(event.intent_id); + if (!intentId) { + return; + } + const resolved = Boolean(event.ok) + ? Boolean(event.approved) + ? "approved" + : "denied" + : "failed"; + setItems((current) => + current.map((item) => + item.intentId === intentId + ? { + ...item, + meta: { + ...(item.meta ?? {}), + resolved, + approval_result_message: stringValue(event.message), + }, + } + : item, + ), + ); + if (!event.ok) { + setSendError( + stringValue(event.message, "Approval request is no longer pending."), + ); + } + return; + } + eventCount.current += 1; - const item = chatItemFromEvent(event, eventCount.current); - if (!item) { + const nextItems = chatItemsFromEvent(event, eventCount.current); + if (nextItems.length === 0) { return; } - if (item.role === "assistant" || item.type === "error") { + if (nextItems.some((item) => item.role === "assistant" || item.type === "error")) { setThinking(false); } setItems((current) => { - if (current.some((existing) => isDuplicateLocalEcho(existing, event))) { + if ( + nextItems.length === 1 && + current.some((existing) => isDuplicateLocalEcho(existing, event)) + ) { return current; } - return [...current, item].slice(-300); + if (stringValue(event.type) === "chat_history") { + const nextIds = new Set(nextItems.map((item) => item.id)); + return [ + ...nextItems, + ...current.filter((item) => !nextIds.has(item.id)), + ].slice(-300); + } + return mergeUniqueItems(current, nextItems); }); }); @@ -481,13 +625,6 @@ export function ChatView({ active, installDir }: ChatViewProps) { className={active ? "chat-view" : "chat-view chat-view-hidden"} aria-label="Desktop chat" > -
-
-

Desktop channel

-

Chat

-
-
-
{sortedItems.length === 0 ? (
@@ -502,14 +639,16 @@ export function ChatView({ active, installDir }: ChatViewProps) { className={ item.kind === "message" ? `chat-bubble chat-bubble-${item.role === "user" ? "user" : "assistant"}` - : `chat-event chat-event-${item.type}` + : `chat-event chat-event-${item.type}${item.technical ? " chat-event-technical" : ""}` } > -
- {senderLabel(item)} - {item.kind === "message" ? null : {item.type}} - {formatTime(item.createdAt)} -
+ {item.technical ? null : ( +
+ {senderLabel(item)} + {item.kind === "message" ? null : {item.type}} + {formatTime(item.createdAt)} +
+ )} {item.attachments?.some(isImageAttachment) ? (
@@ -524,7 +663,18 @@ export function ChatView({ active, installDir }: ChatViewProps) { )}
) : null} - {item.kind === "approval" && item.intentId ? ( + {item.kind === "approval" && approvalResolution(item) ? ( +

+ {approvalResolution(item) === "approved" + ? "Approved" + : approvalResolution(item) === "denied" + ? "Denied" + : stringValue( + item.meta?.approval_result_message, + "Approval request is no longer pending.", + )} +

+ ) : item.kind === "approval" && item.intentId ? (