From 7245d972b8012f4ab69dd1bc423bee304495c929 Mon Sep 17 00:00:00 2001 From: Tanishq Kancharla Date: Wed, 11 Mar 2026 10:00:25 -0700 Subject: [PATCH] Fix queued-message rendering and improve webview/dev error diagnostics --- src/OpenCodeViewProvider.ts | 66 +++++++++++++++--- src/webview/App.tsx | 96 ++++++++++++++++++++------ src/webview/components/MessageList.tsx | 74 +++++++++++++------- src/webview/state/bootstrap.ts | 4 +- src/webview/state/eventHandlers.ts | 4 +- src/webview/utils/logger.ts | 37 +++++++++- tests/frontend/bootstrap.test.ts | 78 +++++++++++++++++++++ 7 files changed, 298 insertions(+), 61 deletions(-) diff --git a/src/OpenCodeViewProvider.ts b/src/OpenCodeViewProvider.ts index 5d60c9c..bf0a0b5 100644 --- a/src/OpenCodeViewProvider.ts +++ b/src/OpenCodeViewProvider.ts @@ -18,6 +18,14 @@ import { const LAST_AGENT_KEY = "opencode.lastUsedAgent"; +interface DevServerConfig { + origin: string; + port: number; + viteClientUrl: string; + mainEntryUrl: string; + wsOrigin: string; +} + export class OpenCodeViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = "opencode.chatView"; private _view?: vscode.WebviewView; @@ -39,6 +47,15 @@ export class OpenCodeViewProvider implements vscode.WebviewViewProvider { ) { const logger = getLogger(); logger.info("resolveWebviewView called"); + const devServerConfig = this._getDevServerConfig(); + if (devServerConfig) { + logger.info("[ViewProvider] Using dev server for webview", { + origin: devServerConfig.origin, + port: devServerConfig.port, + viteClientUrl: devServerConfig.viteClientUrl, + mainEntryUrl: devServerConfig.mainEntryUrl, + }); + } this._view = webviewView; this._webviewReady = false; @@ -46,12 +63,12 @@ export class OpenCodeViewProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "out")], - portMapping: process.env.OPENCODE_DEV_SERVER_URL - ? [{ webviewPort: 5173, extensionHostPort: 5173 }] + portMapping: devServerConfig + ? [{ webviewPort: devServerConfig.port, extensionHostPort: devServerConfig.port }] : [], }; - const html = this._getHtmlForWebview(webviewView.webview); + const html = this._getHtmlForWebview(webviewView.webview, devServerConfig); logger.info("Generated webview HTML length:", html.length); webviewView.webview.html = html; @@ -556,22 +573,53 @@ export class OpenCodeViewProvider implements vscode.WebviewViewProvider { } } - private _getHtmlForWebview(webview: vscode.Webview) { - const devServerUrl = process.env.OPENCODE_DEV_SERVER_URL; + private _getDevServerConfig(): DevServerConfig | null { + const raw = process.env.OPENCODE_DEV_SERVER_URL?.trim(); + if (!raw) return null; + + try { + const parsed = new URL(raw); + const originRoot = `${parsed.origin}/`; + const viteClientUrl = new URL("/@vite/client", originRoot).toString(); + const mainEntryUrl = new URL("/src/webview/main.tsx", originRoot).toString(); + + const wsUrl = new URL(originRoot); + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + + const port = + parsed.port.length > 0 + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : 80; + + return { + origin: parsed.origin, + port, + viteClientUrl, + mainEntryUrl, + wsOrigin: wsUrl.origin, + }; + } catch { + getLogger().warn("[ViewProvider] Invalid OPENCODE_DEV_SERVER_URL; falling back to bundled webview"); + return null; + } + } - if (devServerUrl) { + private _getHtmlForWebview(webview: vscode.Webview, devServerConfig: DevServerConfig | null) { + if (devServerConfig) { return ` - + OpenCode
- - + + `; } diff --git a/src/webview/App.tsx b/src/webview/App.tsx index c134654..a426b7d 100644 --- a/src/webview/App.tsx +++ b/src/webview/App.tsx @@ -93,6 +93,53 @@ function App() { // Get the current session key for drafts/agents const sessionKey = () => sync.currentSessionId() || NEW_SESSION_KEY; + + const getSdkErrorMessage = (error: unknown): string => { + if (typeof error === "string" && error.length > 0) return error; + if (!error || typeof error !== "object") return "Unknown error"; + + const record = error as Record; + const topLevelMessage = record.message; + if (typeof topLevelMessage === "string" && topLevelMessage.length > 0) { + return topLevelMessage; + } + + const data = record.data; + if (data && typeof data === "object") { + const dataMessage = (data as Record).message; + if (typeof dataMessage === "string" && dataMessage.length > 0) { + return dataMessage; + } + } + + const nestedError = record.error; + if (nestedError && typeof nestedError === "object") { + const nestedRecord = nestedError as Record; + const nestedMessage = nestedRecord.message; + if (typeof nestedMessage === "string" && nestedMessage.length > 0) { + return nestedMessage; + } + const nestedData = nestedRecord.data; + if (nestedData && typeof nestedData === "object") { + const nestedDataMessage = (nestedData as Record).message; + if (typeof nestedDataMessage === "string" && nestedDataMessage.length > 0) { + return nestedDataMessage; + } + } + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } + }; + + const getResponseStatus = (result: unknown): number | undefined => { + if (!result || typeof result !== "object") return undefined; + const response = (result as { response?: { status?: unknown } }).response; + return typeof response?.status === "number" ? response.status : undefined; + }; // Derive current session title from store const isDefaultTitle = (title: string) => /^(New session|Child session) - \d{4}-\d{2}-\d{2}T/.test(title); @@ -547,27 +594,27 @@ function App() { const result = await sendPrompt(sessionId, text, agent, extraParts, messageID); // Log the full result for debugging + const responseStatus = getResponseStatus(result); logger.info("sendPrompt result", { hasError: !!result?.error, hasData: !!result?.data, - response: result?.response?.status, + responseStatus, }); // Check for SDK error in result (SDK doesn't throw by default) if (result?.error) { + const errorMessage = getSdkErrorMessage(result.error); + // Log full error structure for debugging logger.error("sendPrompt returned error", { + sessionId, + messageID, + responseStatus, + errorMessage, error: result.error, response: result?.response, }); - - // Extract error message from nested structure: result.error may be { error: { data: { message } } } or { data: { message } } - const errorData = result.error as { data?: { message?: string }; error?: { data?: { message?: string } } }; - const errorMessage = - errorData.data?.message || - errorData.error?.data?.message || - (typeof errorData === 'string' ? errorData : JSON.stringify(errorData)) || - "Unknown error"; + sync.setThinking(sessionId, false); setInFlightMessage(null); sync.setSessionError(sessionId, errorMessage); @@ -622,15 +669,18 @@ function App() { const extraParts = buildSelectionParts(next.attachments); const result = await sendPrompt(sessionId, next.text, next.agent, extraParts, messageID); + const responseStatus = getResponseStatus(result); // Check for SDK error in result (SDK doesn't throw by default) if (result?.error) { - const errorData = result.error as { data?: { message?: string }; error?: { data?: { message?: string } } }; - const errorMessage = - errorData.data?.message || - errorData.error?.data?.message || - (typeof errorData === 'string' ? errorData : JSON.stringify(errorData)) || - "Unknown error"; + const errorMessage = getSdkErrorMessage(result.error); + logger.error("queue sendPrompt returned error", { + sessionId, + messageID, + responseStatus, + errorMessage, + error: result.error, + }); sync.setThinking(sessionId, false); setInFlightMessage(null); setMessageQueue([]); @@ -798,15 +848,19 @@ function App() { try { await revertToMessage(sessionId, messageId); const result = await sendPrompt(sessionId, newText.trim(), agent, [], newMessageID); + const responseStatus = getResponseStatus(result); // Check for SDK error in result (SDK doesn't throw by default) if (result?.error) { - const errorData = result.error as { data?: { message?: string }; error?: { data?: { message?: string } } }; - const errorMessage = - errorData.data?.message || - errorData.error?.data?.message || - (typeof errorData === 'string' ? errorData : JSON.stringify(errorData)) || - "Unknown error"; + const errorMessage = getSdkErrorMessage(result.error); + logger.error("edit sendPrompt returned error", { + sessionId, + messageId, + newMessageID, + responseStatus, + errorMessage, + error: result.error, + }); sync.setThinking(sessionId, false); setInFlightMessage(null); sync.setSessionError(sessionId, `Error editing message: ${errorMessage}`); diff --git a/src/webview/components/MessageList.tsx b/src/webview/components/MessageList.tsx index 185f81e..bfa877a 100644 --- a/src/webview/components/MessageList.tsx +++ b/src/webview/components/MessageList.tsx @@ -144,22 +144,39 @@ export function MessageList(props: MessageListProps) { return props.messages.findIndex(m => m.id === messageId); }; - // Find the last assistant message that hasn't completed yet - const pendingAssistantMessageId = createMemo(() => { + // Find the last assistant message that hasn't completed yet. + // We use message position (not ID comparison) to classify queued user messages. + const pendingAssistantMessageIndex = createMemo(() => { const msgs = props.messages; for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i]; if (msg.type === "assistant" && !msg.time?.completed) { - return msg.id; + return i; } } - return null; + return -1; }); - const isMessageQueued = (messageId: string, message: Message) => { - // User messages are queued if sent after pending assistant message started - const pending = pendingAssistantMessageId(); - return pending && message.type === "user" && messageId > pending; + const queuedMessageIds = createMemo(() => { + const queued = new Set(); + const msgs = props.messages; + const pendingIndex = pendingAssistantMessageIndex(); + + // Only show "queued" section while actively thinking. + if (!props.isThinking || pendingIndex === -1) return queued; + + for (let i = pendingIndex + 1; i < msgs.length; i++) { + const msg = msgs[i]; + if (msg.type === "user") { + queued.add(msg.id); + } + } + + return queued; + }); + + const isMessageQueued = (messageId: string) => { + return queuedMessageIds().has(messageId); }; const isMessageDimmed = (messageId: string) => { @@ -173,24 +190,29 @@ export function MessageList(props: MessageListProps) { return currentIndex > editingIndex; }; - // Split messages into non-queued and queued - const nonQueuedMessages = createMemo(() => { - const result = props.messages.filter(msg => !isMessageQueued(msg.id, msg)); - console.log("[MessageList] nonQueuedMessages memo recomputed", { total: props.messages.length, nonQueued: result.length }); - return result; - }); + const separatedMessages = createMemo(() => { + const nonQueued: Message[] = []; + const queued: Message[] = []; + const queuedIds = queuedMessageIds(); + + for (const message of props.messages) { + if (queuedIds.has(message.id)) { + queued.push(message); + } else { + nonQueued.push(message); + } + } - const queuedMessages = createMemo(() => { - const result = props.messages.filter(msg => isMessageQueued(msg.id, msg)); - console.log("[MessageList] queuedMessages memo recomputed", { total: props.messages.length, queued: result.length }); - return result; + return { nonQueued, queued }; }); - const renderMessage = (message: Message, index: () => number) => { - const isLastMessage = () => index() === props.messages.length - 1; - const isStreaming = () => isLastMessage() && props.isThinking && message.type === "assistant"; + const renderMessage = (message: Message) => { + const isStreaming = () => + props.messages[props.messages.length - 1]?.id === message.id && + props.isThinking && + message.type === "assistant"; const isEditing = () => props.editingMessageId === message.id; - const isQueued = () => isMessageQueued(message.id, message); + const isQueued = () => isMessageQueued(message.id); const isDimmed = () => isQueued() || isMessageDimmed(message.id); // Get the text content of the message for editing @@ -248,14 +270,14 @@ export function MessageList(props: MessageListProps) { return (
- - {(message, index) => renderMessage(message, index)} + + {(message) => renderMessage(message)} - - {(message, index) => renderMessage(message, index)} + + {(message) => renderMessage(message)} diff --git a/src/webview/state/bootstrap.ts b/src/webview/state/bootstrap.ts index 11f3977..b2965d0 100644 --- a/src/webview/state/bootstrap.ts +++ b/src/webview/state/bootstrap.ts @@ -195,10 +195,10 @@ export async function fetchBootstrapData(ctx: BootstrapContext): Promise !!m.id) - .sort((a, b) => a.id.localeCompare(b.id)); + .filter((m) => !!m.id); const session = sessionRes?.data; diff --git a/src/webview/state/eventHandlers.ts b/src/webview/state/eventHandlers.ts index 2b913cf..2302fd7 100644 --- a/src/webview/state/eventHandlers.ts +++ b/src/webview/state/eventHandlers.ts @@ -114,7 +114,9 @@ export function applyEvent(event: Event, ctx: EventHandlerContext): void { messageToSession.set(info.id, sessionId); if (!messages.length) { - console.log("[EventHandler] Creating message array for session", { sessionId, msgId: msg.id }); + console.log( + `[EventHandler] Creating message array for session sessionId=${sessionId} msgId=${msg.id}` + ); setStore("message", sessionId, [msg]); } else if (result.found) { setStore("message", sessionId, result.index, msg); diff --git a/src/webview/utils/logger.ts b/src/webview/utils/logger.ts index 9acceb7..3c66476 100644 --- a/src/webview/utils/logger.ts +++ b/src/webview/utils/logger.ts @@ -15,8 +15,41 @@ function getVscode(): { postMessage(message: unknown): void } | null { return null; } +function serializeForLog(value: unknown): string { + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (value instanceof Error) { + return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ""}`; + } + + try { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_key, current) => { + if (current instanceof Error) { + return { + name: current.name, + message: current.message, + stack: current.stack, + }; + } + if (typeof current === "object" && current !== null) { + if (seen.has(current)) return "[Circular]"; + seen.add(current); + } + return current; + }, + 2 + ); + } catch { + return String(value); + } +} + function log(level: "debug" | "info" | "error", message: string, data?: unknown) { const vscode = getVscode(); + const serialized = data !== undefined ? serializeForLog(data) : undefined; if (vscode) { // Send to extension host @@ -24,13 +57,13 @@ function log(level: "debug" | "info" | "error", message: string, data?: unknown) type: "log", level, message, - data, + data: serialized, }); } // Always log to console as well for debugging const prefix = `[OpenCode]`; - const logData = data !== undefined ? [message, data] : [message]; + const logData = serialized !== undefined ? [message, serialized] : [message]; switch (level) { case "debug": diff --git a/tests/frontend/bootstrap.test.ts b/tests/frontend/bootstrap.test.ts index 0038de2..a97fd5f 100644 --- a/tests/frontend/bootstrap.test.ts +++ b/tests/frontend/bootstrap.test.ts @@ -299,4 +299,82 @@ describe("Frontend Bootstrap", () => { expect(result.sessions).toHaveLength(0); expect(result.permissionMap).toEqual({}); }); + + it("should preserve message time metadata and API order from session.messages", async () => { + const harness = new GatekeeperHarness() + .add("appApi", () => new MockAppApi()) + .add("sessionApi", () => new MockSessionApi()) + .add("permissionApi", () => new MockPermissionApi()); + + harness.raiseAllGates(); + + const ctx: BootstrapContext = { + client: { + app: harness.appApi.intercept, + session: harness.sessionApi.intercept, + permission: harness.permissionApi.intercept, + }, + sessionId: "session-1", + workspaceRoot: "/test", + }; + + const resultPromise = fetchBootstrapData(ctx); + + const agentsCall = await harness.appApi.waitForCall("agents"); + await agentsCall.fulfill({ data: [] }); + + const sessionListCall = await harness.sessionApi.waitForCall("list"); + await sessionListCall.fulfill({ data: [] }); + + const permissionListCall = await harness.permissionApi.waitForCall("list"); + await permissionListCall.fulfill({ data: [] }); + + const messagesCall = await harness.sessionApi.waitForCall("messages"); + await messagesCall.fulfill({ + data: [ + { + info: { + id: "msg_ffffffffffffAAA", + role: "assistant", + time: { created: 20, completed: 30 }, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + parts: [{ id: "prt_2", type: "text", text: "second" }], + }, + { + info: { + id: "msg_000000000000AAA", + role: "user", + time: { created: 10 }, + }, + parts: [{ id: "prt_1", type: "text", text: "first" }], + }, + ], + }); + + const sessionGetCall = await harness.sessionApi.waitForCall("get"); + await sessionGetCall.fulfill({ + data: { + id: "session-1", + title: "Test Session", + projectID: "proj-1", + directory: "/test", + parentID: undefined, + time: { created: Date.now(), updated: Date.now() }, + }, + }); + + const result = await resultPromise; + + expect(result.messageList).toHaveLength(2); + expect(result.messageList[0].id).toBe("msg_ffffffffffffAAA"); + expect(result.messageList[1].id).toBe("msg_000000000000AAA"); + expect(result.messageList[0].time).toEqual({ created: 20, completed: 30 }); + expect(result.messageList[1].time).toEqual({ created: 10 }); + }); });