feat(web): port full Goose Desktop UI to standalone web app#2
feat(web): port full Goose Desktop UI to standalone web app#2itsablabla merged 6 commits intobasefrom
Conversation
- Custom ACP client (JSON-RPC 2.0 over HTTP/SSE) - React 19 + Vite + Tailwind CSS 4 - Streaming chat with tool call visualization - Connection status indicator - Markdown rendering with GFM support - Nginx config for production deployment - Dockerfile for containerized builds
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| messages: [ | ||
| { | ||
| role: "user", | ||
| content: { type: "text", text }, |
There was a problem hiding this comment.
🔴 content sent as single object instead of array, violating own AcpMessage interface
The sendMessage method at ui/web/src/lib/acp-client.ts:133 sends content: { type: "text", text } (a single object), but the AcpMessage interface defined at ui/web/src/lib/acp-client.ts:6-9 declares content: ContentPart[] (an array). The backend server's convert_acp_prompt_to_message at crates/goose-acp/src/server.rs:835 also expects a Vec<ContentBlock> (array). Sending a plain object where an array is expected will cause the server to fail to deserialize the user message.
| content: { type: "text", text }, | |
| content: [{ type: "text", text }], |
Was this helpful? React with 👍 or 👎 to provide feedback.
| this.sessionId = acpSessionId; | ||
| } | ||
|
|
||
| // Read SSE response |
There was a problem hiding this comment.
🔴 Comment // Read SSE response restates what the code does (AGENTS.md violation)
AGENTS.md rule: "Comments: Never add comments that restate what code does." The comment // Read SSE response at line 92 simply labels the code that follows (res.text(), text.split, JSON.parse), which is self-evidently reading and parsing an SSE response.
| // Read SSE response |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const event = JSON.parse(data) as AcpStreamEvent; | ||
| onEvent(event); | ||
| } catch { | ||
| // skip malformed JSON |
There was a problem hiding this comment.
🔴 Comment // skip malformed JSON restates what the code does (AGENTS.md violation)
AGENTS.md rule: "Comments: Never add comments that restate what code does" and "Never: Comment self-evident operations." The comment // skip malformed JSON inside an empty catch block after JSON.parse is self-evident from the code structure.
| // skip malformed JSON | |
| } catch { |
Was this helpful? React with 👍 or 👎 to provide feedback.
| } | ||
| } | ||
|
|
||
| // Error |
There was a problem hiding this comment.
🔴 Comment // Error is a self-evident operation label (AGENTS.md violation)
AGENTS.md rule: "Never: Comment self-evident operations (// Initialize, // Return result)." The comment // Error at line 170 is directly analogous to the banned // Initialize example — it simply labels the error-handling branch that immediately follows (if (event.error)).
| // Error |
Was this helpful? React with 👍 or 👎 to provide feedback.
| textareaRef.current?.focus(); | ||
| }, []); | ||
|
|
||
| // Auto-resize textarea |
There was a problem hiding this comment.
🔴 Comment // Auto-resize textarea restates what the code does (AGENTS.md violation)
AGENTS.md rule: "Comments: Never add comments that restate what code does." The comment // Auto-resize textarea restates the effect body that sets ta.style.height — the code is self-documenting.
| // Auto-resize textarea |
Was this helpful? React with 👍 or 👎 to provide feedback.
| for (const item of result.content) { | ||
| const contentItem = item as Record<string, unknown>; | ||
| if (contentItem.type === "text" && contentItem.text) { | ||
| // Append or update text content |
There was a problem hiding this comment.
🔴 Comment // Append or update text content restates what the code does (AGENTS.md violation)
AGENTS.md rule: "Comments: Never add comments that restate what code does." The comment at line 74 restates the immediately following logic which finds an existing text part and either appends to it or pushes a new one.
| // Append or update text content |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (event.result && typeof event.result === "object") { | ||
| const result = event.result as Record<string, unknown>; | ||
|
|
||
| // Handle content array in result |
There was a problem hiding this comment.
🔴 Comment // Handle content array in result restates what the code does (AGENTS.md violation)
AGENTS.md rule: "Comments: Never add comments that restate what code does." The comment at line 69 simply restates the condition if (result.content && Array.isArray(result.content)) that follows.
| // Handle content array in result |
Was this helpful? React with 👍 or 👎 to provide feedback.
Port the full Goose Desktop UI (React/Vite) to work as a standalone web app communicating with the Jada Code ACP server over WebSocket. Key changes: - Web shims for @tauri-apps/api (core, event, dialog, opener) - WebSocket transport replacing Tauri IPC invoke() calls - Event bus bridging ACP notifications to React hooks - Vite web config with module aliasing for shims - Nginx config for static file serving - Fix acp_prepare_session handler (sessionId vs localSessionId bug) - Fix ACP protocol fields: cwd, mcpServers, prompt - Handle session/update notifications with snake_case variants Verified E2E: browser → WebSocket → ACP server → Kimi K2.5 → streaming response rendered in chat UI.
Address Devin Review findings: - Reset `initialized` flag when WebSocket closes so reconnect re-runs the initialize/notifications handshake - Fix undefined `notifType` → `updateType` in default switch case
- Add window.electron web shim (web-shim.ts) replacing ~50 Electron IPC methods - Settings → localStorage with jada_setting_ prefix - Event bus for on/off/emit channels - API connection via /api proxy to goosed REST server - File ops, dialogs, desktop features → web-compatible stubs - Add web entry point (web-entry.ts) that installs shims before renderer - Add electron-stub.ts for type-level imports (IpcRendererEvent) - Add web-only Vite config (vite.web.config.mts) without Electron Forge - Add index.web.html with theme initialization for web builds - Builds as static SPA served by nginx, proxying /api to goosed
- fetchMetadata: remove misleading no-cors fetch that always returns empty string due to opaque response; document that metadata fetching is not available in web context - sessionMap: clear stale session entries on WebSocket close to prevent prompt failures after reconnection
| proxy_pass http://jada-code-backend:3284/acp; | ||
| proxy_http_version 1.1; | ||
| proxy_set_header Upgrade $http_upgrade; | ||
| proxy_set_header Connection "upgrade"; |
There was a problem hiding this comment.
🟡 Hardcoded Connection "upgrade" header sent on all /acp requests, including non-WebSocket HTTP POSTs
In ui/web/nginx.conf, the /acp location block unconditionally sets Connection "upgrade" for every proxied request. The ui/web ACP client (ui/web/src/lib/acp-client.ts) communicates via HTTP POST with SSE streaming—not WebSocket—so regular POST requests receive Connection: upgrade with an empty Upgrade header. This is a protocol violation (RFC 7230 §6.7) that prevents HTTP keep-alive connection reuse and may confuse strict HTTP backends. The sibling config at ui/goose2/nginx.web.conf:1-4 solves this correctly by using a map block to conditionally set the header based on $http_upgrade.
Prompt for agents
The /acp location block in ui/web/nginx.conf hardcodes proxy_set_header Connection "upgrade" on line 17, which sends the upgrade signal even for regular HTTP POST requests used by the ACP client. Fix this by adding a map block (like the one in ui/goose2/nginx.web.conf lines 1-4) at the top of the server config that maps $http_upgrade to a $connection_upgrade variable: when $http_upgrade is empty use "close", otherwise use "upgrade". Then replace the hardcoded string on line 17 with proxy_set_header Connection $connection_upgrade.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Valid point. Note that ui/web/nginx.conf is from the Phase 1 chat-only UI and is not used in the current deployment — the desktop UI port uses ui/goose2/nginx.web.conf which already has the correct map block (lines 1-4). That said, fixing it for correctness if anyone ever uses this config.
Use map block to set Connection header based on $http_upgrade, matching the pattern already used in ui/goose2/nginx.web.conf. Prevents sending upgrade header on regular HTTP POST requests.
E2E Test Results — Jada Code Desktop Web PortTested the full Goose Desktop UI web port at https://coder.garzaos.online against goosed REST API (Kimi K2.5 via OpenRouter). All 5 tests passed. Test Results (5/5 passed)
ScreenshotsHome — Full sidebar with dashboard statsChat E2E — Kimi K2.5 streaming responseSettings — Provider configuration loaded from goosed APIHome after navigation — Stats updated (3 sessions, 48.06K tokens)Minor observations (non-blocking)
|
Summary
Ports the full Goose Desktop UI (
ui/desktop/) to work as a standalone web application served by nginx + goosed REST API. This is the complete desktop app with Home dashboard, Chat, Recipes, Skills, Apps, Scheduler, Extensions, and Settings — not just the chat component.Two phases of work in this PR:
Phase 1 — Goose2 chat UI (earlier commits)
ui/goose2/componentPhase 2 — Full Desktop UI port (latest commit)
web-shim.ts— Drop-in replacement for all ~50window.electron.*IPC methods:jada_setting_prefix/apiproxy to goosed REST serverweb-entry.ts— Installs shims before renderer loads (critical ordering)electron-stub.ts— Stub forIpcRendererEventtype importvite.web.config.mts— Standalone Vite config (no Electron Forge):electronmodule aliased to stub/api→ goosedindex.web.html— Web-specific entry HTML with theme initializationDeployed and verified E2E:
/api/*to goosed (injects X-Secret-Key)E2E verified: Full desktop UI loads with all sidebar items, chat works with Kimi K2.5 via OpenRouter.
Review & Testing Checklist for Human
Notes
goosed.service) on port 3270 with TLS disabled (nginx handles HTTPS)dist/directory is not committed — build withpnpm exec vite build --config vite.web.config.mtsinui/desktop//api/proxied requests so the frontend doesn't need to know the server secretLink to Devin session: https://app.devin.ai/sessions/f4e16f12bfa34fc6bdc3d73a833a5d91
Requested by: @itsablabla