From 7dca5527cab13ca9e634220e9359bbd7a849e8ab Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Thu, 16 Apr 2026 23:36:09 -0400 Subject: [PATCH 1/6] chore: open PR for analysis and understanding From 5ddde0d072652fe1dae22b756f69f143a70cbca6 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Fri, 17 Apr 2026 00:01:42 -0400 Subject: [PATCH 2/6] docs: add analysis and understanding materials --- .../01-high-level-architecture.svg | 239 +++++++++++ .../02-session-message-flow.svg | 262 ++++++++++++ .../03-package-dependency-graph.svg | 183 +++++++++ .../04-core-modules.svg | 373 ++++++++++++++++++ .../05-event-bus-instance-state.svg | 151 +++++++ .../06-data-model-erd.svg | 280 +++++++++++++ .../07-startup-bootstrap.svg | 166 ++++++++ .../08-tool-invocation-permission.svg | 315 +++++++++++++++ .../09-skills-plugins-injection.svg | 281 +++++++++++++ .../10-skills-plugins-runtime-sequence.svg | 283 +++++++++++++ ...desktop-architecture-tauri-vs-electron.svg | 246 ++++++++++++ .../12-desktop-bootstrap-sequence.svg | 255 ++++++++++++ analysis-and-understanding/README.md | 85 ++++ .../opencode-architecture.md | 85 ++++ .../opencode-desktop-dev-setup.md | 130 ++++++ .../opencode-desktop-testing.md | 87 ++++ .../opencode-desktop.md | 262 ++++++++++++ 17 files changed, 3683 insertions(+) create mode 100644 analysis-and-understanding/01-high-level-architecture.svg create mode 100644 analysis-and-understanding/02-session-message-flow.svg create mode 100644 analysis-and-understanding/03-package-dependency-graph.svg create mode 100644 analysis-and-understanding/04-core-modules.svg create mode 100644 analysis-and-understanding/05-event-bus-instance-state.svg create mode 100644 analysis-and-understanding/06-data-model-erd.svg create mode 100644 analysis-and-understanding/07-startup-bootstrap.svg create mode 100644 analysis-and-understanding/08-tool-invocation-permission.svg create mode 100644 analysis-and-understanding/09-skills-plugins-injection.svg create mode 100644 analysis-and-understanding/10-skills-plugins-runtime-sequence.svg create mode 100644 analysis-and-understanding/11-desktop-architecture-tauri-vs-electron.svg create mode 100644 analysis-and-understanding/12-desktop-bootstrap-sequence.svg create mode 100644 analysis-and-understanding/README.md create mode 100644 analysis-and-understanding/opencode-architecture.md create mode 100644 analysis-and-understanding/opencode-desktop-dev-setup.md create mode 100644 analysis-and-understanding/opencode-desktop-testing.md create mode 100644 analysis-and-understanding/opencode-desktop.md diff --git a/analysis-and-understanding/01-high-level-architecture.svg b/analysis-and-understanding/01-high-level-architecture.svg new file mode 100644 index 000000000000..cfbfd27592c9 --- /dev/null +++ b/analysis-and-understanding/01-high-level-architecture.svg @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + Client Layer + + + + + SDK Layer + + + + + Server (Hono HTTP/WS) + + + + + Core Runtime + + + + + Service Layer + + + + + Persistence + + + + + External + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HTTP/WS + + + + HTTP/WS + + + + HTTP/WS + + + + typed client + + + + TUI (opencode)Ink/React + + + + Desktop AppTauri + React + + + + Web Apppackages/web + + + + CLI commandsrun/generate/agent/models + + + + ACPAgent Client Protocol + + + + JS/TS SDK@opencode-ai/sdk + + + + HTTP Routes + + + + WebSocketevent stream + + + + MiddlewareAuth / CORS /Compression / Fence + + + + mDNS Discovery + + + + Session Processor + + + + Agentbuild / plan / general + + + + Event Bus (PubSub) + + + + Permission System + + + + Plugin System + + + + ProviderAnthropic, OpenAI,Google, Bedrock... + + + + Toolsbash/edit/read/grep/glob/lsp + + + + LSPLauncher & Client + + + + MCP Clientexternal tools + + + + Skills + + + + Snapshot (git) + + + + SQLiteDrizzle ORM + + + + Storagesessions, messages, parts + + + + Shareremote sync + + + + LLM ProvidersAI SDK v3 + + + + GitHub / PR + + + + MCP Servers + + + + LSP Servers + + \ No newline at end of file diff --git a/analysis-and-understanding/02-session-message-flow.svg b/analysis-and-understanding/02-session-message-flow.svg new file mode 100644 index 000000000000..d3335afe8c4d --- /dev/null +++ b/analysis-and-understanding/02-session-message-flow.svg @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + loop [Streaming chunks] + + + + + alt [Permission required] + + + + + alt [Tool call requested] + + + + + alt [Needs compaction] + + + + + + + + + + + + + + + + + Type message + Enter + + + + POST /session/:id/message + + + + append user message + + + + INSERT into part (user text) + + + + publish message.part.updated + + + + stream event + + + + render user msg + + + + create(assistantMessage) + + + + resolve agent + system prompt + + + + get model + tools + + + + streamText(messages, tools) + + + + HTTP stream call + + + + text / tool-call delta + + + + StreamEvent + + + + upsert part (text/tool) + + + + publish part.updated + + + + stream event + + + + incremental render + + + + invoke(name, input) + + + + check permission + + + + publish question + + + + show permission dialog + + + + allow / deny + + + + answer + + + + resolve + + + + execute (bash/edit/read/...) + + + + ToolPart result + + + + persist result + + + + publish tool.updated + + + + Result (stop/continue/compact) + + + + run compaction + + + + summarize history + + + + update session.time_updated + + + + publish message.completed + + + + final event + + + + render complete + + + + + + + + User + + + + TUI Client + + + + HTTP Server (Hono) + + + + WebSocket (Bus stream) + + + + Session Service + + + + Session Processor + + + + Agent + + + + Provider + + + + LLM (AI SDK) + + + + Tool Registry + + + + Permission + + + + SQLite (Drizzle) + + + + Event Bus + + \ No newline at end of file diff --git a/analysis-and-understanding/03-package-dependency-graph.svg b/analysis-and-understanding/03-package-dependency-graph.svg new file mode 100644 index 000000000000..ba460f5361d0 --- /dev/null +++ b/analysis-and-understanding/03-package-dependency-graph.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + Runtime Packages + + + + + SDK / Public API + + + + + Client / UI Packages + + + + + Platform / Services + + + + + Tooling + + + + + + + + + + + + + + + + + + + + + + + + + + + generates + + + + talks to + + + + talks to + + + + builds + + + + opencodeCLI + Server + TUI + + + + pluginplugin host API + + + + sharedutils, schemas,error, hash + + + + sdk/jsgenerated client + + + + openapi.json + + + + uidesign system + + + + weblanding + docs (Astro) + + + + desktop (Tauri) + + + + desktop-electron + + + + consoleaccount mgmt + + + + storybook + + + + identity (auth) + + + + functioncloudflare workers + + + + app + + + + containers + + + + extensions + + + + IDE extensions + + + + slack + + + + scriptbuild / release + + + + docs + + \ No newline at end of file diff --git a/analysis-and-understanding/04-core-modules.svg b/analysis-and-understanding/04-core-modules.svg new file mode 100644 index 000000000000..8e26fa3856f7 --- /dev/null +++ b/analysis-and-understanding/04-core-modules.svg @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + Entry / CLI + + + + + server/ + + + + + Core Services (Effect) + + + + + session/ + + + + + agent/ + + + + + tool/ + + + + + provider/ + + + + + Platform IO + + + + + storage/ + sync/ + + + + + effect/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + index.tsyargs bootstrap + + + + cli/cmd/*run, serve, tui,agent, mcp, pr + + + + server.ts (Hono) + + + + instance/ routes + + + + control/ workspaces + + + + adapter.bun/.node + + + + fence.ts + + + + middleware.ts + + + + bus/ PubSub + + + + config/ + + + + project/ instance state + + + + global/ + + + + permission/ + + + + plugin/ + + + + skill/ + + + + snapshot/ (git) + + + + session.ts + + + + processor.tsstreaming loop + + + + llm.ts + + + + message-v2.ts + + + + compaction.ts + + + + overflow.ts + + + + summary.ts + + + + system.ts (prompts) + + + + retry.ts + + + + revert.ts + + + + agent.ts + + + + prompt/*build, plan,explore, title + + + + registry.ts + + + + edit/multiedit + + + + bash + + + + read/grep/glob/codesearch + + + + apply_patch + + + + lsp + + + + task (subagent) + + + + webfetch/websearch + + + + todo/todowrite + + + + plan enter/exit + + + + mcp-exa + + + + question + + + + provider.ts + + + + models.ts + + + + auth.ts + + + + transform.ts + + + + lsp/ + + + + mcp/ + + + + pty/ + + + + shell/ + + + + file/ + + + + git/ + + + + ide/ + + + + db.bun/.node (sqlite) + + + + schema.sql.ts + + + + json-migration.ts + + + + storage.ts + + + + sync/ (share) + + + + run-service.ts + + + + instance-state.ts + + + + EffectBridge + + \ No newline at end of file diff --git a/analysis-and-understanding/05-event-bus-instance-state.svg b/analysis-and-understanding/05-event-bus-instance-state.svg new file mode 100644 index 000000000000..e1b90146fd09 --- /dev/null +++ b/analysis-and-understanding/05-event-bus-instance-state.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + Event Producers + + + + + Bus Service (Effect Layer) + + + + + InstanceState (per directory) + + + + + GlobalBus (cross-instance) + + + + + Event Consumers + + + + + + + + + + + + + + + + + + + + + + Session Processorpart.updatedmessage.completed + + + + Tool executiontool.start/end + + + + Permissionquestion.asked + + + + Serverinstance.disposed + + + + LSPdiagnostic.updated + + + + Plugin hooks + + + + publish(def, props) + + + + PubSub: wildcard + + + + PubSub Map:type -> PubSub + + + + ScopedCachekeyed by directory + + + + Finalizers:publish disposed +shutdown pubsubs + + + + emit('event', payload) + + + + Server WS / SSE stream + + + + TUI renderer + + + + SDK clientsdesktop/web + + + + sync/ (share remote) + + + + Logger + + \ No newline at end of file diff --git a/analysis-and-understanding/06-data-model-erd.svg b/analysis-and-understanding/06-data-model-erd.svg new file mode 100644 index 000000000000..081169ef9843 --- /dev/null +++ b/analysis-and-understanding/06-data-model-erd.svg @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + WORKSPACE + + + PK + text + id + text + name + integer + created_at + + + + + PROJECT + + + PK + text + id + + FK + text + workspace_id + text + directory + text + name + integer + created_at + + + + + SESSION + + + PK + text + id + text + slug + + FK + text + project_id + + FK + text + workspace_id + + FK + text + parent_id + text + title + text + version + text + directory + text + share_url + integer + summary_additions + integer + summary_deletions + integer + summary_files + text + revert + text + permission + integer + time_created + integer + time_updated + integer + time_compacting + integer + time_archived + + + + + PART + + + PK + text + id + + FK + text + session_id + text + message_id + text + type + text + role + text + text + text + tool_name + text + tool_input + text + tool_output + integer + time_created + + + + + TODO + + + PK + text + id + + FK + text + session_id + text + content + text + status + integer + order_idx + + + + + PERMISSION + + + PK + text + id + + FK + text + session_id + text + tool + text + pattern + text + decision + integer + created_at + + + + + SNAPSHOT + + + PK + text + id + + FK + text + project_id + text + commit_sha + text + message + integer + created_at + + + + + MESSAGE + + + PK + text + id + + FK + text + session_id + text + role + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +contains + +has + +v2 messages + +parent/child + +tracks + +pending asks + +git commits + \ No newline at end of file diff --git a/analysis-and-understanding/07-startup-bootstrap.svg b/analysis-and-understanding/07-startup-bootstrap.svg new file mode 100644 index 000000000000..cf2bd9828f43 --- /dev/null +++ b/analysis-and-understanding/07-startup-bootstrap.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + loop [migration progress events] + + + + + alt [First run] + + + + + + + + + + + + + + + opencode <cmd> [args] + + + + entry + + + + register unhandled handlers + + + + configure parser + global opts + + + + init(level, print, dev) + + + + start (perf tracing) + + + + set env AGENT/OPENCODE/OPENCODE_PID + + + + check data/opencode.db exists + + + + run(drizzle(client)) + + + + progress bar/stderr + + + + dispatch handler + + + + resolve instance for cwd + + + + build layer stackConfig, Bus, Auth,Provider, Plugin, Skill,Storage, Session + + + + runtime + + + + execute (serve/run/tui/...) + + + + exit code + + + + + Finalizers publishInstanceDisposed andshut down PubSubs + + + + Shell + + + + Bun/Node + + + + index.ts + + + + Yargs + + + + Log + + + + Heap + + + + JsonMigration + + + + SQLite + + + + Chosen Command + + + + InstanceState + + + + Effect Layers + + \ No newline at end of file diff --git a/analysis-and-understanding/08-tool-invocation-permission.svg b/analysis-and-understanding/08-tool-invocation-permission.svg new file mode 100644 index 000000000000..21ee57771b9a --- /dev/null +++ b/analysis-and-understanding/08-tool-invocation-permission.svg @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + ExecTool + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Provider.streamText + + + + text chunk + + + + tool-call chunk + + + + assemble input + + + + registry.invoke + + + + rule match (allow) + + + + rule match (ask) + + + + rule match (deny) + + + + Bus.publish + + + + user approves + + + + user denies + + + + write ToolPart + + + + Bus.publish + + + + feed back as tool result + + + + write denied part + + + + stop/length/tool_use + + + + needs compaction + + + + auto continue + + + + finish + + + + + + + LLMStream + + + + TextDelta + + + + ToolCallDelta + + + + BuildToolPart + + + + CheckPermission + + + + Allowed + + + + AskUser + + + + Denied + + + + PublishQuestion + + + + WaitAnswer + + + + PersistOutput + + + + PublishUpdate + + + + PersistDenied + + + + StopReason + + + + Compact + + + + Continue + + + + Done + + + + + + + + + + + bash + + + + + + + edit + + + + + + + read + + + + + + + patch + + + + + + + lsp + + + + + + + task + + + + + + + mcp + + + + + + + webfetch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/analysis-and-understanding/09-skills-plugins-injection.svg b/analysis-and-understanding/09-skills-plugins-injection.svg new file mode 100644 index 000000000000..3ca08f0ad3ee --- /dev/null +++ b/analysis-and-understanding/09-skills-plugins-injection.svg @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + Skill Sources (discovery) + + + + + Plugin Sources (plugin/index.ts) + + + + + Skill.Service (InstanceState layer) + + + + + Plugin.Service (InstanceState layer) + + + + + Session start / each turn + + + + + When LLM calls skill tool + + + + + Plugin hooks fired during turn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ~/.claude/skills/&ast;&ast;/SKILL.md + + + + ~/.agents/skills/&ast;&ast;/SKILL.md + + + + <worktree>/.claude /.agentswalk up from cwd + + + + config.directories(){skill,skills}/&ast;&ast;/SKILL.md + + + + config.skills.pathsuser paths / ~-expansion + + + + config.skills.urlsremote bundles + + + + INTERNAL_PLUGINScodex, copilot,gitlab, poe,cloudflare&times;2 + + + + config.plugin_origins(skipped if --pure) + + + + Discovery.pullHTTP → cache dir~/cache/skills/<name> + + + + Glob scanSKILL.md files + + + + ConfigMarkdown.parsefrontmatter → Info{name,description,location,content} + + + + skills: Record<name, Info> + + + + PluginLoader.loadExternalnpm install + import + + + + applyPlugin →hooks.push(plugin(input)) + + + + PluginInput:client (SDK), project,directory, worktree,$ (BunShell), serverUrl + + + + "hooks: Hooks[ + + + + Bus.subscribeAll →hook.event(&hellip;) + + + + SystemPrompt.skills(agent)Permission filter →Skill.fmt verbose<available_skills> block + + + + ToolRegistryincludes SkillTool + + + + LLM streamTextsystem prompt +tools + messages + + + + skill toolparams: {name} + + + + ctx.askpermission 'skill' + + + + Skill.get(name) →SKILL.md content + + + + ripgrep listadjacent files(scripts/, refs/) + + + + <skill_content>+ <skill_files>returned to LLM + + + + experimental.chat.system.transform(agent.ts) + + + + experimental.chat.messages.transform(compaction + prompt) + + + + chat.params / chat.headers(llm.ts) + + + + permission.ask(override allow/ask/deny) + + + + tool.definition(registry.ts) + + + + tool.execute.beforetool.execute.after + + + + shell.env(pty + bash) + + + + experimental.session.compactingexperimental.compaction.autocontinue + + + + event(bus events) + + \ No newline at end of file diff --git a/analysis-and-understanding/10-skills-plugins-runtime-sequence.svg b/analysis-and-understanding/10-skills-plugins-runtime-sequence.svg new file mode 100644 index 000000000000..80d57031b31f --- /dev/null +++ b/analysis-and-understanding/10-skills-plugins-runtime-sequence.svg @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + loop [Streaming] + + + + + alt [LLM calls skill tool] + + + + + alt [LLM calls any other tool] + + + + + alt [Context window overflow] + + + + + + + + + + + + + + + + discoverSkills(EXTERNAL_DIRS, config.paths, urls) + + + + Glob SKILL.md + ConfigMarkdown.parse + + + + skills Map populated + + + + load INTERNAL_PLUGINS +PluginLoader.loadExternal(cfg.plugin_origins) + + + + hook = await plugin(PluginInput) + + + + hooks[] + bus.subscribeAll + + + + send message + + + + create(assistantMessage) + + + + resolve agent (build/plan/...) + + + + Skill.available(agent)Permission filter + + + + Info[] + + + + SystemPrompt.skills(agent) + + + + <available_skills> block (verbose) + + + + trigger experimental.chat.system.transform + + + + mutated system prompt + + + + toolset(agent) + + + + trigger tool.definition (per tool) + + + + mutated description/parameters + + + + tools (incl. "skill") + + + + streamText(system, tools, messages) + + + + trigger chat.params / chat.headers(llm.ts) before request + + + + text / tool-call deltas + + + + execute({name}) + + + + ctx.ask("skill", [name]) + + + + trigger permission.ask + + + + override decision (optional) + + + + allow + + + + skill.get(name) + + + + Info {content, location} + + + + ripgrep bundled files + + + + file list + + + + <skill_content>+ <skill_files> + + + + tool result (feeds next turn) + + + + tool.execute.before(input, output) + + + + execute + + + + result + + + + tool.execute.after(input, output) + + + + trigger experimental.session.compacting + + + + trigger experimental.chat.messages.transform + + + + summarize history + + + + Result (stop / continue / compact) + + + + + Per user turn + + + + User + + + + Session + + + + Session Processor + + + + SystemPrompt (system.ts) + + + + Skill.Service + + + + Plugin.Service + + + + ToolRegistry + + + + Agent + + + + Provider / LLM + + + + skill tool + + + + Permission + + + + Filesystem + + \ No newline at end of file diff --git a/analysis-and-understanding/11-desktop-architecture-tauri-vs-electron.svg b/analysis-and-understanding/11-desktop-architecture-tauri-vs-electron.svg new file mode 100644 index 000000000000..633a9bca29b6 --- /dev/null +++ b/analysis-and-understanding/11-desktop-architecture-tauri-vs-electron.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + Shared UI Layer (shell-agnostic) + + + + + packages/desktop (Tauri v2 - PRIMARY) + + + + + packages/desktop-electron (SECONDARY) + + + + + OpenCode server (same binary either way) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @tauri-apps/plugin-http + + + + Node fetch + NO_PROXY + + + + @opencode-ai/appSolidJS AppInterfaceAppBaseProvidersPlatformProviderServerConnection + + + + @opencode-ai/ui(design system) + + + + Platform abstractionfile/dir pickers, openLink, openPath,storage(name), updater, notifications,fetch, WSL, webviewZoom,parseMarkdown, checkAppExists + + + + Renderer (system WebView)SolidJS + Vite :1420index.tsx, loading.tsx + + + + src/bindings.ts(GENERATED by tauri-specta)typed commands + events + + + + Tauri IPCNEVER call invoke manually + + + + Rust shell (opencode-desktop)src-tauri/src/main.rs - lib.rscli.rs - server.rs - markdown.rswindows.rs - logging.rs + + + + Tauri pluginsopener - deep-link - shelldialog - updater - storewindow-state - clipboardhttp - notificationsingle-instance - os - decorum + + + + Sidecar: bundled opencode binaryprocess-wrap (ProcessGroup / JobObject + KillOnDrop)opencode serve --hostname --port--auth-user opencode --auth-password <uuid> + + + + reqwest (rustls)no_proxy loopback + + + + comrak markdown + + + + tracing +tracing-appender + + + + tauri-plugin-store(disk) + + + + Renderer (Chromium)SolidJS + electron-viteindex.html + loading.html + + + + preload/index.tscontextBridge → window.api(ElectronAPI, types.ts) + + + + ipcMain.handlerenderer only uses window.apimain IPC lives in main/ipc.ts + + + + Main process (Node)main/index.ts - initialize()server.ts - migrate.tswindows.ts - menu.ts - apps.tsshell-env.ts - store.ts + + + + Vite virtual modulevirtual:opencode-server→ ../opencode/dist/node/node.js + + + + Embedded server (in-process)Server.listen({port, hostname,username, password}).wasm tree-sitter blobs copied + + + + @lydell/node-ptynarrowed per platform-arch + + + + marked markdown + + + + electron-log + + + + electron-store + + + + electron-updaterautoDownload:falseautoInstallOnAppQuit + + + + GET /global/healthbasic auth + + + + ~/.local/share/opencode/opencode.db(sqlite migration on first run) + + + + opencode HTTP + WS API + + \ No newline at end of file diff --git a/analysis-and-understanding/12-desktop-bootstrap-sequence.svg b/analysis-and-understanding/12-desktop-bootstrap-sequence.svg new file mode 100644 index 000000000000..7b17b4c8bc9f --- /dev/null +++ b/analysis-and-understanding/12-desktop-bootstrap-sequence.svg @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + loop [poll up to 30 s] + + + + + alt [migration > 1 s] + + + + + alt [opencode.db missing] + + + + + par [Health + migration in parallel] + + + + + + opt [Loading overlay] + + + + + alt [Tauri] + + [Electron] + + + + + + + + + + + + + + launch app + + + + start (acquire single-instance lock) + + + + register opencode:// handler,wire OS menu,ensureLoopbackNoProxy + + + + initialize() + + + + pick free port (OPENCODE_PORT or 127.0.0.1:0)generate uuid passwordusername = "opencode" + + + + spawn sidecar / Server.listen({port,user,pass}) + + + + child handle / Listener + + + + publish ServerReadyData{url, username, password} + + + + GET /global/health (basic auth) + + + + 200 OK + + + + listen SqliteMigrationProgress + + + + events (current/total/label) + + + + forward init_tx = SqliteWaiting + + + + create LoadingWindow + + + + Done + + + + create MainWindow immediately + + + + build Platform(Tauri: commands/eventsElectron: window.api) + + + + commands.awaitInitialization(channel) + + + + stream InitStep eventsserver_waiting → sqlite_waiting → done + + + + ServerReadyData + + + + connect (ServerConnection = sidecar)basic auth + + + + API + WS ready + + + + ready screen + + + + LoadingWindowComplete (Tauri) /ready (Electron) + + + + close LoadingWindow + + + + Quit + + + + kill sidecar (process-wrap drop)on RunEvent::Exit + + + + killSidecar() on before-quit/will-quit + + + + exit + + + + + InitStep = server_waiting + + + + + Runtime: Platform proxies OS calls(pickers, storage, fetch, updater, notifications) + + + + User + + + + OS (single-instance + deep-link) + + + + Native Shell(Tauri Rust / Electron main) + + + + initialize() /lib.rs::initialize + + + + Server(sidecar binary | virtual:opencode-server) + + + + Sqlite Migration + + + + LoadingWindow + + + + MainWindow(SolidJS renderer) + + + + Platform(bindings.ts / window.api) + + + + @opencode-ai/appAppInterface + + \ No newline at end of file diff --git a/analysis-and-understanding/README.md b/analysis-and-understanding/README.md new file mode 100644 index 000000000000..5659dd44a146 --- /dev/null +++ b/analysis-and-understanding/README.md @@ -0,0 +1,85 @@ +# OpenCode - System Diagrams + +Generated by reading `opencode/` (the open-source AI coding agent by anomalyco). +Rendered as Mermaid SVGs using the `tokyo-night` theme. + +## What OpenCode is + +A terminal-first, provider-agnostic AI coding agent with a **client/server +architecture**. The headless server (Hono on Bun) runs the agent loop, talks to +LLMs, executes tools, and streams events; multiple clients (TUI, desktop +Tauri app, web, SDK) drive it via HTTP/WebSocket. + +Core is written in TypeScript with heavy use of [Effect](https://effect.website) +for composable services, `Layer`s and per-directory `InstanceState` scoped +caches. Persistence is SQLite via Drizzle ORM. + +## Diagrams + +| # | File | What it shows | +|---|------|---------------| +| 01 | [`01-high-level-architecture.svg`](./01-high-level-architecture.svg) | 10,000-ft view: clients → SDK → server → core → services → persistence/external | +| 02 | [`02-session-message-flow.svg`](./02-session-message-flow.svg) | Sequence: user prompt → LLM stream → tool calls → permission → events → render | +| 03 | [`03-package-dependency-graph.svg`](./03-package-dependency-graph.svg) | How the monorepo's ~20 packages depend on each other (runtime / SDK / UI / platform) | +| 04 | [`04-core-modules.svg`](./04-core-modules.svg) | Inside `packages/opencode/src`: entry, server, session, agent, tools, providers, storage | +| 05 | [`05-event-bus-instance-state.svg`](./05-event-bus-instance-state.svg) | Bus `PubSub` wiring, `InstanceState` per-directory scoping, GlobalBus fan-out | +| 06 | [`06-data-model-erd.svg`](./06-data-model-erd.svg) | SQLite schema: workspace, project, session, part (message-v2), todo, permission, snapshot | +| 07 | [`07-startup-bootstrap.svg`](./07-startup-bootstrap.svg) | `index.ts` → yargs → log/heap → first-run migration → command dispatch → Effect layers | +| 08 | [`08-tool-invocation-permission.svg`](./08-tool-invocation-permission.svg) | State machine for a single tool call: permission gate, exec dispatch, feedback to LLM | +| 09 | [`09-skills-plugins-injection.svg`](./09-skills-plugins-injection.svg) | How skills are discovered/loaded and how plugins register hooks into a session | +| 10 | [`10-skills-plugins-runtime-sequence.svg`](./10-skills-plugins-runtime-sequence.svg) | Per-turn sequence: system prompt injection, `tool.definition`, `permission.ask`, `skill` tool execution, compaction hooks | +| 11 | [`11-desktop-architecture-tauri-vs-electron.svg`](./11-desktop-architecture-tauri-vs-electron.svg) | Side-by-side: Tauri (Rust shell + bundled sidecar + `tauri-specta` IPC) vs Electron (Node main + embedded `virtual:opencode-server` + `window.api` contextBridge), both over the same SolidJS `@opencode-ai/app` | +| 12 | [`12-desktop-bootstrap-sequence.svg`](./12-desktop-bootstrap-sequence.svg) | Shared startup state machine: port/password pick → sidecar spawn / `Server.listen` → `ServerReadyData` → parallel health poll + sqlite migration → optional loading window → MainWindow + `awaitInitialization` stream | + +## Key findings + +### Client/server split +`packages/opencode/src/cli/cmd/serve.ts` starts a Hono server +(`server/server.ts`). The TUI (`cli/cmd/tui/`) is an Ink/React React app that +talks to the same server over HTTP/WebSocket — as do the desktop (Tauri) and +web clients via the generated `sdk/js` client. This is why OpenCode can run +headlessly and be driven remotely. + +### Effect-based service graph +Every subsystem (`Bus`, `Config`, `Provider`, `Agent`, `Skill`, `Plugin`, +`Storage`, `Session`) is an `Effect.Service` with a `Layer`. `makeRuntime` +(`src/effect/run-service.ts`) deduplicates layers via a shared `memoMap`; +`InstanceState` (`src/effect/instance-state.ts`) uses `ScopedCache` keyed by +directory so each open project gets its own state with automatic cleanup. + +### Streaming session processor +`session/processor.ts` is the engine: it wraps `Provider.streamText`, routes +`text` / `tool-call` / `reasoning` deltas into message parts, persists them via +Drizzle, and republishes over the `Bus`. It handles compaction, retries, +tool-call deduplication, and a "doom-loop" threshold. + +### Tool registry + permission +`tool/registry.ts` exposes all built-in tools plus MCP-proxied ones. Each +invocation passes through `permission/` which can match `allow`/`ask`/`deny` +rules. An `ask` decision publishes a `question` event on the bus; a TUI/desktop +client renders a prompt and posts the answer back. + +### Persistence +All state lives in SQLite (`storage/db.bun.ts`) with Drizzle schemas in +`**/*.sql.ts`. A one-time `JsonMigration` on first boot moves legacy JSON data +into SQLite (shown in diagram 07). Shares/sync stream deltas to a remote +service. + +### Pluggability +- **Providers** — AI SDK v3 bundled providers (Anthropic, OpenAI, Google, + Bedrock, Azure, Vertex, etc.) plus dynamically-loaded NPM providers. +- **Plugins** — `plugin/` package with a TUI runtime (`cli/cmd/tui/plugin/`) + that registers sidebar widgets, keybinds, and slots. +- **MCP** — External tools via the Model Context Protocol client in `mcp/`. +- **LSP** — Language server launcher/client in `lsp/` feeding the `lsp` tool. +- **Skills** — Markdown-based prompt packs under `skill/`. + +## Source references + +- Entry: `opencode/packages/opencode/src/index.ts` +- Server: `opencode/packages/opencode/src/server/server.ts` +- Session processor: `opencode/packages/opencode/src/session/processor.ts` +- Bus: `opencode/packages/opencode/src/bus/index.ts` +- Agent: `opencode/packages/opencode/src/agent/agent.ts` +- Tool registry: `opencode/packages/opencode/src/tool/registry.ts` +- Provider: `opencode/packages/opencode/src/provider/provider.ts` diff --git a/analysis-and-understanding/opencode-architecture.md b/analysis-and-understanding/opencode-architecture.md new file mode 100644 index 000000000000..e3b5c5cfdb11 --- /dev/null +++ b/analysis-and-understanding/opencode-architecture.md @@ -0,0 +1,85 @@ +# OpenCode — Quick Architecture Summary + +**What it is:** A 100% open-source, provider-agnostic AI coding agent (think Claude Code alternative). TUI-first, with a client/server architecture so the "driver" (TUI, desktop app, mobile, ACP client) is decoupled from the agent runtime. + +## Repo Shape + +Bun + Turbo monorepo (`packageManager: bun@1.3.11`, TS 5.8, `type: module`). Workspaces under `packages/*`: + +- **`packages/opencode`** — the core agent runtime & CLI (the brain) +- **`packages/app`** — SolidJS web client (Vite) +- **`packages/desktop`** / **`desktop-electron`** — Tauri + Electron desktop apps +- **`packages/console`** — cloud/console UI +- **`packages/sdk/js`** — generated JS SDK (from `openapi.json`, regen via `packages/sdk/js/script/build.ts`) +- **`packages/plugin`**, **`packages/function`**, **`packages/slack`**, **`packages/extensions`**, **`packages/identity`**, **`packages/containers`** — integrations +- **`packages/ui`** / **`packages/storybook`** — shared components +- **`packages/web`**, **`packages/docs`** — marketing/docs site +- **`infra/`** + `sst.config.ts` — deploys on SST + +## Core Package Architecture (`packages/opencode/src/`) + +**Entry:** `index.ts` — yargs-based CLI. Commands (`cli/cmd/*`) include `run`, `serve`, `tui/attach`, `acp`, `mcp`, `agent`, `github`, `pr`, `session`, `db`, `stats`, `export/import`, `upgrade`, etc. Bootstrap does log init, heap sampling, and a one-time JSON→SQLite migration (`JsonMigration.run`) against `~/.opencode/opencode.db`. + +**Runtime foundation:** Heavy use of **Effect (v4 beta)** for composition. Two orthogonal runtime concepts: + +- `makeRuntime` (`src/effect/run-service.ts`) — shared memoized layers +- `InstanceState` (`src/effect/instance-state.ts`) — `ScopedCache` keyed by project directory, giving each open project its own isolated state with automatic cleanup (watchers, processes, subscriptions). `Instance.bind` propagates AsyncLocalStorage context into native callbacks. + +**Server (`src/server/`):** Hono app with a pluggable adapter (`#hono` conditional export → `adapter.bun.ts` / `adapter.node.ts`). Two route trees: + +- `InstanceRoutes` — per-project session/message/tool APIs + WebSocket upgrade for streaming +- `ControlPlaneRoutes` — multi-instance/workspace plane (gated by `OPENCODE_WORKSPACE_ID`) +- `UIRoutes` — serves bundled UI +- Middleware stack: Error → Auth → Logger → Compression → CORS → Fence +- mDNS broadcast (`mdns.ts`) for local discovery; OpenAPI auto-generated via `hono-openapi` (drives SDK regen) + +**Session (`src/session/`):** The conversation engine. + +- `session.ts` — CRUD over `SessionTable`/`PartTable` (Drizzle + SQLite via Bun/Node adapters: `#db` conditional) +- `llm.ts` — provider calls via Vercel AI SDK (Anthropic, OpenAI, Google, Bedrock, xAI, Groq, etc. — one dependency per provider) +- `processor.ts`, `run-state.ts`, `status.ts` — run loop state machine +- `compaction.ts` + `overflow.ts` + `summary.ts` — context management +- `message-v2.ts` — typed message/part model (text, tool calls, files) +- `prompt/` + `system.ts` + `instruction.ts` — prompt assembly +- `retry.ts`, `revert.ts`, `todo.ts`, `projectors.ts` — supporting concerns + +**Tools (`src/tool/`):** Each tool is a `Def` with a `Zod` schema, description (often loaded from a sibling `.txt` prompt file), and an `Effect`-returning `execute`. The `tool.ts` `wrap()` adds validation, tracing spans, and automatic output truncation (`truncate.ts`) keyed on the calling agent. Built-ins: `bash`, `read`, `write`, `edit`, `multiedit`, `apply_patch`, `grep`, `glob`, `codesearch`, `lsp`, `webfetch`, `websearch`, `task` (subagent), `plan`, `todo`, `question`, `skill`. Extras via MCP. + +**Agents (`src/agent/`):** Built-ins — `build` (full-access), `plan` (read-only, denies edits, asks before bash), and a `general` subagent for multistep searches. Switched via `Tab` in the TUI; invoked as `@general` inline. + +**Other key modules:** + +- `mcp/` — Model Context Protocol client (external tool servers) +- `lsp/` — LSP client for diagnostics/symbols used by the `lsp` tool +- `permission/` — user-gated tool execution (plan mode hook) +- `acp/` — Agent Client Protocol (IDE/editor integration) +- `pty/` — cross-runtime pty (`#pty` conditional: `bun-pty` vs `@lydell/node-pty`) +- `provider/` — provider discovery/config across all AI SDKs +- `auth/`, `account/`, `control-plane/`, `share/`, `sync/` — auth/cloud sharing +- `storage/` — Drizzle schema (tables in `**/*.sql.ts`, snake_case), migrations in `migration/`, JSON→SQL migrator +- `bus/` — event bus (`Bus.publish`) for cross-service async events +- `snapshot/` + `git/` + `worktree/` + `patch/` — checkpoint/undo via git worktrees and patch apply +- `project/` — project/instance bootstrap; `bootstrap.ts` forks every service `init()` detached +- `skill/` + `plugin/` — user extension points +- `ide/`, `installation/`, `command/`, `shell/` — platform glue + +## How a request flows + +1. CLI command (`opencode run …` / `serve` / TUI attach) → bootstrap → Effect runtime +2. For a project dir: `InstanceState` materializes services (Agent, Tool, Session, Provider, LSP, Bus) scoped to that dir +3. Hono server listens; TUI/desktop client connects via HTTP + WebSocket +4. A user message starts a `Session` run: LLM call via AI SDK → streaming parts → tool calls routed through `tool/registry.ts` → each tool yields an `Effect`, outputs get truncated & persisted as `PartTable` rows → Bus events stream back to the client over the WebSocket +5. Permissions gate destructive tools depending on the active agent; MCP/plugin tools merge into the registry + +## Conventions worth noting (from AGENTS.md) + +- No `export namespace`; instead flat exports + `export * as Foo from "./foo"` at file bottom +- No barrel `index.ts` in multi-sibling dirs (keeps tree-shaking) +- Drizzle schemas in `src/**/*.sql.ts`, snake_case columns +- Effect-style: `Effect.gen` + `Effect.fn("Domain.method")`, prefer typed errors via `Schema.TaggedErrorClass`, no `Effect.fork` (use `forkIn(scope)`), `Effect.cached` for dedup +- Prefer Bun APIs (`Bun.file()`), avoid `try/catch`, avoid `any`, avoid `else` (early return), prefer ternaries, inline single-use vars +- Default branch is `dev` (not `main`); tests run from package dirs, typecheck via `bun typecheck` (tsgo) + +## TL;DR + +A Bun/Effect-powered monorepo where `packages/opencode` exposes a CLI that boots a Hono HTTP+WS server. The server hosts a per-project session engine that talks to any AI SDK provider, executes Zod-typed tools (including MCP/LSP/bash/edit/etc.) under Effect, persists everything in SQLite via Drizzle, and streams to pluggable clients (TUI, desktop, web, ACP, mobile). diff --git a/analysis-and-understanding/opencode-desktop-dev-setup.md b/analysis-and-understanding/opencode-desktop-dev-setup.md new file mode 100644 index 000000000000..b2f43b8f99ab --- /dev/null +++ b/analysis-and-understanding/opencode-desktop-dev-setup.md @@ -0,0 +1,130 @@ +# Running the OpenCode Desktop App for Local Development + +OpenCode ships two desktop builds — pick one. **The Tauri build is primary**; Electron is secondary. Both packages are under `opencode/packages/`. + +## Prereqs (one-time) + +```bash +# From opencode/ root +bun install +``` + +For **Tauri** additionally (required; the Electron build does not need Rust): + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +# Plus Tauri OS dependencies — see https://v2.tauri.app/start/prerequisites/ +# macOS: Xcode CLI tools +# Linux: webkit2gtk, libayatana-appindicator3, librsvg2, ... +# Windows: WebView2 runtime + MSVC build tools +``` + +--- + +## Option 1 — Tauri (recommended) + +Standard dev command (from `opencode/` root): + +```bash +bun run --cwd packages/desktop tauri dev +``` + +What happens: + +1. Tauri runs its `beforeDevCommand` (`bun run dev` → Vite on `http://localhost:1420`, `strictPort`, watch excludes `src-tauri/**`). +2. The package's `predev` script first executes `packages/desktop/scripts/predev.ts`, which **builds the opencode CLI binary** via `cd ../opencode && bun run build --single` (or `--single --baseline`) and copies it into `src-tauri/sidecars/` as the bundled sidecar. +3. Rust compiles (`cargo`) — first run is slow (5–10 min), subsequent are fast. +4. A native window opens pointing at `localhost:1420` with the SolidJS UI; the Rust shell spawns that freshly built `opencode` binary as the sidecar, picks a free port, generates a UUID password, and exposes credentials through `commands.awaitInitialization`. + +Other scripts (from `packages/desktop`): + +```bash +bun run dev # Vite only, no native window (UI-only iteration) +bun run typecheck # tsgo -b +bun run tauri build # production bundle (dmg/app/deb/rpm/nsis) +``` + +And Rust-only: + +```bash +cd packages/desktop/src-tauri +cargo check # quick Rust typecheck +cargo test # run the 27 Rust unit tests +``` + +Environment knobs: + +- `OPENCODE_PORT=…` — pin the sidecar port instead of auto-selecting +- `TAURI_DEV_HOST=…` — enable LAN / mobile-preview HMR (Vite binds that host; HMR switches to `ws://:1421`) +- `OPENCODE_CHANNEL=dev|beta|prod` — controls app id/icons (dev is default) +- Iteration tip: after changing anything in `src/bindings.ts` consumers, rebuild Rust so `tauri-specta` regenerates bindings; the `test_export_types` Rust test flags drift if CI sees a mismatch. + +--- + +## Option 2 — Electron + +```bash +bun run --cwd packages/desktop-electron dev +``` + +What happens: + +1. The `predev` script runs `packages/desktop-electron/scripts/predev.ts`, which copies channel icons (`copy-icons.ts `, default `dev`) and then `cd ../opencode && bun script/build-node.ts` — **builds the Node bundle of the opencode server** into `packages/opencode/dist/node/`. This is what Electron embeds in-process via the Vite virtual module `virtual:opencode-server`. +2. `electron-vite dev` builds three targets (`main` / `preload` / `renderer`) and launches Electron. The main process starts the server **in-process** (not as a subprocess), then opens the main window. + +Channel-specific run (affects app id, name, userData path): + +```bash +OPENCODE_CHANNEL=beta bun run --cwd packages/desktop-electron dev +OPENCODE_CHANNEL=prod bun run --cwd packages/desktop-electron dev +``` + +Other scripts (from `packages/desktop-electron`): + +```bash +bun run typecheck # tsgo -b across main/preload/renderer +bun run build # electron-vite build +bun run package # electron-builder for current platform +bun run package:mac|win|linux # target-specific bundle +``` + +Unit tests (only two files exist): + +```bash +cd packages/desktop-electron +bun test src/main/shell-env.test.ts +bun test src/renderer/html.test.ts +``` + +--- + +## UI-only iteration (fastest loop) + +If you only want to change SolidJS UI and not touch native shell code, skip the desktop runner entirely and use the web dev flow that `packages/app/AGENTS.md` documents: + +```bash +# Terminal 1 — backend +cd opencode/packages/opencode +bun run --conditions=browser ./src/index.ts serve --port 4096 + +# Terminal 2 — UI +cd opencode/packages/app +bun dev -- --port 4444 +# Open http://localhost:4444 +``` + +This runs the exact same `@opencode-ai/app` code that both shells render, with far faster rebuilds and no Rust/Electron overhead. + +--- + +## First-run gotchas + +- **Rust not found** (Tauri): install via `rustup`. +- **Sidecar binary missing** (Tauri): the `predev` step failed to build the opencode CLI. Run `cd opencode/packages/opencode && bun run build --single` manually and check errors. +- **`virtual:opencode-server` not found** (Electron): the `prebuild`/`predev` Node build didn't complete. Run `cd opencode/packages/opencode && bun script/build-node.ts`. +- **Stale sidecar process**: both shells kill their sidecar on quit, but if dev was hard-killed, a zombie `opencode` may hold the port. Find and kill it (`pgrep -fl opencode` / `taskkill` on Windows) or pick a different port via `OPENCODE_PORT`. +- **Sqlite first-time migration**: on a fresh machine the first launch performs a one-time JSON→SQLite migration against `~/.local/share/opencode/opencode.db` (or `$XDG_DATA_HOME`). If it takes >1 s, a loading overlay appears. This only happens once. +- **Ports already in use**: default Vite dev port is `1420` (Tauri) with `strictPort: true`. Close whatever else is bound, or (for Tauri) it will hard-fail rather than fall back. +- **Corporate proxy eating loopback**: both shells auto-add loopback to `NO_PROXY` / `proxy-bypass-list`, but if your global proxy config is unusual and you see "sidecar unreachable", explicitly `export NO_PROXY=127.0.0.1,localhost,::1`. + +Per `AGENTS.md`: never restart the app/server process yourself during a running session — let the dev runners own lifecycle. diff --git a/analysis-and-understanding/opencode-desktop-testing.md b/analysis-and-understanding/opencode-desktop-testing.md new file mode 100644 index 000000000000..27714bee738d --- /dev/null +++ b/analysis-and-understanding/opencode-desktop-testing.md @@ -0,0 +1,87 @@ +# OpenCode Desktop — Testing Strategy + +**Short answer: the desktop packages themselves have almost no tests. Essentially no unit runner, no integration tests, no Playwright, no Spectron/WebdriverIO. Testing is done at layers below.** + +--- + +## What exists in the desktop packages + +### `packages/desktop` (Tauri) — no JS tests, only Rust unit tests + +- **No `test` script** in `package.json`. The only script-level checks are `typecheck` and the `tauri` build/dev runners. +- Rust side has inline `#[cfg(test)] mod tests` blocks in three files (`src-tauri/src/`): + - `linux_windowing.rs` — **22** `#[test]` functions (the bulk of the tests in the whole desktop surface) + - `cli.rs` — **4** `#[test]` functions (shell env / path logic around the sidecar) + - `lib.rs` — **1** `#[test]` `test_export_types` that re-runs the `tauri-specta` generator to ensure `src/bindings.ts` is up-to-date +- Run with `cargo test` from `packages/desktop/src-tauri` — standard Rust test harness. +- **Not wired to Turbo.** No CI target in `turbo.json`. +- No `@tauri-apps/webdriver`, no `tauri driver`, no Playwright fixture — nobody is driving the webview end-to-end. + +### `packages/desktop-electron` — two Bun unit tests, that's it + +- Also **no `test` script** in `package.json` (only `typecheck`, `predev`, `dev`, `prebuild`, `build`, `package*`). +- The two files that exist use `bun:test`: + - `src/main/shell-env.test.ts` — pure-function tests for `parseShellEnv`, `mergeShellEnv`, `isNushell` (no Electron runtime) + - `src/renderer/html.test.ts` — reads `index.html` / `loading.html` as files and asserts relative paths (because `loadFile()` breaks with absolute paths in Electron) plus a Vite config sanity check +- Both run under plain `bun test` — neither boots Electron, neither mocks `electron` APIs, both test pure logic or static files. +- **No Playwright, no Spectron, no `playwright-electron`.** No integration harness that launches the Electron app. + +--- + +## Why this is OK (where the real tests live) + +Desktop is intentionally a thin shell, so the testing strategy pushes testing down two levels: + +### 1. The UI (`packages/app`) + +This is what both desktop shells render, and it has the full test infra: + +- `bun test --preload ./happydom.ts ./src` → SolidJS unit/component tests in happy-dom +- `@playwright/test` with `playwright.config.ts` running a chromium project against Vite `webServer` on port 3000, targeting a real backend via `VITE_OPENCODE_SERVER_HOST`/`VITE_OPENCODE_SERVER_PORT` (e.g. `4096`) +- `e2e/todo.spec.ts` is currently the only spec; retries=2 and trace/screenshot/video on failure in CI +- Scripts: `test`, `test:unit`, `test:unit:watch`, `test:e2e`, `test:e2e:ui`, `test:e2e:report`, `test:ci` (JUnit output to `.artifacts/unit/junit.xml`) +- Wired in `turbo.json` as `@opencode-ai/app#test` and `@opencode-ai/app#test:ci` + +### 2. The server/agent (`packages/opencode`) + +Which both shells boot as a sidecar — has `bun test --timeout 30000` (scripts `test`, `test:ci`), and is the `opencode#test` / `opencode#test:ci` task in Turbo. + +--- + +## Practical dev workflow + +- **Unit-testing main-process logic (Electron):** put it in `src/main/*.ts` as a pure function, add a `*.test.ts` next to it, run `bun test src/main/shell-env.test.ts` from `packages/desktop-electron`. That's the pattern `shell-env.test.ts` establishes. +- **Unit-testing Rust helpers (Tauri):** add `#[cfg(test)] mod tests` in-file and run `cargo test` from `packages/desktop/src-tauri`. Anything platform-gated (`#[cfg(target_os = "linux")]`) only runs on that host. +- **Integration/E2E on real UI behavior:** write a Playwright spec in `packages/app/e2e/*.spec.ts` and run `bun run test:e2e` there. It runs against the Vite dev server + a real `opencode serve` backend — not through the Electron/Tauri shell. **You cannot currently `playwright.open` the packaged desktop app; there is no such harness.** +- **Verifying the IPC contract:** + - **Tauri:** the `test_export_types` Rust test regenerates `src/bindings.ts`. If CI sees `bindings.ts` diverge from what `specta` produces, you know a command drifted. + - **Electron:** `preload/types.ts` is hand-written, so contract drift is caught by `bun typecheck` (`tsgo -b`) across main/preload/renderer — there is no runtime IPC test. +- **Typecheck is the de-facto test for the shells:** `bun typecheck` from each desktop package (runs `tsgo -b`) + `cargo check`/`cargo build` for Rust is what actually gates most PRs touching shell code. + +--- + +## Gaps worth noting + +- There is **no automated test** that launches either shell and verifies the sidecar spawn → health-check → main-window handshake. That flow is validated manually via `bun run --cwd packages/desktop tauri dev` / `bun run --cwd packages/desktop-electron dev`. +- No test covers the `await_initialization` / `InitStep` streaming channel contract end-to-end. +- No test for deep-link handling, auto-updater flows, or the loading-window trigger heuristic (>1 s sqlite migration). +- Playwright exists but is scoped to `packages/app` against the browser build; it does not exercise `Platform` desktop implementations (`@tauri-apps/plugin-*`, `window.api`). + +--- + +## Summary Matrix + +| Layer | Unit | Integration | E2E | +|----------------------------------------|-------------------|-------------|----------------------| +| `packages/desktop` Rust (`src-tauri`) | `cargo test` (27 tests) | — | — | +| `packages/desktop` TS renderer | — | — | — | +| `packages/desktop-electron` main | `bun test` (1 file)| — | — | +| `packages/desktop-electron` renderer | `bun test` (1 file, static HTML only) | — | — | +| `packages/app` (shared UI) | `bun test` + happy-dom | — | Playwright (chromium, `packages/app/e2e`) | +| `packages/opencode` (sidecar/server) | `bun test` (`opencode#test` in Turbo) | — | — | + +--- + +## TL;DR + +Desktop has no Playwright, no Electron E2E harness, no Tauri WebDriver. It has a handful of Rust unit tests (mostly Linux window logic + specta export verification) and two Bun unit tests in Electron (shell-env parsing + HTML static analysis). Everything real — component tests, E2E — lives in `packages/app` (Playwright against the web build) and `packages/opencode` (`bun test`). **Typecheck is the primary safety net for shell code.** diff --git a/analysis-and-understanding/opencode-desktop.md b/analysis-and-understanding/opencode-desktop.md new file mode 100644 index 000000000000..952c45f3307e --- /dev/null +++ b/analysis-and-understanding/opencode-desktop.md @@ -0,0 +1,262 @@ +# OpenCode Desktop — Architecture Summary + +OpenCode ships **two parallel desktop implementations** living side-by-side in the monorepo: + +- `packages/desktop` — **Tauri v2** (Rust shell + system WebView), the primary/production build +- `packages/desktop-electron` — **Electron** (Node main + Chromium renderer), a secondary build sharing the same UI + +Both render the exact same SolidJS app (`@opencode-ai/app`) and follow the same lifecycle model: the native shell spawns the OpenCode server as a child ("sidecar"), waits for it to be healthy, then loads the web UI pointed at `http://127.0.0.1:` with basic-auth credentials. The UI code is unaware which shell it runs inside — a `Platform` object abstracts all native calls. + +--- + +## 1. Shared Design (both shells) + +### Packages consumed +- `@opencode-ai/app` — the SolidJS web app (`AppInterface`, `AppBaseProviders`, `PlatformProvider`, `ServerConnection`, `useCommand`). Exposed through `@opencode-ai/app/vite` as a Vite plugin that both shells register. +- `@opencode-ai/ui` — shared component library. +- `solid-js`, `@solidjs/meta`, `@solid-primitives/i18n`, `@solid-primitives/storage`. + +### Platform abstraction +Both renderers build a `Platform` object (see `desktop/src/index.tsx` and `desktop-electron/src/renderer/index.tsx`) implementing the same interface: + +- File/dir/save pickers, `openLink`, `openPath` +- Key/value `storage(name)` with async debounced writes (250 ms flush on visibility hide/pagehide) +- Updater: `checkUpdate`, `update`, `restart` +- Notifications (OS-level, only when window unfocused) +- `fetch` override (Tauri uses `@tauri-apps/plugin-http` to bypass CORS; Electron uses Node `fetch`) +- WSL toggles + `wslPath()` translation on Windows +- Linux display-backend (Wayland vs auto) +- `parseMarkdown()` (server-side native markdown) +- Clipboard image read +- `checkAppExists`/`resolveAppPath` for "open in…" integrations +- `webviewZoom`, `getDefaultServer`, `setDefaultServer` + +### Bootstrap flow (identical state machine on both) +1. Native shell starts logging, acquires single-instance lock, registers `opencode://` deep-link handler, wires OS menu. +2. Pick a free port (env `OPENCODE_PORT` or bind 127.0.0.1:0). +3. Generate a UUID password; username is always `opencode`. +4. Spawn local server; publish `ServerReadyData { url, username, password }` **immediately** (credentials known pre-health-check). +5. Track `InitStep`: `server_waiting` → (optionally) `sqlite_waiting` → `done`. +6. If `~/.local/share/opencode/opencode.db` (or `$XDG_DATA_HOME/opencode/opencode.db`) is missing, a one-time SQLite migration runs. Progress events (`SqliteMigrationProgress`) stream to the renderer. +7. Main window is created **immediately**; the web app gates its own loading screen on the init channel. If migration takes > 1 s, an extra native "loading" overlay window appears for UX. +8. Server is polled at `GET /global/health` (with basic auth) until it returns 200. +9. On quit, the sidecar is killed in `before-quit`/`will-quit` (Electron) or `RunEvent::Exit` (Tauri). + +### Key environment +- `OPENCODE_CLIENT=desktop` (Electron sets explicitly) +- `XDG_STATE_HOME` → Electron redirects to `app.getPath("userData")` so server state lives under the app's user data dir +- `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true`, `OPENCODE_EXPERIMENTAL_FILEWATCHER=true` (Electron) +- Loopback auto-added to `NO_PROXY` so corporate proxy envs can't break the sidecar connection (both shells; Tauri uses `reqwest::Client::builder().no_proxy()` when host is loopback; Electron mutates `process.env.NO_PROXY` and Chromium's `proxy-bypass-list`) + +--- + +## 2. Tauri Shell (`packages/desktop`) + +### Stack +- Tauri 2.9.5 (`"macos-private-api"` feature), Rust edition 2024 +- Rust crate `opencode-desktop` → `opencode_lib` (staticlib, cdylib, rlib) +- `tokio` runtime for async sidecar/IPC work +- `tauri-specta` 2 + `specta` 2 — **typed IPC bridge**: Rust `#[tauri::command]` functions are auto-exported to TypeScript at `src/bindings.ts` during `cargo build`. The renderer must import `commands`/`events` from `bindings.ts` (per `AGENTS.md`: **never call `invoke` manually**). +- Tauri plugins: `opener`, `deep-link`, `shell`, `dialog`, `updater`, `process`, `store`, `window-state`, `clipboard-manager`, `http`, `notification`, `single-instance`, `os`, `decorum` +- `reqwest` (rustls) for sidecar health checks +- `comrak` for native markdown parsing +- `tracing` + `tracing-appender` for logs → `app_log_dir()` +- `process-wrap` with `ProcessGroup` (unix) / `JobObject` + `KillOnDrop` (windows) so the sidecar dies if the parent is killed + +### Layout +``` +packages/desktop/ +├── src/ # SolidJS renderer (runs in Tauri webview) +│ ├── entry.tsx # routes /loading → loading.tsx, else index.tsx +│ ├── index.tsx # builds Platform, mounts +│ ├── loading.tsx # splash window during sqlite migration +│ ├── bindings.ts # GENERATED by tauri-specta — DO NOT EDIT +│ ├── cli.ts # "install CLI" UX wrapper +│ ├── menu.ts # Tauri app menu registration +│ ├── updater.ts # Tauri updater flags +│ ├── webview-zoom.ts # zoom ctrl +│ ├── i18n/ # locale dicts, loadLocaleDict() +│ └── styles.css +├── src-tauri/ # Rust shell +│ ├── Cargo.toml +│ ├── tauri.conf.json # prod config +│ ├── tauri.beta.conf.json # beta channel overrides +│ ├── capabilities/ # permission allowlists +│ ├── entitlements.plist # macOS entitlements +│ └── src/ +│ ├── main.rs # thin entry → lib::run() +│ ├── lib.rs # orchestrator: builder, plugins, initialize() +│ ├── cli.rs # sidecar spawn/kill (process-wrap), install_cli, +│ │ # sqlite_migration::SqliteMigrationProgress event +│ ├── server.rs # spawn_local_server + health check loop +│ ├── markdown.rs # parse_markdown_command (comrak) +│ ├── windows.rs # MainWindow / LoadingWindow creation +│ ├── window_customizer.rs # PinchZoomDisablePlugin +│ ├── linux_display.rs # Wayland preference file +│ ├── linux_windowing.rs +│ ├── logging.rs # tracing setup → app_log_dir +│ ├── constants.rs # store keys, feature flags +│ └── os/ # platform-specific: check_windows_app, powershell open, etc. +└── vite.config.ts # port 1420, strictPort, watch excludes src-tauri +``` + +### Rust initialization (`lib.rs::initialize`) +1. `watch::channel(InitStep::ServerWaiting)` — single-producer state broadcast. +2. Register deep-link handler + `tauri_plugin_window_state` (denies saving loading-window geometry). +3. Synchronously spawn sidecar via `cli::serve()` (returns a `CommandChild` + exit `oneshot`), store under `ServerState`. +4. Fire a `oneshot` with `ServerReadyData { url, username: "opencode", password: uuid }` **before** health check → stored in `SidecarReady` (wrapped as `Shared` so many renderer awaits share one result). +5. If sqlite file missing, listen for the `SqliteMigrationProgress` event emitted by the sidecar itself; forward to `init_tx` as `SqliteWaiting` and resolve `done_tx` on `Done`. +6. Spawn `loading_task`: wait for (optional) sqlite done + 30-s health timeout. +7. If migration > 1 s, create `LoadingWindow`; always create `MainWindow` immediately. +8. On completion send `InitStep::Done`, wait for renderer-fired `LoadingWindowComplete`, close loading window. + +### Rust → TS commands (`make_specta_builder`) +`kill_sidecar`, `install_cli`, `await_initialization`, `get_default_server_url`, `set_default_server_url`, `get_wsl_config`, `set_wsl_config`, `get_display_backend`, `set_display_backend`, `parse_markdown_command`, `check_app_exists`, `wsl_path`, `resolve_app_path`, `open_path`. Events: `LoadingWindowComplete`, `SqliteMigrationProgress`. + +### Sidecar binary discovery +`cli::serve` locates the bundled `opencode` binary via `app.path().resolve(..., BaseDirectory::Resource)` and launches it with `opencode serve --hostname ... --port ... --auth-user opencode --auth-password `. WSL mode wraps the command in `wsl -e`. The child is held in a `process-wrap` group for reliable kill-on-drop. + +### Renderer glue (`index.tsx`) +- `void initI18n()` early. +- `createResource(() => commands.awaitInitialization(new Channel()))` opens a streaming channel while awaiting credentials. +- `storage` is backed by `@tauri-apps/plugin-store` with a `Store.load(name)` cache + memory fallback + debounced batching + auto-flush on `pagehide`/`visibilitychange` hidden. +- `fetch` is proxied through `@tauri-apps/plugin-http` (bypasses browser CORS, respects no-proxy loopback rule on Rust side). +- `webviewZoom.ts` manipulates the native webview directly on each OS via `objc2-web-kit` / `webkit2gtk` / platform APIs. + +--- + +## 3. Electron Shell (`packages/desktop-electron`) + +### Stack +- Electron 40 + `electron-vite` 5 + `electron-builder` 26 +- **No Rust**. The sidecar is not spawned as a subprocess — it is **embedded in-process** via a Vite virtual module: + - `electron.vite.config.ts` defines `virtual:opencode-server` → `../opencode/dist/node/node.js`. The Node build of the opencode server is imported directly into the Electron main process and started with `Server.listen()` (see `server.ts`). + - The corresponding `.wasm` tree-sitter blobs are copied into `out/main/chunks/` at bundle time. +- `@lydell/node-pty` for terminal support; narrowed to the current `platform-arch` variant via a pre-resolve plugin. Listed as `optionalDependencies` per platform so the installer doesn't pull all six binaries. +- `effect`, `electron-log`, `electron-store`, `electron-updater`, `electron-window-state`, `electron-context-menu`, `marked` (for native markdown), `@valibot/to-json-schema`, `sury`. +- Channels (`dev` / `beta` / `prod`) selected by `OPENCODE_CHANNEL` env; app id/name/userData path differ per channel (`ai.opencode.desktop.{dev,beta,prod}`). + +### Layout +``` +packages/desktop-electron/ +├── electron.vite.config.ts # three-target build: main / preload / renderer +├── electron-builder.config.ts # native packaging +├── scripts/predev.ts, prebuild.ts # asset sync, opencode server prebuild +├── icons/, resources/ +└── src/ + ├── main/ # Node main process + │ ├── index.ts # setupApp + initialize() state machine + │ ├── ipc.ts # registerIpcHandlers — ALL handlers live here + │ ├── server.ts # spawnLocalServer — imports virtual:opencode-server + │ ├── store.ts # electron-store wrapper (getStore(name)) + │ ├── windows.ts # createMainWindow, createLoadingWindow, titlebar + │ ├── menu.ts # OS menu builder + │ ├── apps.ts # checkAppExists, wslPath, resolveAppPath + │ ├── markdown.ts # marked() server-side parser + │ ├── migrate.ts # sqlite migration progress forwarder + │ ├── shell-env.ts # loadShellEnv(shell) — pulls PATH from login shell + │ ├── logging.ts # electron-log init + │ └── constants.ts # channel, updater flag, store keys + ├── preload/ # context-bridge + │ ├── index.ts # exposes `window.api` of type ElectronAPI + │ └── types.ts # ElectronAPI, InitStep, SqliteMigrationProgress, ... + └── renderer/ # same SolidJS app as Tauri + ├── index.html # main window + ├── loading.html # loading window (separate bundle entry) + ├── index.tsx # builds Platform from window.api + ├── loading.tsx, cli.ts, updater.ts, webview-zoom.ts, i18n/, styles.css + └── html.test.ts +``` + +### Main process init (`main/index.ts`) +1. Set app name / userData path per `OPENCODE_CHANNEL`. +2. `OPENCODE_DISABLE_EMBEDDED_WEB_UI=true` — renderer hits the server for API only, UI is served locally from Vite output. +3. Single-instance lock + `open-url` + `second-instance` → collect `opencode://` URLs into `pendingDeepLinks`. +4. `ensureLoopbackNoProxy()` + `app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")`. +5. In `app.whenReady()` → `initialize()`: + - Pick port, generate UUID password. + - `prepareServerEnv()` loads the user's shell PATH via `loadShellEnv(getUserShell())` (so the embedded server sees `node`, `git`, etc. on macOS .app launches), sets experimental flags, injects creds. + - `spawnLocalServer()` dynamically imports `virtual:opencode-server`, calls `Log.init({level:"WARN"})`, then `Server.listen({port, hostname, username, password})` — **returns a `Listener` in-process**. + - `serverReady.resolve()` publishes credentials immediately. + - Sqlite migration events are forwarded from the embedded server via `initEmitter` → `sendSqliteMigrationProgress(window, progress)` IPC. + - Health loop polls `http://127.0.0.1:/global/health` with basic auth until success or 30 s timeout. + - Loading window appears only if migration takes > 1 s; main window always opens; menu is wired with `createMenu({ trigger, checkForUpdates, reload, relaunch })`. + +### IPC surface (`main/ipc.ts`) +Handlers (invoke): +`kill-sidecar`, `install-cli`, `await-initialization` (streams `init-step` events via `event.sender.send`), `get-default-server-url`, `set-default-server-url`, `get-wsl-config`, `set-wsl-config`, `get-display-backend`, `set-display-backend`, `parse-markdown`, `check-app-exists`, `wsl-path`, `resolve-app-path`, `run-updater`, `check-update`, `install-update`, `set-background-color`, plus the `store-*` family (`get`/`set`/`delete`/`clear`/`keys`/`length`) and picker/shell helpers (`open-directory-picker`, `open-file-picker`, `save-file-picker`, etc.). + +Renderer events (sender → renderer): +`init-step`, `sqlite-migration-progress`, `menu-command`, `deep-link`. + +### Preload (`preload/index.ts`) +Exposes a single `window.api: ElectronAPI` object through `contextBridge`. `AGENTS.md`: **renderer must only touch `window.api`**, and **main must only register IPC in `main/ipc.ts`**. `onSqliteMigrationProgress`, `onMenuCommand`, `onDeepLink` return unsubscribe closures. + +### Renderer (`renderer/index.tsx`) +Structurally mirrors the Tauri renderer but maps every `Platform` method onto `window.api.*`. Uses `window.api.onSqliteMigrationProgress` to forward progress into the loading UI, and `await window.api.awaitInitialization(onStep)` to get `ServerReadyData`. + +### Auto-updater +Uses `electron-updater` with `channel: "latest"`, `autoDownload: false`, `autoInstallOnAppQuit: true`. `checkUpdate` downloads explicitly; dialog asks "Restart now?". `killSidecar()` is always invoked before `quitAndInstall()`. + +### Native helpers +`apps.ts` reimplements the Rust `check_app_exists` / `resolve_windows_app_path` / `wslpath -w|-u` logic in Node. `markdown.ts` uses `marked` to match Tauri's `parse_markdown_command` output contract. + +--- + +## 4. Key Differences at a Glance + +| Concern | Tauri (`desktop`) | Electron (`desktop-electron`) | +|-------------------------|------------------------------------------------------------|------------------------------------------------------------| +| Native runtime | Rust + Tokio, system WebView | Node + Chromium | +| Sidecar | External child process: bundled `opencode` binary | **In-process**: imports `virtual:opencode-server` Node build | +| IPC | `tauri-specta` generates typed `commands` + `events` | Manual `ipcMain.handle` + `contextBridge` on `window.api` | +| Renderer contract | Never call `invoke` — always use `bindings.ts` | Never touch `ipcRenderer` — always use `window.api` | +| Typed schema source | Rust `#[specta::specta]` → `src/bindings.ts` | `preload/types.ts` authored manually | +| Markdown | `comrak` (Rust) | `marked` (JS) | +| Storage | `tauri-plugin-store` on disk | `electron-store` | +| Secure fetch | `@tauri-apps/plugin-http` + reqwest no-proxy loopback | Node `fetch` + `NO_PROXY` env + Chromium bypass switch | +| Single-instance | `tauri-plugin-single-instance` | `app.requestSingleInstanceLock()` | +| Deep links | `tauri-plugin-deep-link` + Linux/Windows registration | `setAsDefaultProtocolClient` + `open-url`/argv scan | +| Terminal (pty) | Not hosted in shell (sidecar handles pty via its own `#pty`)| `@lydell/node-pty` in main, narrowed per platform | +| Loading window | Native window created after 1 s slow migration | Same behavior via `createLoadingWindow` | +| Updater | `tauri-plugin-updater` (optional, `UPDATER_ENABLED`) | `electron-updater` + downloads + `quitAndInstall` | +| Config channels | `tauri.conf.json` / `tauri.beta.conf.json` / `tauri.prod.conf.json` | `OPENCODE_CHANNEL` env → app id + name + userData path | + +Both shells target the same SolidJS `AppInterface` with a `sidecar` `ServerConnection` and emit identical `ServerReadyData`/`InitStep`/`SqliteMigrationProgress` shapes — so the Solid code is truly shell-agnostic. + +--- + +## 5. Running & Building + +**Tauri (primary):** +```bash +bun install +bun run --cwd packages/desktop tauri dev # dev — vite 1420 + rustc +bun run --cwd packages/desktop dev # just vite, no native shell +bun run --cwd packages/desktop tauri build # bundle +``` +Requires a Rust toolchain (`rustup`) and platform Tauri prerequisites. + +**Electron (secondary):** +```bash +bun install +bun run --cwd packages/desktop-electron dev +bun run --cwd packages/desktop-electron build +bun run --cwd packages/desktop-electron package # all platforms +bun run --cwd packages/desktop-electron package:mac|win|linux +``` +Pre-build/pre-dev scripts (`scripts/predev.ts`, `scripts/prebuild.ts`) build the opencode server Node bundle and sync native assets. + +--- + +## 6. Conventions to Respect + +- **Tauri:** Never call `invoke` manually. Always import typed `commands`/`events` from `src/bindings.ts` (generated by tauri-specta; regenerated on Rust build or via the `test_export_types` unit test). +- **Electron:** Renderer only consumes `window.api` (defined in `preload/types.ts`). Main-process IPC handlers only live in `src/main/ipc.ts`. +- Both shells import the real UI from `@opencode-ai/app` and must stay thin — feature work goes in `packages/app` unless it truly needs native capability. + +--- + +## TL;DR + +Two shells, one UI: `packages/desktop` is a Tauri/Rust app that spawns the `opencode` CLI as a managed sidecar (typed IPC via `tauri-specta`); `packages/desktop-electron` is an Electron app that embeds the OpenCode Node server directly in its main process through a Vite virtual module. Both wrap the same SolidJS `AppInterface` with a `Platform` abstraction and a sidecar `ServerConnection` — so identical features, different native packaging, strict separation rules enforced by per-package `AGENTS.md`. From b30675fa64afc2e4b9b4dd107decef4c518c878d Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Fri, 17 Apr 2026 00:14:32 -0400 Subject: [PATCH 3/6] docs: add UI testing guide and desktop Playwright planning --- .../adding-desktop-playwright-tests.md | 383 ++++++++++++++++++ .../opencode-desktop-ui-changes.md | 126 ++++++ .../ui-testing-guide.md | 300 ++++++++++++++ 3 files changed, 809 insertions(+) create mode 100644 analysis-and-understanding/adding-desktop-playwright-tests.md create mode 100644 analysis-and-understanding/opencode-desktop-ui-changes.md create mode 100644 analysis-and-understanding/ui-testing-guide.md diff --git a/analysis-and-understanding/adding-desktop-playwright-tests.md b/analysis-and-understanding/adding-desktop-playwright-tests.md new file mode 100644 index 000000000000..f8499495695d --- /dev/null +++ b/analysis-and-understanding/adding-desktop-playwright-tests.md @@ -0,0 +1,383 @@ +# Adding Playwright Tests with Screenshots for Desktop UI + +How hard would it be to start covering the OpenCode desktop UI with Playwright + visual regression? Short answer: **medium for Electron (3–5 days), hard for Tauri (weeks, experimental tooling), and trivial for browser-only pixel assertions against the shared SolidJS UI.** The big friction isn't writing tests — it's booting the shell deterministically and wrangling the embedded sidecar. + +--- + +## Three Scopes, Three Difficulty Levels + +| Scope | What it actually tests | Difficulty | Effort | +|---|---|---|---| +| **A. Browser-only visual regression** | The SolidJS app running in Chromium (same code both desktops render) | ⭐ Easy | 1–2 days | +| **B. Electron end-to-end** | Real Electron main + renderer + in-process sidecar | ⭐⭐ Medium | 3–5 days (7 if cross-platform CI) | +| **C. Tauri end-to-end** | Real Tauri system webview on each OS | ⭐⭐⭐⭐ Hard | 2–4 weeks, and you'd be pioneering | + +Most teams land A + B and skip C. + +--- + +## A. Browser-only Visual Regression (Recommended first step) + +Since both desktop shells render the same `@opencode-ai/app` code, running Playwright against that UI in Chromium catches ~90% of visual regressions without any native-shell complexity. The harness is already there — it's what we wired up in `ui-testing-guide.md`. + +### What's already in place + +- `@playwright/test` wired up in `packages/app` +- Chromium installed via `bunx playwright install chromium` +- `playwright.config.ts` auto-starts Vite, configures `screenshot: "only-on-failure"`, `video: "retain-on-failure"`, `trace: "on-first-retry"` +- Test runs cleanly (`1 skipped` on the `test.fixme()` stub) + +### What you'd add + +```ts +// packages/app/e2e/smoke.spec.ts +import { test, expect } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + // anti-flake: kill animations, transitions, caret blink + await page.emulateMedia({ reducedMotion: "reduce" }) + await page.addStyleTag({ + content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }", + }) +}) + +test("home renders", async ({ page }) => { + await page.goto("/") + await expect(page).toHaveScreenshot("home.png", { maxDiffPixels: 100 }) +}) +``` + +First run creates the baseline under `e2e/__screenshots__/`. Subsequent runs diff against it. + +### Known gotchas + +- **Font / scrollbar / cursor diffs across machines.** Commit per-platform baselines (`home-darwin.png` vs `home-linux.png`) or run visual tests only on Linux in CI. +- **Backend dependency.** Most real flows need `opencode serve` on `:4096`. Options: + - Mock with `await page.route("**/api/**", (r) => r.fulfill({ json: fixture }))` — fastest, no backend needed + - Start a real backend (see `ui-testing-guide.md` § "Running Against a Real Backend") +- **Flaky specs.** Prefer `toHaveScreenshot` with `maxDiffPixels` > `toMatchSnapshot` — it waits for network idle automatically. + +### Effort breakdown + +| Task | Effort | +|---|---| +| Scaffold anti-flake style helper + test fixture | 2 hours | +| Decide on API mocking strategy and write shared route stubs | 3 hours | +| Land 10 representative snapshot tests (home, session, settings, etc.) | 4 hours | +| Stabilize after first CI flake surface | 0.5–1 day | + +**Total: 1–2 days.** + +### Value + +Catches most UI regressions for both desktops at once. If your goal is "PR screenshots for designers," add Chromatic/Percy/Argos on top of this and stop here. + +--- + +## B. Full Electron E2E with Screenshots + +This is the real "desktop UI tests with screenshots" option. Playwright officially supports Electron via `_electron.launch()` — you spawn the Electron binary, get a `Page` per `BrowserWindow`, interact normally. + +### Baseline API + +```ts +// packages/desktop-electron/e2e/smoke.spec.ts +import { _electron as electron, test, expect } from "@playwright/test" + +test("launches and shows main window", async () => { + const app = await electron.launch({ + args: ["./out/main/index.js", `--user-data-dir=${tmpDir}`], + env: { + ...process.env, + OPENCODE_PORT: "45671", + OPENCODE_SERVER_PASSWORD: "test-password", + XDG_STATE_HOME: tmpDir, + NODE_ENV: "test", + }, + }) + + const window = await app.firstWindow() + await window.waitForLoadState("domcontentloaded") + await expect(window).toHaveScreenshot("main.png") + await app.close() +}) +``` + +### Why this is harder than browser tests (OpenCode-specific) + +1. **Embedded sidecar is in-process.** The server ships inside the Electron main bundle via `virtual:opencode-server`. Good news: no subprocess to manage. Bad news: it writes to `XDG_STATE_HOME` (= `app.getPath("userData")`), polluting real user data unless isolated. + - **Fix:** per-test tmp dir passed via `--user-data-dir=` AND matching `XDG_STATE_HOME`/`XDG_DATA_HOME` in `env`. + +2. **One-time SQLite migration on first run.** The migration runs on a clean userData dir and can take seconds. Your test must: + - Wait for the `sqlite-migration-progress` IPC `Done` event before driving the UI, OR + - Pre-seed `opencode.db` into the tmp dir from a committed fixture to skip migration entirely (faster — probably 10× speed-up). + +3. **Sidecar health check is 30 s max.** Cold-start tests need Playwright `test.setTimeout(60_000)` or longer. + +4. **Random port + random UUID password every launch.** `main/index.ts` picks a free port; `prepareServerEnv` injects a UUID password. If your test wants to make API calls directly, either: + - Pin both via `env.OPENCODE_PORT` and `env.OPENCODE_SERVER_PASSWORD` — `prepareServerEnv` merges `process.env` first, so env-provided values win, OR + - Expose them from the main process via a test-only IPC handler. + +5. **`app.setPath("userData")` runs at module top.** The channel-specific suffix (`ai.opencode.desktop.dev`) is applied before `whenReady`. Passing `--user-data-dir` via Chromium CLI overrides Electron's path logic, giving you clean isolation. + +6. **Packaged vs unpackaged.** Both work: + - **Unpackaged (dev):** `args: ["./out/main/index.js"]` after `bun run build`. Fast iteration, good for PR CI. + - **Packaged:** `executablePath: "./dist/mac-arm64/OpenCode.app/Contents/MacOS/OpenCode"`. Closer to reality. Run nightly, not per-PR. + +7. **Loading window complication.** `createLoadingWindow` fires only if migration takes >1 s. With a pre-seeded db, it doesn't appear; with a clean db, it does. `app.firstWindow()` returns whichever pops first — prefer enumerating `app.windows()` and matching by URL (`index.html` vs `loading.html`) or using `app.waitForEvent("window")` with a filter. + +8. **Deep links, menus, updater.** These trigger via OS events that tests can't generate directly. Use `app.evaluate(({ BrowserWindow, app }) => app.emit("open-url", {}, "opencode://..."))` to simulate from inside Electron main, or add a test-only IPC to trigger them. + +9. **Loopback proxy rewriting.** `ensureLoopbackNoProxy()` mutates `process.env.NO_PROXY` at startup. If your CI sets a proxy, confirm the code path still runs correctly. + +### Infrastructure you'd add + +``` +packages/desktop-electron/ +├── e2e/ +│ ├── fixtures/ +│ │ ├── opencode-prebuilt.db # pre-migrated SQLite for fast startup +│ │ └── seed-session.json # canned data if needed +│ ├── helpers/ +│ │ ├── launch.ts # electron.launch wrapper: tmp dirs, pinned port/password +│ │ ├── screenshot.ts # anti-flake styles, wait-for-quiet +│ │ └── ipc.ts # test-only IPC helpers via app.evaluate +│ ├── fixtures.ts # Playwright fixtures: electronApp, mainWindow +│ ├── smoke.spec.ts # window opens, title, basic screenshot +│ └── flows/ +│ ├── create-session.spec.ts +│ ├── switch-agent.spec.ts +│ └── open-project.spec.ts +├── playwright.config.ts # separate from packages/app config +└── package.json # add test:e2e, test:e2e:ui scripts +``` + +### Effort breakdown + +| Task | Effort | +|---|---| +| Write `launch.ts` with tmp userData, pinned port/password, env plumbing | 0.5 day | +| Playwright fixtures + first smoke test (main window screenshot) | 0.5 day | +| SQLite pre-seed fixture + skip-migration flow | 0.5 day | +| Handle loading window + init-step channel awaits | 0.5 day | +| Write 5–10 real flow specs with screenshots | 1–1.5 days | +| Wire into Turbo (`@opencode-ai/desktop-electron#test:e2e`) + JUnit | 0.5 day | +| Stabilize CI on macOS and Linux (Xvfb) | 0.5–1 day | +| Decide packaged vs unpackaged CI strategy | 0.5 day | + +**Total: 3–5 days for one dev comfortable with Playwright Electron; 7 if cross-platform.** + +### Why this *isn't* harder + +- `_electron` API is stable and Microsoft-maintained +- Your IPC is already typed (`preload/types.ts`), so asserting on `window.api` is straightforward +- Init flow has explicit phases (`InitStep`: `server_waiting` → `sqlite_waiting` → `done`) you can await deterministically +- No Spectron (dead), no WebdriverIO, no CDP shenanigans +- The shell code is thin — ~30 main-process code paths to cover + +### What you get + +- Real render pipeline, real CSS, real fonts — screenshots match what users see +- Coverage of main-process ↔ renderer IPC contract +- Catches sidecar lifecycle bugs (spawn, health, kill-on-quit) +- Deep-link / menu / updater flows testable via `app.evaluate` +- A base you can extend with accessibility checks (`@axe-core/playwright`), performance budgets, etc. + +--- + +## C. Full Tauri E2E with Screenshots (Not Recommended Yet) + +This is where the ecosystem fights you. + +### Why it's hard + +- **There is no `playwright._tauri` API.** Playwright does not support Tauri. +- Tauri's official test story is **`tauri-driver`**, a WebDriver proxy: + - **Linux:** wraps `WebKitWebDriver` (via `webkit2gtk`) — works + - **Windows:** wraps `msedgedriver` — works, sometimes flaky + - **macOS:** no viable WebDriver for WKWebView — **blocked today** +- Tests would be written in WebdriverIO or Selenium syntax, not Playwright's. Different API, different screenshot tooling, different assertion library. +- You'd have zero automated macOS/Windows Tauri coverage for users (who are most of your user base, because macOS is Tauri's primary target). + +### The community workaround + +Some projects "test Tauri with Playwright" by disabling the native shell and running the webview content in real Chromium. That's effectively **option A in disguise** — you're not testing Tauri anymore, you're testing the same SolidJS UI we already cover. + +### Pragmatic strategy + +Unless you specifically need to validate: +- Rust-side behavior (`cli.rs` sidecar spawning via `process-wrap`, `lib.rs::initialize` state machine) +- `tauri-specta` bindings drift (already covered by the `test_export_types` Rust unit test) +- Linux/Windows-specific windowing (Wayland backend toggling, etc.) +- Deep-link registration on each OS + +…skip Tauri E2E. Lean on: +- **Option A** for visual coverage of the renderer +- **Option B** (Electron) as your "real native app" coverage tier — same renderer code, so bugs in the UI surface there too +- **Rust `cargo test`** for shell-specific logic (already in place: 27 tests across `cli.rs`, `lib.rs`, `linux_windowing.rs`) +- **Manual release smoke** for the Tauri binary per platform + +### Effort if you insist + +**2–4 weeks** to get a Linux-only `tauri-driver` + WebdriverIO pipeline stable in CI. Rewrites of assertions into Selenium-style, custom screenshot comparison, no macOS coverage at the end. Value per hour is low. + +### When to revisit + +Watch for: +- A `playwright._tauri` launcher (community interest exists) +- Tauri 3 shipping a first-party WebDriver for macOS (WKWebView maturing) +- `tauri-driver` gaining macOS support via Safari Technology Preview or similar + +Until then, manual smoke is the rational choice. + +--- + +## Realistic Rollout Recommendation + +If I were prioritizing this, I'd do: + +### Week 1 — Option A + +Write real Playwright specs in `packages/app/e2e/` with visual regression. You already have the infra. Catches 90% of UI regressions because both desktop shells render this exact code. + +- Replace `todo.spec.ts` with 10 real specs covering top-level routes +- Add anti-flake styling helper + `page.route` API mocks +- Commit per-platform baselines +- Optional: wire Chromatic/Percy/Argos for PR previews + +### Week 2 — Option B (Electron) + +Stand up `_electron` tests in `packages/desktop-electron/e2e/`. + +- Pre-seed SQLite fixture to make cold start fast +- Pin sidecar port + password via env +- Isolate every test via tmp userData dir +- Cover 5–10 critical flows with screenshots +- Gate CI on macOS + Linux (Electron supports both cleanly) + +### Defer — Option C (Tauri) + +Rely on existing Rust unit tests + manual release smoke. Revisit when the tooling matures. + +--- + +## Goal-Driven Decision Tree + +| If your real goal is… | Do this | +|---|---| +| "Designers want to see UI changes in PRs" | Option A + Chromatic/Percy. Done in 1–2 days. | +| "Catch visual regressions in the UI" | Option A. Start here. | +| "Catch bugs specific to the desktop shell" (IPC drift, sidecar lifecycle, deep links, updater) | Option B. A won't help you. | +| "Pixel-perfect coverage of the packaged app users install" | Option B (packaged mode, nightly CI) + manual per-release smoke for Tauri. | +| "Full cross-shell, cross-platform automation" | Not achievable today for Tauri. Accept B + manual for macOS Tauri. | + +--- + +## Key Technical Decisions You'll Need to Make + +1. **Visual regression tool:** native Playwright `toHaveScreenshot` (free, per-platform baselines in repo) vs Chromatic/Percy/Argos (hosted, PR comments, cross-machine stable). +2. **Backend strategy for UI tests:** API mocks via `page.route` (fast, isolated) vs real `opencode serve` (realistic, slower, flaky). +3. **SQLite startup:** pre-seeded fixture (fast, one code path) vs fresh migration each run (slow, tests the migration itself). +4. **CI matrix:** macOS-only (covers most devs), Linux-only (cheapest, stable), or both (ideal). +5. **Packaged vs unpackaged Electron:** PR runs unpackaged (fast), nightly runs packaged (real-world). +6. **Test-only IPC surface:** add conditional handlers (`if (process.env.NODE_ENV === "test")`) or use `app.evaluate` in Playwright to drive main directly (no production code changes). + +--- + +## Why Electron Is the Sweet Spot + +- Playwright + Electron is a first-party, documented, maintained combo +- Your IPC is already typed and centralized +- The init flow is state-machine-driven with observable phases +- The shell is thin; most complexity lives in `@opencode-ai/app` which is also testable standalone +- The sidecar being in-process means one-less-thing to manage from tests +- Hard parts (tmp dirs, port/password pinning, SQLite fixture, loading-window timing) all have known single-afternoon solutions + +The Tauri shell is harder not because OpenCode's architecture is wrong, but because the broader Tauri + Playwright ecosystem hasn't caught up yet. + +--- + +## Starter Scaffold (What I'd Write First) + +Minimum viable Electron E2E to prove the path: + +```ts +// packages/desktop-electron/e2e/helpers/launch.ts +import { mkdtempSync, copyFileSync, mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { _electron, type ElectronApplication } from "@playwright/test" + +export async function launch(opts: { seedDb?: boolean } = {}): Promise<{ app: ElectronApplication; userDataDir: string }> { + const userDataDir = mkdtempSync(join(tmpdir(), "opencode-e2e-")) + const dataDir = join(userDataDir, "opencode") + mkdirSync(dataDir, { recursive: true }) + + if (opts.seedDb) { + copyFileSync(join(__dirname, "../fixtures/opencode-prebuilt.db"), join(dataDir, "opencode.db")) + } + + const app = await _electron.launch({ + args: [join(__dirname, "../../out/main/index.js"), `--user-data-dir=${userDataDir}`], + env: { + ...process.env, + OPENCODE_PORT: "45671", + OPENCODE_SERVER_PASSWORD: "test-password", + XDG_STATE_HOME: userDataDir, + XDG_DATA_HOME: userDataDir, + NODE_ENV: "test", + }, + timeout: 60_000, + }) + + return { app, userDataDir } +} +``` + +```ts +// packages/desktop-electron/e2e/smoke.spec.ts +import { test, expect } from "@playwright/test" +import { launch } from "./helpers/launch" + +test("main window opens with seeded db", async () => { + test.setTimeout(60_000) + const { app } = await launch({ seedDb: true }) + + const window = await app.firstWindow() + await window.waitForLoadState("domcontentloaded") + await window.emulateMedia({ reducedMotion: "reduce" }) + + await expect(window).toHaveScreenshot("smoke-main.png", { maxDiffPixels: 200 }) + await app.close() +}) +``` + +```ts +// packages/desktop-electron/playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { timeout: 10_000 }, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, +}) +``` + +Adding this plus a matching `bun run build` prereq and `test:e2e` script gets you the first green screenshot in under a day. + +--- + +## TL;DR + +- **Option A (browser-only visual):** 1–2 days. Do this first. Covers most regressions. +- **Option B (Electron E2E):** 3–5 days. Real native coverage, real screenshots, well-supported tooling. Worth doing. +- **Option C (Tauri E2E):** weeks, experimental, no macOS coverage. Skip until the ecosystem matures. + +The OpenCode codebase is well-suited for Playwright Electron specifically because it already has typed IPC, observable init phases, and a thin shell. The blockers are environmental (tmp dirs, port pinning, SQLite fixture) — each is a 2–4 hour fix. diff --git a/analysis-and-understanding/opencode-desktop-ui-changes.md b/analysis-and-understanding/opencode-desktop-ui-changes.md new file mode 100644 index 000000000000..15979bf14627 --- /dev/null +++ b/analysis-and-understanding/opencode-desktop-ui-changes.md @@ -0,0 +1,126 @@ +# Where UI Changes Go in OpenCode Desktop + +**Short answer: almost never in `packages/desktop` or `packages/desktop-electron`. Almost always in `packages/app` (feature UI) or `packages/ui` (shared primitives).** + +Both desktop shells render the exact same SolidJS application. Their `.tsx` files are ~10 lines each and only wire a `Platform` object into the shared tree: + +```ts +// packages/desktop/src/index.tsx AND packages/desktop-electron/src/renderer/index.tsx +import { AppBaseProviders, AppInterface, PlatformProvider, ... } from "@opencode-ai/app" +``` + +That `@opencode-ai/app` import is the UI. Everything else is glue. + +--- + +## Decision Guide — Where Does Your Change Belong? + +| What you want to change | Package | Why | +|---|---|---| +| A screen, page, dialog, settings panel, session view, prompt input, titlebar, terminal panel, file tree, model picker, etc. | **`packages/app`** | This is the SolidJS app. `src/pages/` = routes, `src/components/` = feature-specific Solid components. | +| A generic reusable primitive (button, popover, tooltip, icon set, diff viewer, theme tokens, shiki setup, font/audio assets) | **`packages/ui`** | Design-system level, consumed by `app` and potentially other hosts (console, web, storybook). Exports are path-specific: `@opencode-ai/ui/Button`, `@opencode-ai/ui/theme`, `@opencode-ai/ui/styles`, etc. | +| Global CSS / Tailwind tokens used across the UI | `packages/app/src/index.css` or `packages/ui/src/styles/` | App-scoped vs design-system-scoped. Tailwind is generated via `bun run --cwd packages/ui generate:tailwind`. | +| Translations / new locale strings | `packages/app/src/i18n/` (app-specific) or `packages/ui/src/i18n/` (primitive-level) | Matches where the text lives. | +| Icons (provider/file-type/app) | `packages/ui/src/components/{provider-icons,file-icons,app-icons}` | Typed via `@opencode-ai/ui/icons/provider` etc.; shared spritesheet via `vite-plugin-icons-spritesheet`. | +| A **new capability the web build cannot express** (native dialog, notification, OS menu, webview zoom, deep link, filesystem access, clipboard image, auto-updater UX, WSL toggle, shell env) | **Both desktop packages** + extend `Platform` in `packages/app` | See "Platform Abstraction" below. | +| Native window behavior, tray, app menu, updater plumbing | `packages/desktop/src-tauri/` (Rust) and `packages/desktop-electron/src/main/` (Node main) | Pure shell work. | +| Splash / loading window appearance | `packages/desktop/src/loading.tsx` and `packages/desktop-electron/src/renderer/loading.tsx` | These are shell-specific mini-renderers (they aren't inside `AppInterface`). | + +--- + +## The Critical Pattern: `Platform` + +If a UI change needs something only a native shell can do, don't reach for Tauri/Electron APIs from the UI. Instead: + +1. **Extend the `Platform` type** in `packages/app/src/context/platform.tsx`. (It's re-exported from `@opencode-ai/app` as `Platform` / `PlatformProvider`.) Define an interface method, e.g. `openInFinder(path: string): Promise`. +2. **Consume it** in your UI via the context hook (there's a `usePlatform`/similar hook in `packages/app/src/context/platform.tsx`). +3. **Implement it twice** — once in each shell: + - **Tauri:** add a Rust `#[tauri::command] #[specta::specta]` in `packages/desktop/src-tauri/src/*.rs`, register it in `make_specta_builder()` (`lib.rs`). Rebuild Rust → `src/bindings.ts` is regenerated by `tauri-specta` → call `commands.openInFinder(...)` from `packages/desktop/src/index.tsx`. + - **Electron:** add an `ipcMain.handle("open-in-finder", ...)` in `packages/desktop-electron/src/main/ipc.ts`, expose it on `window.api` in `packages/desktop-electron/src/preload/index.ts` + type it in `preload/types.ts`, then call it from `packages/desktop-electron/src/renderer/index.tsx`. +4. Browser/web builds can provide a no-op or throw. + +This is why both `desktop/src/index.tsx` and `desktop-electron/src/renderer/index.tsx` look nearly identical — they're both implementing the same `Platform` contract via different native backends. **If you only add the call to one shell, the other will break at runtime.** + +--- + +## Fastest Dev Loop for UI Work + +Per `packages/app/AGENTS.md`, you do not need to run Tauri or Electron at all: + +```bash +# Terminal 1 — backend (from opencode/packages/opencode) +bun run --conditions=browser ./src/index.ts serve --port 4096 + +# Terminal 2 — UI (from opencode/packages/app) +bun dev -- --port 4444 +# Open http://localhost:4444 +``` + +This renders the exact UI the desktop shells render, with instant HMR and no Rust/Electron rebuild. Only drop into `tauri dev` / `electron-vite dev` when your change specifically touches the native integration, the `Platform` wiring, or something the browser can't reproduce (e.g. OS notifications, deep links, native menus). + +--- + +## Conventions to Respect + +- From `packages/app/AGENTS.md`: prefer `createStore` over multiple `createSignal` calls. +- From the Tauri `AGENTS.md`: **never** call Tauri's `invoke` from inside `packages/app` — the app must stay shell-agnostic. All native calls flow through the `Platform` object. +- From the Electron `AGENTS.md`: same principle — renderer only touches `window.api`, and that too only from the tiny shell layer in `packages/desktop-electron/src/renderer/index.tsx`, never from `packages/app`. +- Shared design-system components must not import from `@opencode-ai/app`. `ui` → `app` is the allowed direction, never the reverse. + +--- + +## Package Layout Reference + +``` +packages/ +├── app/ ← 99% of feature UI lives here +│ └── src/ +│ ├── pages/ ← route-level screens +│ │ ├── home.tsx +│ │ ├── session.tsx, session/ +│ │ ├── directory-layout.tsx +│ │ ├── layout.tsx, layout/ +│ │ └── error.tsx +│ ├── components/ ← feature components +│ │ ├── dialog-*.tsx (connect-provider, settings, fork, select-model, …) +│ │ ├── settings-*.tsx (general, keybinds, providers, models) +│ │ ├── prompt-input.tsx, prompt-input/ +│ │ ├── session-*.tsx, session/ +│ │ ├── titlebar.tsx +│ │ ├── terminal.tsx +│ │ ├── file-tree.tsx +│ │ └── status-popover*.tsx +│ ├── context/ ← Platform, language, server, command, … +│ ├── hooks/ +│ ├── i18n/ ← app-specific translations +│ ├── addons/ +│ ├── constants/ +│ ├── utils/ +│ ├── index.ts ← public exports consumed by shells +│ ├── entry.tsx, app.tsx +│ └── index.css +│ +├── ui/ ← shared design-system primitives +│ └── src/ +│ ├── components/ ← Button, Popover, icons, diffs, … +│ ├── context/ +│ ├── hooks/ +│ ├── theme/ ← design tokens +│ ├── styles/ ← global + tailwind +│ ├── i18n/ +│ ├── assets/ ← fonts, audio +│ ├── pierre/ +│ └── storybook/ +│ +├── desktop/ ← Tauri shell (thin) +│ └── src/index.tsx ← builds Platform from Tauri APIs, mounts AppInterface +│ +└── desktop-electron/ ← Electron shell (thin) + └── src/renderer/index.tsx ← builds Platform from window.api, mounts AppInterface +``` + +--- + +## TL;DR + +**99% of UI work lives in `packages/app/src/` (feature UI) or `packages/ui/src/` (shared primitives). Touch `packages/desktop{,-electron}` only when you need a new native capability — and when you do, model it as a method on `Platform` and implement it in both shells.** diff --git a/analysis-and-understanding/ui-testing-guide.md b/analysis-and-understanding/ui-testing-guide.md new file mode 100644 index 000000000000..69aa42377531 --- /dev/null +++ b/analysis-and-understanding/ui-testing-guide.md @@ -0,0 +1,300 @@ +# UI Testing Guide (OpenCode) + +How to test UI in this repo — what each tier actually does, where tests live, and how to run them. The short version: **almost everything here is headless**, and the one tool that produces screenshots is essentially unused today. + +--- + +## The Three Tiers + +| Tier | Tool | File pattern | Runs in | Sees pixels? | Count in repo | +|---|---|---|---|---|---| +| Unit / logic | `bun test` | `*.test.ts` (many in `test/` dirs) | Bun runtime (no DOM) | ❌ No | ~173 files | +| Component / DOM | `bun test --preload ./happydom.ts` | `*.test.ts` co-located with source | Bun + **happy-dom** (simulated DOM) | ❌ No (DOM exists in memory, never rendered) | ~52 files (all `packages/app`) | +| E2E | `playwright test` | `e2e/*.spec.ts` | Real **Chromium** browser | ✅ Yes — screenshots, videos, traces on failure | **1 file** (a `test.fixme()` stub) | + +### Bun tests are headless + +`bun:test` is Bun's built-in Jest/Vitest-compatible runner. Code runs directly in the Bun runtime — no browser, no Electron, no Tauri webview. Output is plain pass/fail text. No `page`, no `click()`, no screenshots, ever. + +```ts +import { describe, expect, test } from "bun:test" + +describe("foo", () => { + test("bar", () => { + expect(1 + 1).toBe(2) + }) +}) +``` + +### happy-dom tests are also headless (even though they test components) + +The 52 files in `packages/app` preload `happydom.ts`, which registers `document`, `window`, `Element`, etc. as globals into the Bun process. Solid components mount into a detached `
` and assertions check `textContent`, attributes, dispatched events. There is **no layout, no paint, no CSS rendering** — happy-dom doesn't run a compositor. You can't screenshot what isn't rendered. + +```ts +import { render } from "solid-js/web" +import { test, expect } from "bun:test" + +test("renders title", () => { + const div = document.createElement("div") + render(() => , div) + expect(div.textContent).toContain("hi") +}) +``` + +### Playwright E2E is the only source of visuals + +And it's currently empty. Screenshots/videos exist in the config but only fire on failure, and there's nothing substantive to fail: + +```ts +// packages/app/playwright.config.ts +use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", +} +``` + +So nothing visual is being generated anywhere in the project today. + +--- + +## Running Bun Tests + +The default branch is `dev`. Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from each package directory. + +### `packages/opencode` — unit + integration (157 files) + +```bash +cd packages/opencode +bun test # everything +bun test test/session # a subtree +bun test test/session/session.test.ts # one file +bun test --watch # re-run on save +``` + +Mirrors `src/` under `test/`: `test/session/`, `test/util/`, `test/tool/`, `test/cli/tui/`, `test/plugin/`, `test/file/`, `test/server/`, `test/cli/`, `test/project/`, `test/mcp/`, `test/effect/`, `test/config/`, `test/provider/` (+ `copilot/`), `test/lsp/`, `test/storage/`, `test/pty/`, `test/bus/`, `test/skill/`, `test/permission/`, `test/control-plane/`, plus a few at `test/` root. + +### `packages/app` — components + utilities (52 files) + +These need happy-dom preloaded: + +```bash +cd packages/app +bun run test:unit # = bun test --preload ./happydom.ts ./src +bun run test:unit:watch # same, --watch +bun run test # alias for test:unit +``` + +Organized by role: `src/utils/` (11), `src/context/` (11 + `context/global-sync/` 6), `src/pages/session/` (6), `src/components/prompt-input/` (6), `src/components/` (3). + +### Other packages + +```bash +cd packages/ui && bun test # 5 files +cd packages/shared && bun test # 2 files +cd packages/console && bun test # 3 files +cd packages/enterprise && bun test # 2 files +cd packages/desktop-electron && bun test # 2 files (shell-env + html) +``` + +### Testing conventions (from repo `AGENTS.md`) + +- **Avoid mocks** as much as possible +- **Test actual implementation**; don't duplicate logic into tests +- Never `cd` then run — use the package directory directly + +--- + +## Running Playwright (the only visual option) + +### TL;DR + +From `packages/app/`: + +```bash +PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed +``` + +Result today: `1 skipped`. The only spec is `e2e/todo.spec.ts`, a `test.fixme()` placeholder. Getting it to skip cleanly means the pipeline (Bun → Vite webServer → Playwright → Chromium) is wired end-to-end. + +### First-time setup + +OpenCode pins Bun in its root `package.json` (`"packageManager": "bun@1.3.11"`) and uses Bun-only APIs like `Bun.file()`. npm/pnpm won't work. + +```bash +# 1. Install Bun if missing +brew install oven-sh/bun/bun # or: curl -fsSL https://bun.sh/install | bash + +# 2. Install workspace deps (from repo root) +bun install + +# 3. Install the Chromium browser binary (from packages/app) +bunx playwright install chromium # add --with-deps on Linux +``` + +### The port-3000 gotcha + +`packages/app/playwright.config.ts` defaults the Vite `webServer` to port **3000**. If something else is on that port (Docker Desktop commonly holds 3000–3002), Vite auto-shifts and Playwright waits 120 s on the wrong port, then fails with: + +``` +Error: Timed out waiting 120000ms from config.webServer. +``` + +Detect: + +```bash +lsof -nP -iTCP:3000 -iTCP:3001 -iTCP:3002 -sTCP:LISTEN +``` + +Avoid — set an explicit free port. The same env var feeds both Vite and Playwright: + +```bash +PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed +``` + +### Commands + +All from `packages/app/`: + +```bash +# Headed — watch Chromium +PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed + +# Headless (fast, matches CI) +PLAYWRIGHT_PORT=3030 bun run test:e2e + +# Interactive UI mode — best for writing/debugging specs +PLAYWRIGHT_PORT=3030 bun run test:e2e:ui + +# Single spec +PLAYWRIGHT_PORT=3030 bunx playwright test e2e/todo.spec.ts --headed + +# Codegen — record a test by clicking +PLAYWRIGHT_PORT=3030 bunx playwright codegen http://127.0.0.1:3030 + +# Open the last HTML report +bun run test:e2e:report +``` + +### What the config provides + +From `packages/app/playwright.config.ts`: + +- `testDir: "./e2e"`, `outputDir: "./e2e/test-results"` +- 60 s test timeout, 10 s `expect` timeout +- CI: 2 retries; local: 0 +- Single project: `chromium` with `devices["Desktop Chrome"]` +- `webServer.command` = `bun run dev -- --host 0.0.0.0 --port ${PLAYWRIGHT_PORT ?? 3000}` with 120 s boot window and `reuseExistingServer: !CI` +- Env passed into Vite: `VITE_OPENCODE_SERVER_HOST` (default `127.0.0.1`), `VITE_OPENCODE_SERVER_PORT` (default `4096`) +- Reporters: HTML (`e2e/playwright-report`) + line; adds JUnit when `PLAYWRIGHT_JUNIT_OUTPUT` is set +- Failure artifacts: trace on first retry, screenshot only on failure, video retained on failure + +### Env vars worth knowing + +| Variable | Purpose | +|---|---| +| `PLAYWRIGHT_PORT` | Port Vite + Playwright share (workaround for 3000 conflict) | +| `PLAYWRIGHT_BASE_URL` | Full URL override (e.g. remote deploy) | +| `PLAYWRIGHT_SERVER_HOST` | Forwarded to Vite as `VITE_OPENCODE_SERVER_HOST` | +| `PLAYWRIGHT_SERVER_PORT` | Forwarded to Vite as `VITE_OPENCODE_SERVER_PORT` | +| `PLAYWRIGHT_WORKERS` | Parallel workers (5 in CI by default, auto local) | +| `PLAYWRIGHT_FULLY_PARALLEL` | `"1"` to parallelize within files | +| `PLAYWRIGHT_JUNIT_OUTPUT` | Path to write JUnit XML for CI | + +### Running against a real backend + +The config only starts Vite — not `opencode serve`. If your spec hits API routes, start the backend in a second terminal first: + +```bash +# Terminal 1 — backend (from packages/opencode) +bun run --conditions=browser ./src/index.ts serve --port 4096 + +# Terminal 2 — tests (from packages/app) +PLAYWRIGHT_PORT=3030 \ + PLAYWRIGHT_SERVER_HOST=127.0.0.1 \ + PLAYWRIGHT_SERVER_PORT=4096 \ + bun run test:e2e -- --headed +``` + +Defaults already point at `127.0.0.1:4096`, so the `PLAYWRIGHT_SERVER_*` vars are optional if your backend matches. + +**Per `packages/app/AGENTS.md`: never restart the app or server process during a debugging session** — start it once and leave it running. + +### Current state of the E2E suite + +`packages/app/e2e/todo.spec.ts` is the only spec: + +```ts +import { test } from "@playwright/test" + +test( + "test something cool", + { annotation: { type: "todo" } }, + async () => { + test.fixme() + }, +) +``` + +`test.fixme()` marks it as a known-broken placeholder, so it always skips. **There is no substantive E2E coverage today.** Getting Playwright to run successfully currently just proves the harness works; writing real specs is the next step. + +--- + +## First-Run Reproduction Log + +``` +$ which bun +bun not found + +$ brew install oven-sh/bun/bun +... +🍺 /opt/homebrew/Cellar/bun/1.3.12: 8 files, 61.5MB, built in 2 seconds + +$ cd opencode && bun install +... +4764 packages installed [32.03s] + +$ cd packages/app && bunx playwright install chromium +(installs Chromium binary) + +$ bun run test:e2e -- --headed +$ playwright test --headed +[WebServer] $ vite --host "0.0.0.0" --port "3000" +Error: Timed out waiting 120000ms from config.webServer. + +$ lsof -nP -iTCP:3000 -iTCP:3001 -iTCP:3002 -sTCP:LISTEN +COMMAND PID NAME +com.docke 14869 *:3000 +com.docke 14869 *:3001 +com.docke 14869 *:3002 + +$ PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed +$ playwright test --headed +[WebServer] $ vite --host "0.0.0.0" --port "3030" +Running 1 test using 1 worker +[1/1] [chromium] › e2e/todo.spec.ts:3:1 › test something cool + 1 skipped +``` + +--- + +## Which Tier Should I Use? + +- **Assert logic, data flow, types, service composition** → `bun test` (fast, hundreds of ms, no window) +- **Assert that a component renders the right text/attrs/events** → `bun test` + happy-dom (still no window, simulated DOM only) +- **See the app, take screenshots, check CSS rendering, click real elements, assert navigation** → Playwright (basically unwritten here; you'll be adding the first real specs) + +Desktop shells (`packages/desktop`, `packages/desktop-electron`) have their own tiny unit coverage (Rust `#[test]` and Bun `*.test.ts`). Neither has Playwright, Spectron, or `tauri driver` — there is no automated test that launches the packaged native app today. See `opencode-desktop-testing.md` for that deeper story. + +--- + +## Troubleshooting + +- **`Timed out waiting 120000ms from config.webServer`** → port conflict. Check `lsof`, set `PLAYWRIGHT_PORT`. +- **`browserType.launch: Executable doesn't exist at .../chromium...`** → you skipped `bunx playwright install chromium`. +- **`command not found: bun`** → `brew install oven-sh/bun/bun`. +- **`1 skipped` every run** → expected. The only spec is `test.fixme()`. Write a real one. +- **HMR / "Page closed" errors headed** → spec finishing before the page stabilizes; add `await expect(page).toHaveURL(...)` or `await page.waitForLoadState("networkidle")`. +- **CI-only failures** → CI enables `retries: 2` and (optionally) `fullyParallel`. Reproduce locally with `CI=1 PLAYWRIGHT_FULLY_PARALLEL=1 bun run test:e2e`. +- **happy-dom test `ReferenceError: document is not defined`** → run through `packages/app`'s scripts (`bun run test:unit`), not raw `bun test` — those scripts pass `--preload ./happydom.ts`. +- **"do-not-run-tests-from-root" error** → `bun test` must be invoked from a package directory, not repo root. From 2b62f9c9c7fa4293935cd0150a396ba4e98b9142 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Fri, 17 Apr 2026 00:20:56 -0400 Subject: [PATCH 4/6] docs: plan for Chrome-style session tabs replacing left sidebar --- .../session-tabs-plan.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 analysis-and-understanding/session-tabs-plan.md diff --git a/analysis-and-understanding/session-tabs-plan.md b/analysis-and-understanding/session-tabs-plan.md new file mode 100644 index 000000000000..c015a96ee0d6 --- /dev/null +++ b/analysis-and-understanding/session-tabs-plan.md @@ -0,0 +1,182 @@ +# Plan: Chrome-style Session Tabs (replaces left sidebar) + +## Recommended desktop target + +**Both `desktop` (Tauri) and `desktop-electron` share the same UI** — it lives in `packages/app` and is mounted from both shells. All UI changes below land once in `packages/app` and work on both targets automatically. Only the shell code (e.g. `titleBarOverlay` color) might need a touch-up because the titlebar gets taller; those are small, symmetric tweaks in each shell. + +## User-selected scope + +- **Auto** — all sessions in an open project are tabs (no new open/close state model) +- **Global** — one tab bar across all open projects; colored/grouped by project +- **Remove the entire sidebar** — project switching migrates into a dropdown/menu in the tab bar + +## Architecture today (what we're replacing) + +Currently, sessions are rendered in the left sidebar, owned by `packages/app/src/pages/layout.tsx` (≈2 500 lines). The relevant pieces: + +- **Project rail** (64 px, always visible): `pages/layout/sidebar-shell.tsx` + `sidebar-project.tsx`. Clicking a project icon switches project; right-click = project menu. +- **Session panel** (resizable, 244–~30vw): rendered inside the rail host by `SidebarPanel` (inline in `layout.tsx` at ≈L2024) and delegated to `pages/layout/sidebar-workspace.tsx` (which renders `NewSessionItem`, `SessionItem`, `SessionSkeleton`). Shown only for the *currently selected* project. +- **Titlebar** (40 px, drag region, mounts center/right Portals used by `SessionHeader`): `components/titlebar.tsx`. +- **State**: `context/layout.tsx` — `sidebar.{opened,width,workspaces,workspacesDefault}`, `mobileSidebar`, plus project list in `LayoutContext.projects`. +- **Routing**: `/:dir/session/:id?` in `app.tsx`. URL is the source of truth for the active session; there's no existing "open tabs" concept for sessions (there is one for files, which we'll mirror). +- **Prefetch/warm logic** (≈500 LOC inside `layout.tsx`): prefetches N sessions around the active one in each visible workspace. This currently piggybacks on "visible session dirs" derived from the sidebar; we'll need to keep it alive using the tab-bar projection instead. +- **Notifications, unseen counts, permissions, archive, drag-to-reorder, inline rename, hover-preview, peek panel, sort bumping, AIM triangle, keybinds (`alt+↑/↓`, `mod+alt+↑/↓`), deep links** — all live in the sidebar paths and must be re-homed on the tab bar. + +## Target architecture + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ Titlebar row (shrunk, keeps drag region + window controls + ⌘search) │ +├───────────────────────────────────────────────────────────────────────┤ +│ TAB BAR [ProjectMenu▼] [tab][tab][tab*][+] [overflow▼][settings]│ +├───────────────────────────────────────────────────────────────────────┤ +│ │ +│ Active session view │ +│ │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +- One horizontal tab strip, always visible on desktop (hidden on `xl:hidden` → mobile keeps existing drawer). +- Tabs are grouped/colored by project (left color stripe + project icon, matching Chrome's tab-group look). +- A **Project menu** (`[ProjectMenu▼]`) on the far-left replaces the project rail: list open projects, open-project action, recent projects, settings, help. +- **Overflow menu** on the right holds tabs that don't fit (Chrome-style). Horizontally scrolls + ⌘-wheel, plus keyboard navigation. +- **Settings/help** (today at the bottom of the rail) move to the right side of the tab bar. + +## Files that need to change + +### Core (new) + +1. **`packages/app/src/components/session-tabs/session-tab-bar.tsx`** *(new, ≈300–400 LOC)* + Top-level tab strip. Renders: project menu, sortable tab list (grouped by project), new-session button, overflow menu, settings cluster. + +2. **`packages/app/src/components/session-tabs/session-tab.tsx`** *(new, ≈150 LOC)* + Single tab: project-colored stripe, project icon, working spinner / unseen / permission dot, truncated title, inline rename on double-click, close (archive) button, middle-click-close, drag handle (reuses `@thisbeyond/solid-dnd`), context menu (archive, rename, duplicate, close-others, close-right…). + +3. **`packages/app/src/components/session-tabs/session-tab-overflow.tsx`** *(new, ≈100 LOC)* + Overflow dropdown using `IntersectionObserver` to detect which tabs are clipped (same trick as Chrome). + +4. **`packages/app/src/components/session-tabs/project-switcher.tsx`** *(new, ≈150 LOC)* + Replaces the project rail. Shows a project dropdown with: open projects, "open project" (⌘O), close project, project settings. Uses existing `ProjectIcon`, `chooseProject`, `openProject`, `closeProject` from `layout.tsx`. + +5. **`packages/app/src/components/session-tabs/use-tab-model.ts`** *(new, ≈150 LOC)* + Aggregates every open project's sessions into a single flat tab list (sorted by project order, then session `sortedRootSessions`). Wraps the prefetch/warm/LRU logic currently scattered in `layout.tsx` so we can reuse it with an "all sessions visible" input instead of "sessions in visible sidebar workspaces". + +6. **`packages/app/src/components/session-tabs/session-tabs.css`** *(new)* + Chrome-style tab shapes (trapezoid with rounded top corners via masks) + color-stripe per project. Can also extend `packages/ui/src/components/tabs.css` variant if we want to reuse Kobalte. + +### Core (modify) + +7. **`packages/app/src/pages/layout.tsx`** *(≈2 500 LOC, big surgery)* + - Remove entirely: `SidebarPanel` (≈L2024–L2327), `sidebarContent` (≈L2331), the desktop `