Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions src/OpenCodeViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,19 +47,28 @@ 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;

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;

Expand Down Expand Up @@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' ${devServerUrl}; script-src 'unsafe-inline' ${devServerUrl}; connect-src ${devServerUrl} ws://localhost:5173 http://127.0.0.1:* ws://127.0.0.1:* http://localhost:* ws://localhost:* ${webview.cspSource};">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' ${devServerConfig.origin}; script-src 'unsafe-inline' ${devServerConfig.origin}; connect-src ${devServerConfig.origin} ${devServerConfig.wsOrigin} http://127.0.0.1:* ws://127.0.0.1:* http://localhost:* ws://localhost:* ${webview.cspSource};">
<title>OpenCode</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<script type="module" src="${devServerUrl}/src/webview/main.tsx"></script>
<script type="module" src="${devServerConfig.viteClientUrl}"></script>
<script type="module" src="${devServerConfig.mainEntryUrl}"></script>
</body>
</html>`;
}
Expand Down
96 changes: 75 additions & 21 deletions src/webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>).message;
if (typeof dataMessage === "string" && dataMessage.length > 0) {
return dataMessage;
}
}

const nestedError = record.error;
if (nestedError && typeof nestedError === "object") {
const nestedRecord = nestedError as Record<string, unknown>;
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<string, unknown>).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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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}`);
Expand Down
74 changes: 48 additions & 26 deletions src/webview/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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) => {
Expand All @@ -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
Expand Down Expand Up @@ -248,14 +270,14 @@ export function MessageList(props: MessageListProps) {
return (
<div class="messages-container" ref={containerRef!} role="log" aria-label="Messages">
<div class="messages-content" ref={contentRef!}>
<For each={nonQueuedMessages()} fallback={null}>
{(message, index) => renderMessage(message, index)}
<For each={separatedMessages().nonQueued} fallback={null}>
{(message) => renderMessage(message)}
</For>

<ThinkingIndicator when={props.isThinking} />

<For each={queuedMessages()}>
{(message, index) => renderMessage(message, index)}
<For each={separatedMessages().queued}>
{(message) => renderMessage(message)}
</For>

<Show when={props.sessionError}>
Expand Down
4 changes: 2 additions & 2 deletions src/webview/state/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,10 @@ export async function fetchBootstrapData(ctx: BootstrapContext): Promise<Bootstr
id: messageId,
type: role,
text,
time: msgInfo.time,
} as Message;
})
.filter((m) => !!m.id)
.sort((a, b) => a.id.localeCompare(b.id));
.filter((m) => !!m.id);

const session = sessionRes?.data;

Expand Down
4 changes: 3 additions & 1 deletion src/webview/state/eventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading