This document summarizes the main pitfalls and mistakes discovered while stabilizing the OpenCode VS Code sidebar.
It exists so future contributors do not repeat the same failures.
WebviewViewlifecycle is easy to get wrong.- Host-to-webview messaging is much stricter than it first appears.
- Large sidebar bootstrap payloads can make a working implementation look broken.
- Raw SDK objects should not cross the webview boundary.
- Debugging webviews is harder when the protocol is too clever.
We repeatedly rewrote webview.html to push new state into the sidebar.
Why this was wrong:
- it remounts the webview
- it resets event listeners and UI-local state
- it creates race conditions around startup and message delivery
- it fights the intended VS Code model for
WebviewView
Correct direction:
- assign HTML when a specific
WebviewViewis resolved - use
postMessagefor live updates - use
getStateandsetStatefor persisted webview-local state
We used provider-level assumptions such as a one-time render flag.
Why this was wrong:
- VS Code can deallocate and recreate the underlying webview document
- a resolved
WebviewViewinstance must be initialized as that specific view - disposal and visibility transitions matter
Correct direction:
- initialize every resolved view fully
- treat dispose as real teardown
- avoid assuming a single immortal iframe
We introduced custom relay mechanisms and non-standard event shims.
Why this was wrong:
- it diverged from the official VS Code webview examples
- it increased the number of failure points
- it obscured whether the platform message channel itself was working
Correct direction:
- primary path should stay
webview.postMessage(...)pluswindow.addEventListener('message', ...) - only add fallbacks after the standard path is understood and instrumented
We passed raw SDK-shaped objects and framework-managed objects across boundaries.
Why this was wrong:
- some objects are not safe to clone or persist
- framework proxies caused failures in
postMessageandsetState - even when types compile, runtime cloning rules are stricter
Correct direction:
- normalize to plain JSON-safe DTOs in
src/shared/models.ts - clone draft state and context-chip state before
postMessageorsetState - keep raw SDK types host-side where possible
At one point bootstrap loaded full details for all sessions in the workspace.
Why this was wrong:
- a sidebar webview must reach a usable UI quickly
- loading full transcript state for many sessions makes the UI appear frozen
- this also made logs noisy and masked real failures
Correct direction:
- bootstrap with session summaries first
- lazily hydrate the active or selected session
- keep the first render path small
We initially sent a full session.snapshot on almost every event, including high-frequency streaming deltas.
Why this was wrong:
- huge amount of serialization work
- unnecessary churn in the webview
- degraded responsiveness during generation
Correct direction:
- coalesce hot-path updates
- keep snapshots cheap
- only refresh expensive state at meaningful boundaries
The webview message handler also wrote persistent state and emitted extra diagnostics during every update.
Why this was wrong:
- it increased the chance that message handling itself would fail
- it made debugging ambiguous because processing one host event triggered more host communication
Correct direction:
- keep receive-path logic minimal
- UI state updates first
- persistence and diagnostics should be conservative and isolated
The sidebar sometimes treated the extension repo as the active workspace.
Why this was wrong:
- confusing and dangerous behavior
- wrong sessions, providers, and file context
- made debugging much harder because the logs looked valid while targeting the wrong directory
Correct direction:
- only use real workspace folders, active editor folder, or explicit environment override
- if no workspace root exists, fail clearly
We added session switching and model/agent controls while the basic webview reliability problem was still unresolved.
Why this was wrong:
- feature work created more state transitions before the underlying transport was trustworthy
- regressions became harder to isolate
Correct direction:
- stabilize the message loop first
- then add UI features on top of a known-good core
- Moving closer to the official VS Code webview guidance.
- Reducing host/webview payloads to explicit DTOs.
- Coalescing snapshot updates instead of pushing on every event.
- Loading session details lazily instead of eagerly.
- Adding host acknowledgements and a compatibility fallback for environments where host-to-webview updates behaved unexpectedly.
- Do not reintroduce HTML rewrites as the primary state transport.
- Do not send raw SDK or framework proxy objects to the webview.
- Keep sidebar bootstrap cheap.
- Coalesce streaming updates.
- Keep the standard VS Code webview messaging pattern as the primary path.
- When behavior is unclear, instrument one layer at a time.
We assumed calling client.session.revert() would permanently delete messages and trigger message.removed events from the server. When the webview still displayed the reverted messages, we thought the SDK call failed or the state needed a manual reload.
Why this was wrong:
- The OpenCode server's
/session/:sessionID/revertendpoint does not delete messages from the database. - Instead, it soft-deletes them by appending a
revert: { messageID: string }property to theSessionobject. - The official CLI/TUI handles this by filtering messages on the client-side (
m.id < session.revert.messageID). Our webview naively rendered all messages it received, displaying the "deleted" messages.
Correct direction:
- The host must intercept
session.info.revertduring state serialization. - Filter out messages, pending permissions, and pending questions where
id >= revert.messageIDbefore sending theSessionStatepayload to the webview.
We used addOptimisticUserMessage to inject a fake user message into the UI while waiting for the server to acknowledge a prompt. We gave it a fake ID (local-<timestamp>).
Why this was wrong:
- OpenCode generates message IDs starting with
msg_. - Alphabetical string sorting (
localeCompare) placedlocal-beforemsg_, permanently pinning the fake optimistic message to the top of the chat. - Because the fake ID never matched the real
msg_ID returned via Server-Sent Events,upsertByIdnever replaced it. It became a permanent duplicate.
Correct direction:
- Server-Sent Events (SSE) from a local server are virtually instantaneous.
- Do not use optimistic UI updates for server-authoritative lists like messages. Wait for the real
message.createdstream to provide the definitive state and ID.