From a7731e96813bdc90bcd641f55140d1556cc584a5 Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 09:22:29 -0800 Subject: [PATCH 01/19] docs(exploration): assess self-editing xNet viability - add an exploration for in-app app development across Electron, web, and mobile - map current repo support for devtools history, Local API, MCP, plugins, and platform capabilities - recommend an Electron-first workspace-shell plus preview-worktree architecture with PR automation --- ...UX_VIABILITY_ACROSS_WEB_ELECTRON_MOBILE.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 docs/explorations/0107_[_]_SELF_EDITING_APP_IN_APP_DEVELOPMENT_GIT_PR_WORKTREE_UX_VIABILITY_ACROSS_WEB_ELECTRON_MOBILE.md diff --git a/docs/explorations/0107_[_]_SELF_EDITING_APP_IN_APP_DEVELOPMENT_GIT_PR_WORKTREE_UX_VIABILITY_ACROSS_WEB_ELECTRON_MOBILE.md b/docs/explorations/0107_[_]_SELF_EDITING_APP_IN_APP_DEVELOPMENT_GIT_PR_WORKTREE_UX_VIABILITY_ACROSS_WEB_ELECTRON_MOBILE.md new file mode 100644 index 000000000..72d94cbbd --- /dev/null +++ b/docs/explorations/0107_[_]_SELF_EDITING_APP_IN_APP_DEVELOPMENT_GIT_PR_WORKTREE_UX_VIABILITY_ACROSS_WEB_ELECTRON_MOBILE.md @@ -0,0 +1,630 @@ +# 🧠 Self-Editing xNet: In-App Development, Git Worktrees, and PR Automation + +> Problem statement: can xNet become a tool for developing xNet itself, where a user long-presses or right-clicks UI, prompts for changes, watches the UI shift in place, scrubs through UI/data/code history, and optionally turns accepted changes into a branch, commit series, screenshot, and PR? + +## Executive Summary + +This is viable, but not as one single feature. It is really three concentric products: + +1. **Prompted UI/data editing inside the app**: viable now, medium complexity. +2. **Repository-aware code editing with live preview**: viable on **Electron first**, high complexity. +3. **Branch/worktree/chat/version management for any user across web, desktop, and mobile**: viable only with a **remote workspace** model, very high complexity. + +### Bottom line + +| Surface | Local self-editing viability | Why | +| --- | --- | --- | +| **Electron** | 🟢 Strong | Real filesystem, child processes, git, preview windows, Playwright screenshots, GitHub CLI/API are all realistic fits. | +| **Web** | 🟡 Conditional | Good for chat, history, and remote preview. Weak for direct local repo editing because browser file access is permissioned and sandboxed. | +| **Mobile / Expo** | 🔴 Local, 🟡 Remote | Best as a remote control/review client. Poor fit for local repo worktrees, dev servers, and PR automation. | + +### Recommendation + +Build this as an **Electron-first workspace shell** with a **separate editable preview worktree**. Use xNet Data for chat history, selection context, screenshots, and session metadata. Use git for code history. Use the existing xNet history tooling for data/state scrubbing. Treat web and mobile as clients for a **remote dev session**, not as primary local authoring runtimes. + +```mermaid +flowchart LR + U["User long-presses / right-clicks UI"] --> I["Inspector overlay"] + I --> P["Prompt composer"] + P --> A["Agent service"] + A --> W["Git worktree"] + A --> T["Tests + lint"] + A --> S["Screenshot capture"] + W --> V["Preview app/window"] + V --> U + A --> G["Commit / branch / PR"] + A --> D["xNet session node"] + S --> D + G --> D +``` + +## 🚩 Title and Problem Statement + +The desired UX is not just “AI edits some files.” It is closer to a **local-first IDE/product-builder embedded inside the app itself**: + +- long-press or right-click a visible element +- prompt against the selected surface +- see the preview update in place +- scrub through **data history** and **code history** +- accept, revert, branch, or create a PR +- keep a chat/worktree/session browser, similar to Codex desktop + +The core question is not whether an LLM can write code. It can. The real question is whether xNet can safely host: + +- a repo-aware editing agent +- a live preview loop +- git/worktree orchestration +- versioned UI/data timelines +- PR automation + +without collapsing the running app or bypassing normal code review. + +## 🧱 Current State in the Repository + +### Observed facts + +1. **Electron already has the right privilege boundary shape.** + - The renderer is sandboxed, `contextIsolation` is enabled, and `nodeIntegration` is disabled in [`apps/electron/src/main/index.ts`](../../../apps/electron/src/main/index.ts#L128-L145). + - Electron already exposes narrow privileged bridges through preload in [`apps/electron/src/preload/index.ts`](../../../apps/electron/src/preload/index.ts#L240-L340). + +2. **Electron already has process-management plumbing.** + - A `ProcessManager` exists and Electron main wires IPC for service start/stop/restart/list/call in [`apps/electron/src/main/service-ipc.ts`](../../../apps/electron/src/main/service-ipc.ts#L49-L120). + +3. **The current Local API is data-focused, not repo/code-focused.** + - The Local API exposes node CRUD, query, events, and schemas, but no repo/git/code-edit endpoints in [`packages/plugins/src/services/local-api.ts`](../../../packages/plugins/src/services/local-api.ts#L282-L324). + +4. **A basic MCP server already exists.** + - It currently covers node CRUD/search/schema access, not code editing or rich repo operations, in [`packages/plugins/src/services/mcp-server.ts`](../../../packages/plugins/src/services/mcp-server.ts#L80-L115). + +5. **The platform capability model already says Electron is special.** + - Only Electron gets `services`, `processes`, `localAPI`, and `filesystem` in [`packages/plugins/src/types.ts`](../../../packages/plugins/src/types.ts#L42-L70). + +6. **Web and Electron already share plugin + devtools infrastructure.** + - Web boots `XNetProvider` with `platform: 'web'` and `XNetDevToolsProvider` in [`apps/web/src/App.tsx`](../../../apps/web/src/App.tsx#L421-L448). + - Electron boots `XNetProvider` with `platform: 'electron'` and devtools in [`apps/electron/src/renderer/main.tsx`](../../../apps/electron/src/renderer/main.tsx#L246-L269). + - `@xnetjs/react` already creates a `PluginRegistry` when the store is ready in [`packages/react/src/context.ts`](../../../packages/react/src/context.ts#L893-L923). + +7. **A data/history scrubber already exists.** + - DevTools has a timeline slider that materializes past state in [`packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx`](../../../packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx#L128-L190). + +8. **Yjs document history infrastructure already exists.** + - `DocumentHistoryEngine` captures snapshots and merges document timeline entries with property history in [`packages/history/src/document-history.ts`](../../../packages/history/src/document-history.ts#L1-L170). + - DevTools creates that engine when the store supports snapshot persistence in [`packages/devtools/src/provider/DevToolsProvider.tsx`](../../../packages/devtools/src/provider/DevToolsProvider.tsx#L296-L321). + +9. **There is already at least one context-menu interaction pattern.** + - Table cells use `onContextMenu` in [`packages/views/src/table/TableCell.tsx`](../../../packages/views/src/table/TableCell.tsx#L439-L480), which is a reasonable starting point for direct-manipulation edit affordances. + +10. **Mobile is currently much thinner.** + - Expo uses a separate provider with `NativeBridge`/SQLite and no plugin/devtools wiring in [`apps/expo/src/context/XNetProvider.tsx`](../../../apps/expo/src/context/XNetProvider.tsx#L1-L120). + - The current document screen is a simple title input + `WebViewEditor` in [`apps/expo/src/screens/DocumentScreen.tsx`](../../../apps/expo/src/screens/DocumentScreen.tsx#L27-L105). + +11. **Screenshot artifacts are already normal in the repo.** + - Existing Playwright tests write screenshots under `tmp/playwright/`, e.g. [`tests/e2e/src/pages-crud.spec.ts`](../../../tests/e2e/src/pages-crud.spec.ts#L40-L47). + +### Important gaps + +- No repo abstraction (`RepoSession`, `WorktreeService`, `BranchService`, `PullRequestService`) exists yet. +- No source-aware element inspector exists yet. +- No normal editing flow appears to call `DocumentHistoryEngine.captureSnapshot()`; repo search on 2026-03-07 found non-test usage only in seed flows. +- No unified “chat history + worktree + preview” shell exists. +- The current Electron service bridge is close but not complete: + - preload allows only `start`, `stop`, `status`, and `list` in [`apps/electron/src/preload/index.ts`](../../../apps/electron/src/preload/index.ts#L240-L267) + - but the service client/main process also expect `restart`, `list-all`, `call`, `status-update`, and `output` in [`packages/plugins/src/services/client.ts`](../../../packages/plugins/src/services/client.ts#L12-L20) and [`apps/electron/src/main/service-ipc.ts`](../../../apps/electron/src/main/service-ipc.ts#L67-L120) + - this should be fixed early if the agent runs as a managed service. + +## 🌐 External Research + +| Source | Observed fact | Relevance | +| --- | --- | --- | +| [git-worktree](https://git-scm.com/docs/git-worktree) | Git officially supports multiple working trees attached to one repository. | This is the right primitive for “one chat/session = one preview workspace”. | +| [GitHub CLI `gh pr create`](https://cli.github.com/manual/gh_pr_create) | GitHub CLI can open PRs non-interactively with title/body/head/base flags. | Good fit for a local Electron agent flow. | +| [GitHub REST: create a pull request](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request) | PR creation is available via API with `title`, `body`, `head`, `base`, and `draft`. | Lets xNet support PR creation without shelling out to `gh` if needed. | +| [MDN File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API) | Browser file access is permissioned and user-mediated. | Limits local “edit my actual repo” workflows on web. | +| [MDN OPFS](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) | OPFS is origin-private and not user-visible. | Great for app storage, weak for interoperating with an existing developer checkout. | +| [Electron security tutorial](https://www.electronjs.org/docs/latest/tutorial/security) | Electron recommends sandboxing, disabling Node integration, and enabling context isolation. | Confirms the repo’s current architecture and implies git/fs/process access must stay out of the renderer. | +| [Expo FileSystem](https://docs.expo.dev/versions/latest/sdk/filesystem/) | Expo supports app-managed file reads/writes, not full desktop-style process orchestration. | Mobile can store artifacts, but local repo workflows remain poor. | +| [isomorphic-git docs](https://isomorphic-git.org/docs/en/quickstart) | Browser git is possible with emulated filesystems such as LightningFS. | Useful for imported repos or remote mirrors, but not a strong replacement for native worktrees. | + +### Interpretation + +- **Observed fact:** Electron is the only current runtime whose repo capabilities naturally line up with real git/worktree/dev-server/PR flows. +- **Inference:** Web/mobile can still participate, but mainly by talking to a remote workspace or Electron host rather than owning the local dev loop. + +## 🔍 Key Findings + +### 1. This is viable if you split the product into a stable shell and a mutable preview + +Trying to let the running shell edit the exact code that is currently rendering the shell is asking for self-inflicted corruption. The better model is: + +- **Workspace shell**: stable app surface for chat, history, worktrees, screenshots, revert/accept +- **Preview surface**: renderer or window driven by a selected worktree/branch + +That gives you “the page shifts around” without betting the whole editing environment on hot-swapping itself. + +```mermaid +sequenceDiagram + participant U as User + participant S as Workspace Shell + participant A as Agent Service + participant G as Git Worktree + participant P as Preview App + participant H as GitHub + + U->>S: Long-press element + prompt + S->>A: Prompt + element metadata + current session + A->>G: Patch files in session worktree + A->>G: Run tests / build / screenshot + G->>P: Reload preview + P-->>S: New UI + screenshot + diagnostics + U->>S: Accept + S->>A: Commit + push + create PR + A->>H: Open draft PR + H-->>S: PR URL +``` + +### 2. Electron is the correct first target + +Electron already has: + +- a secure renderer/main split +- service/process infrastructure +- a local API +- filesystem and process capability in the platform model +- devtools/history plumbing + +So an Electron-first version can use **real git**, **real worktrees**, **real `pnpm`/test/build commands**, and **real screenshots**. + +### 3. Web is good for review/control, weak for authoritative local repo editing + +Web xNet is already strong for: + +- chat UI +- history visualization +- screenshot galleries +- remote preview surfaces +- plugin-based editing affordances + +But a browser is a bad place to promise “edit my actual repo with worktrees like Codex desktop” because: + +- file access is permissioned and not ambient +- OPFS is origin-private, not your normal checkout +- browser git exists, but it is an emulation story, not a native developer workflow +- running app builds, Playwright, Electron preview, and native git auth are all awkward + +### 4. Mobile should be a companion, not the primary local build host + +The current Expo app is structurally much lighter than web/electron. Direct local git/worktree/build/test/PR loops on-device would force you to recreate desktop development inside an app sandbox. That is possible in theory, but it is the wrong optimization. + +Better mobile jobs: + +- browse sessions, branches, screenshots, and chats +- inspect diffs +- request a change against a remote workspace +- accept/reject/revert +- scrub through UI/data history + +### 5. “Commit code back to the repo as a PR with a screenshot” is realistic on Electron + +On Electron, the flow is straightforward: + +1. create or reuse a worktree +2. apply edits +3. run targeted validation +4. capture a screenshot +5. commit with conventional messages +6. push branch +7. create a draft PR + +This can use `gh pr create` or GitHub’s PR API. + +The screenshot is the one awkward part: + +- easiest initial version: store screenshot under `tmp/playwright/` and optionally commit it or upload it via a companion service +- better product version: upload screenshot to a PR comment/artifact store and link it from the PR body or session metadata + +### 6. The timeline scrubber should not rebuild the app on every drag tick + +For code history, the scrubber should primarily show: + +- commit/checkpoint metadata +- screenshot thumbnails +- diffs +- chat turns + +Then only hydrate a real preview when the user clicks a checkpoint. Rebuilding the preview for every slider movement will feel slow and expensive. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Selected : user selects element + Selected --> Planning : prompt submitted + Planning --> Applying : patch accepted + Applying --> PreviewReady : preview builds + Applying --> Failed : build/test error + PreviewReady --> Accepted : user accepts + PreviewReady --> Rejected : user reverts + Accepted --> Committed : create checkpoint commit + Committed --> PRReady : push + open draft PR + Rejected --> Idle + Failed --> Selected + PRReady --> Idle +``` + +## ⚖️ Options and Tradeoffs + +### Option A: Prompted layout/data editing only + +The model edits: + +- page props +- schema/view settings +- design tokens +- component config nodes + +without touching repository code. + +**Pros** + +- Lowest risk +- Works across all platforms +- Fits xNet’s current data model well +- Immediate “the UI shifts around” feedback + +**Cons** + +- Not true self-hosted app development +- Cannot change arbitrary React structure or platform plumbing +- PR/worktree/code scrubber story remains missing + +**Verdict:** Good precursor, not the end state you described. + +### Option B: Electron local repo agent with preview worktree + +The app owns: + +- a repo root +- one or more worktrees +- an agent session per worktree +- preview process/window +- commit/PR automation + +**Pros** + +- Most faithful to the desired UX +- Reuses real git and existing repo tooling +- Keeps normal code review intact +- Can generate real screenshots from the actual changed app + +**Cons** + +- High complexity +- Strong security considerations +- Must isolate host shell from edited preview +- Electron main/preload changes require preview restarts, not just hot module reload + +**Verdict:** **Recommended first implementation.** + +### Option C: Remote workspace service shared by Electron, web, and mobile + +The actual repo, git, tests, and previews run in a remote dev workspace. xNet surfaces chat, history, and preview control. + +**Pros** + +- Works for “anyone” across platforms +- Easier auth centralization and PR automation +- Good collaboration story + +**Cons** + +- Much more infrastructure +- Higher ops/security cost +- Loses the charm of “edit the app that is on this machine” unless paired with Electron local mode + +**Verdict:** Best phase 2 / productization direction after Electron proves the UX. + +### Option D: Pure local web/mobile git editing + +Use browser/mobile storage plus JS git libraries to emulate a repo locally. + +**Pros** + +- No desktop dependency + +**Cons** + +- Weak fit for existing developer expectations +- Worktrees are not a first-class story +- Build/test/screenshot flows are poor +- Repo interop is awkward + +**Verdict:** Not recommended as a primary strategy. + +## 🧭 Recommendation + +### Recommended product shape + +Build **two tightly related modes**: + +1. **Electron local authoring mode** + - local repo root + - local git worktrees + - local preview app/window + - local tests + screenshots + PR creation + +2. **Remote companion mode** + - used by web and mobile + - talks to a remote workspace running the same agent/session model + - shows chat, checkpoints, diffs, screenshots, preview URLs, PRs + +### Recommended architectural boundaries + +```mermaid +mindmap + root((Self-Editing xNet)) + Shell + Chat threads + Session browser + Checkpoints + Screenshots + Revert/accept + Selection + Right-click desktop + Long-press touch + Inspector overlay + Source hints + Agent + Prompt planning + File patching + Validation + Commit creation + PR automation + Preview + Worktree-bound + Hot reload for renderer edits + Restart on preload/main changes + Timeline + xNet data history + Git commits + Screenshots + Chat turns +``` + +### Recommended implementation phases + +#### Phase 1: Electron-only developer spike + +- stable shell + separate preview worktree +- prompt composer +- repo patch/apply loop +- preview reload +- manual checkpoint commits + +#### Phase 2: Session model + history fusion + +- `DevSession` nodes stored in xNet Data +- chat history, prompt turns, screenshots, and git refs stored as first-class records +- checkpoint gallery + code scrubber + +#### Phase 3: PR automation + +- push branch +- create draft PR +- attach screenshot metadata +- record PR URL in session + +#### Phase 4: Direct-manipulation UX + +- element inspector overlay +- right-click / long-press menus +- source-map-backed file hints + +#### Phase 5: Remote workspace support + +- remote session executor +- web/mobile session browser and reviewer + +## 🧪 Complexity Assessment + +### Narrow Electron prototype + +**Viability:** high +**Complexity:** medium-high +**Inference:** likely a few focused weeks for a proof of concept if scope is limited to renderer edits, one preview worktree, basic screenshots, and draft PR creation. + +### Electron productized for daily use + +**Viability:** high +**Complexity:** high +Main cost centers: + +- inspector/source mapping +- preview isolation +- git/worktree lifecycle cleanup +- secure repo permissions +- failure recovery + +### Cross-platform “anyone can use this” product + +**Viability:** medium +**Complexity:** very high +The hard part is not LLM editing. It is: + +- workspace hosting +- auth +- repo bootstrapping +- PR credential delegation +- artifact storage +- cost controls + +## ✅ Implementation Checklist + +- [ ] Add a `DevSession` schema in xNet Data for repo/chat/checkpoint metadata. +- [ ] Implement an Electron-only `RepoSessionService` in the main process or managed background service. +- [ ] Add git worktree lifecycle commands: create, list, cleanup, switch, archive. +- [ ] Add preview lifecycle commands: boot, reload, restart, stop. +- [ ] Fix the current Electron service IPC/preload channel mismatch before relying on managed services. +- [ ] Extend the Local API or add a dedicated repo API for session status, checkpoints, and screenshots. +- [ ] Build a prompt composer panel bound to the active preview/session. +- [ ] Add a checkpoint model: prompt, file diff summary, screenshot path, commit SHA, validation result. +- [ ] Add screenshot capture for accepted checkpoints. +- [ ] Add conventional commit generation with editable review before commit. +- [ ] Add push + draft PR creation using `gh` or GitHub REST. +- [ ] Build a code-history scrubber backed by git checkpoints and screenshot thumbnails. +- [ ] Merge code checkpoints with xNet data history in a unified session timeline. +- [ ] Add a selection overlay for desktop right-click and touch long-press. +- [ ] Add source hints using route/component metadata first; layer source maps later. +- [ ] Add remote workspace support for web/mobile only after the Electron loop is solid. + +## 🧷 Validation Checklist + +- [ ] Prompting against a selected element produces a patch against the correct worktree. +- [ ] Renderer-only changes update the preview without breaking the shell. +- [ ] Electron main/preload changes trigger a controlled preview restart. +- [ ] Revert returns the preview to the previous accepted checkpoint. +- [ ] Checkpoint slider restores screenshot/diff/chat context without expensive rebuilds on drag. +- [ ] Clicking a checkpoint can hydrate a real preview from that commit on demand. +- [ ] Draft PR creation includes the intended head/base/title/body. +- [ ] Screenshot capture works reliably on the changed preview. +- [ ] Failing tests/builds block commit/PR by default. +- [ ] Concurrent sessions map cleanly to distinct worktrees. +- [ ] Deleting/archiving a session cleans up orphaned branches/worktrees safely. +- [ ] Web/mobile can browse and control remote sessions without needing local repo access. +- [ ] Security review confirms repo access is explicit, narrow, and auditable. + +## 💡 Example Code + +The key is to model the system declaratively: session state in xNet, side effects in a repo service, and a preview loop that is replaceable. + +```typescript +/** + * Minimal session model for Electron-first self-editing. + */ +export type PreviewStatus = 'idle' | 'starting' | 'ready' | 'failed' + +export type Checkpoint = { + readonly id: string + readonly prompt: string + readonly commitSha: string | null + readonly screenshotPath: string | null + readonly createdAt: number + readonly validation: + | { readonly kind: 'pending' } + | { readonly kind: 'passed' } + | { readonly kind: 'failed'; readonly summary: string } +} + +export type DevSession = { + readonly id: string + readonly repoRoot: string + readonly baseRef: string + readonly branch: string + readonly worktreePath: string + readonly previewUrl: string | null + readonly previewStatus: PreviewStatus + readonly checkpoints: ReadonlyArray +} + +export type SelectionTarget = { + readonly routeId: string + readonly elementLabel: string + readonly fileHints: ReadonlyArray + readonly nodeId: string | null +} + +export type PromptRequest = { + readonly sessionId: string + readonly selection: SelectionTarget | null + readonly prompt: string + readonly createScreenshot: boolean +} + +export type PlanStep = + | { readonly kind: 'patch-files' } + | { readonly kind: 'run-validation' } + | { readonly kind: 'restart-preview' } + | { readonly kind: 'capture-screenshot'; readonly path: string } + | { readonly kind: 'create-commit'; readonly message: string } + +const isPresent = (value: T | null): value is T => value !== null + +export const canRunLocalRepoFlow = (features: { + readonly processes: boolean + readonly filesystem: boolean + readonly localAPI: boolean +}): boolean => features.processes && features.filesystem && features.localAPI + +export const planPromptApplication = (request: PromptRequest): ReadonlyArray => + [ + { kind: 'patch-files' }, + { kind: 'run-validation' }, + { kind: 'restart-preview' }, + request.createScreenshot + ? { kind: 'capture-screenshot', path: 'tmp/playwright/self-edit-preview.png' } + : null, + { kind: 'create-commit', message: 'feat(self-edit): apply accepted in-app UI change' } + ].filter(isPresent) +``` + +## 📌 Recommended UX Shape + +- **Right-click / long-press menu** + - `Edit this` + - `Explain this` + - `Tidy layout` + - `Compare to previous` + - `Open related chat` + +- **Chat panel** + - bound to current session/worktree + - shows prompts, screenshots, test results, and checkpoints + +- **Version tab** + - git checkpoints + - screenshot strip + - diff summary + - restore from checkpoint + +- **Data tab** + - existing xNet data/history scrubber + +- **Session switcher** + - branch/worktree/chat triad + - one tab per dev session, like Codex desktop + +## 📚 References + +### Repo references + +- [`apps/electron/src/main/index.ts`](../../../apps/electron/src/main/index.ts#L128-L145) +- [`apps/electron/src/preload/index.ts`](../../../apps/electron/src/preload/index.ts#L240-L340) +- [`apps/electron/src/main/service-ipc.ts`](../../../apps/electron/src/main/service-ipc.ts#L49-L120) +- [`apps/electron/src/renderer/main.tsx`](../../../apps/electron/src/renderer/main.tsx#L246-L269) +- [`apps/web/src/App.tsx`](../../../apps/web/src/App.tsx#L421-L448) +- [`apps/expo/src/context/XNetProvider.tsx`](../../../apps/expo/src/context/XNetProvider.tsx#L1-L120) +- [`apps/expo/src/screens/DocumentScreen.tsx`](../../../apps/expo/src/screens/DocumentScreen.tsx#L27-L105) +- [`packages/plugins/src/types.ts`](../../../packages/plugins/src/types.ts#L42-L70) +- [`packages/react/src/context.ts`](../../../packages/react/src/context.ts#L893-L923) +- [`packages/plugins/src/services/local-api.ts`](../../../packages/plugins/src/services/local-api.ts#L282-L324) +- [`packages/plugins/src/services/mcp-server.ts`](../../../packages/plugins/src/services/mcp-server.ts#L80-L115) +- [`packages/devtools/src/provider/DevToolsProvider.tsx`](../../../packages/devtools/src/provider/DevToolsProvider.tsx#L296-L321) +- [`packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx`](../../../packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx#L128-L190) +- [`packages/history/src/document-history.ts`](../../../packages/history/src/document-history.ts#L1-L170) +- [`packages/views/src/table/TableCell.tsx`](../../../packages/views/src/table/TableCell.tsx#L439-L480) +- [`tests/e2e/src/pages-crud.spec.ts`](../../../tests/e2e/src/pages-crud.spec.ts#L40-L47) + +### Web research + +- [Git worktree documentation](https://git-scm.com/docs/git-worktree) +- [GitHub CLI `gh pr create`](https://cli.github.com/manual/gh_pr_create) +- [GitHub REST: create a pull request](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request) +- [Electron security tutorial](https://www.electronjs.org/docs/latest/tutorial/security) +- [MDN File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API) +- [MDN Origin Private File System](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) +- [Expo FileSystem](https://docs.expo.dev/versions/latest/sdk/filesystem/) +- [isomorphic-git quickstart](https://isomorphic-git.org/docs/en/quickstart) + +## Next Actions + +1. Build a **single-user Electron spike** with one repo, one worktree, one preview, one chat, and one checkpoint strip. +2. Prove the full loop: prompt → patch → preview → screenshot → commit → draft PR. +3. Only after that, add source-aware selection overlays and remote workspace support. From 96558a8eed9a241d1eb3371e8f2880a8282d4c78 Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 09:46:08 -0800 Subject: [PATCH 02/19] docs(exploration): design fast electron workspace shell - add an Electron-first exploration for a Codex-like xNet workspace shell - compare OpenCode integration paths, preview surface options, and git/PR orchestration - add performance-first guidance for instant session switching, warm previews, and local xNet-backed state --- ...S_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md | 821 ++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 docs/explorations/0108_[_]_ELECTRON_FIRST_SELF_EDITING_WORKSPACE_SHELL_CODEX_LIKE_CHATS_WORKTREES_BRANCHES_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md diff --git a/docs/explorations/0108_[_]_ELECTRON_FIRST_SELF_EDITING_WORKSPACE_SHELL_CODEX_LIKE_CHATS_WORKTREES_BRANCHES_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md b/docs/explorations/0108_[_]_ELECTRON_FIRST_SELF_EDITING_WORKSPACE_SHELL_CODEX_LIKE_CHATS_WORKTREES_BRANCHES_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md new file mode 100644 index 000000000..52529182f --- /dev/null +++ b/docs/explorations/0108_[_]_ELECTRON_FIRST_SELF_EDITING_WORKSPACE_SHELL_CODEX_LIKE_CHATS_WORKTREES_BRANCHES_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md @@ -0,0 +1,821 @@ +# Electron-First Self-Editing Workspace Shell for xNet + +## 1. 🧭 Title and Problem Statement + +This exploration narrows the earlier self-editing app idea to a single target: an Electron-first xNet workspace shell that feels close to Codex/OpenCode. + +The desired product shape is: + +- left panel: chats, worktrees, branches, PRs, session activity +- center panel: streaming chat UI with strong model/provider ergonomics +- right panel: live app preview plus diffs, file previews, markdown previews, screenshots, and PR state +- optional focus mode: hide shell chrome and let the preview take over +- timeline rail: scrub code checkpoints, screenshots, and xNet data history in one place + +The key design constraint is that "the app edits itself" should not literally mean "the currently running shell mutates its own files in place." The higher-quality Electron design is a **stable workspace shell** that edits **isolated worktrees** and renders each worktree in a **separate preview runtime**. + +## 2. ⚡ Executive Summary + +This is viable in Electron, and Electron is the right place to start. + +Observed facts: + +- The repo already has Electron process management, secure storage helpers, reusable resizable panels, markdown rendering, tree views, and data-history scrubbing. +- The repo does **not** currently have git/worktree orchestration, PR creation flows, OpenCode integration, code diff UI, or a dedicated preview-runtime manager. +- The current Electron shell is canvas-first, not a Codex-like three-panel workspace shell. + +Recommendation: + +1. Build a **native xNet workspace shell** in Electron. +2. Model each editable conversation as `DevSession = worktree + branch + OpenCode session + preview runtime + checkpoints`. +3. Use **OpenCode as the engine**, not as the final product shell. +4. Bootstrap fast with embedded OpenCode web if needed, but target a **native chat panel backed by the OpenCode server or SDK**. +5. Keep the shell running from a stable workspace and edit the app in **sibling worktrees**, never in the same files that power the host window. +6. Treat **speed as a first-class requirement**: session switching must come from local state and warm previews, not from fresh network or git work on every click. + +Estimated complexity: + +| Slice | Complexity | Notes | +| --- | --- | --- | +| 3-panel Electron shell | Low-Medium | Existing UI primitives cover much of this. | +| Git worktree/branch/session orchestration | Medium | Mostly main-process plumbing and guardrails. | +| OpenCode embedded web bootstrap | Medium | Fastest path, weaker integration. | +| Native chat UI backed by OpenCode server/SDK | Medium-High | More UI work, much better product quality. | +| Unified code/data/chat timeline | High | The strongest differentiator, but also new architecture. | +| Long-press element targeting | Medium-High | Requires preview instrumentation and stable targeting metadata. | +| True embedded Electron preview inside the shell | High | Harder than web preview because it needs isolated WebContents lifecycles. | +| One-click PR with screenshot | Medium | Straightforward once git/session/screenshot primitives exist. | + +Inference: + +- A rough prototype is a few weeks. +- A genuinely high-quality internal version is more like 6-10 weeks. +- A "works for other people safely" version is closer to a quarter than a weekend. + +## 3. 🧱 Current State in the Repository + +### Shell and Layout + +The current Electron renderer shell is centered on the canvas experience, not on development workflows. `apps/electron/src/renderer/App.tsx:432-498` renders: + +- a top titlebar area with `SystemMenu` +- the main `CanvasView` +- overlay content +- a bottom `ActionDock` +- a `CommandPalette` + +That means the repo is not starting from a Codex-like shell, but it is also not far from one. + +### Reusable UI Primitives Already Present + +The repo already has good building blocks for the workspace shell: + +- `packages/ui/src/primitives/ResizablePanel.tsx:12-46` exposes `ResizablePanelGroup`, `ResizablePanel`, and `ResizableHandle` +- `packages/ui/src/composed/TreeView.tsx:22-99` provides a hierarchical tree control that fits the left rail +- `packages/ui/src/components/MarkdownContent.tsx:109-121` renders GFM markdown and is a good base for chat markdown and PR body previews +- `packages/ui/src/composed/CodeBlock.tsx:11-18` is enough for raw file preview while a better code diff viewer is added + +### History and Scrubbing + +The repo already has real data-history affordances: + +- `packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx:128-212` includes a timeline slider scrubber and detail pane +- `packages/history/src/diff.ts:11-87` contains a `DiffEngine` for property-level diffs across time + +This is important because the "data scrubber" half of the vision already exists. The missing half is a **code checkpoint rail** with git-aware diffs and preview snapshots. + +### Electron Main-Process Capabilities + +Electron already owns several capabilities that this feature needs: + +- `apps/electron/src/main/index.ts:31-40` supports per-profile user data isolation via `XNET_PROFILE` +- `apps/electron/src/main/index.ts:20-25` enables remote debugging in dev, which fits Playwright/Electron verification +- `apps/electron/electron.vite.config.ts:22-24` already supports multiple renderer ports for multi-instance development +- `apps/electron/src/main/service-ipc.ts:49-129` exposes managed background services via IPC +- `apps/electron/src/main/secure-seed.ts:34-76` already uses Electron secure storage patterns that can be reused for provider credentials + +### Important Gaps + +Observed gaps in the current repo: + +- no git worktree or branch orchestration surfaced in the app +- no PR creation flow +- no OpenCode dependency or integration +- no dedicated code diff viewer +- no terminal or session log UI package such as `xterm` +- no preview runtime manager for per-worktree dev servers or detached preview windows + +### Preload / Service Boundary Mismatch + +There is also a concrete prerequisite bug to fix before leaning on managed services heavily. + +Observed mismatch: + +- `apps/electron/src/main/service-ipc.ts:53-128` registers `start`, `stop`, `restart`, `status`, `list-all`, `call`, and emits `status-update` and `output` +- `packages/plugins/src/services/client.ts:12-20` expects exactly those channels +- but `apps/electron/src/preload/index.ts:240-267` allowlists only `start`, `stop`, `status`, and `list` + +Implication: + +- a renderer-driven OpenCode service client will be artificially blocked until the preload allowlist is corrected + +### Repo Readiness Summary + +```mermaid +flowchart LR + A["Current Electron Shell"] --> B["Needs New Workspace Layout"] + C["Resizable Panels / Tree / Markdown"] --> D["Can Power Left, Center, Right Panels"] + E["HistoryPanel + DiffEngine"] --> F["Can Power Data Timeline Track"] + G["Service IPC + ProcessManager"] --> H["Can Run OpenCode and Preview Services"] + I["safeStorage Pattern"] --> J["Can Store Provider Credentials"] + K["Missing Git / PR / Code Diff / Worktree UX"] --> L["Main New Implementation Surface"] +``` + +## 4. 🌐 External Research + +### OpenCode + +Official OpenCode surfaces line up well with the requested UX: + +- The [OpenCode web docs](https://opencode.ai/docs/web) document browser-based usage via `opencode web`, which makes a fast bootstrap path plausible. +- The [OpenCode server docs](https://opencode.ai/docs/server) describe an OpenAPI-compatible local server with an events stream plus session, message, diff, and revert endpoints. +- The [OpenCode JavaScript SDK docs](https://opencode.ai/docs/sdk/javascript) describe session-oriented APIs, streamed events, permission handling, and access to providers/models config. +- The [OpenCode providers docs](https://opencode.ai/docs/providers) document provider setup for OpenAI, Anthropic, and others; the docs explicitly call out OAuth as the recommended flow for some providers. +- The official [OpenCode site](https://dev.opencode.ai/) positions OpenCode as supporting multiple sessions, a desktop app, and connections to multiple providers. +- The official [OpenCode Go SDK repo](https://github.com/sst/opencode-sdk-go) shows there is at least one non-JS client generated from the server API. + +Observed implication: + +- OpenCode already solves much of the hard chat/session/model/provider work. +- I did **not** find a documented embeddable React component for "drop this exact chat UI into your app." +- The documented surfaces are **web mode**, **server mode**, and **SDK usage**. +- The official SDK docs are JS/TS-focused, and I did **not** find an official Rust SDK. + +That makes the best long-term integration path: + +- short-term: embed OpenCode web if you want speed +- long-term: run OpenCode as a local service or SDK-backed engine and own the shell UX natively + +### Electron Embedding Guidance + +The Electron docs matter here because the preview pane is the hardest part. + +- The [WebContentsView docs](https://www.electronjs.org/docs/latest/api/web-contents-view) position it as the replacement for older embedded content patterns. +- The [webview tag docs](https://www.electronjs.org/docs/latest/api/webview-tag) explicitly warn about architectural instability and recommend alternatives. + +Observed implication: + +- avoid building the right panel around `` +- if you need a real embedded Electron-like preview, `WebContentsView` is the right native primitive to investigate +- if you need the fastest shippable preview, a browser surface backed by a local dev server is much simpler + +### Git, PRs, and Screenshots + +The rest of the workflow also has mature primitives: + +- [git worktree](https://git-scm.com/docs/git-worktree) gives the exact mental model needed for "chat = isolated branch/worktree" +- [gh pr create](https://cli.github.com/manual/gh_pr_create) gives a clean CLI for one-click PR creation +- [Playwright screenshots](https://playwright.dev/docs/screenshots) document a straightforward way to capture preview artifacts + +Observed implication: + +- yes, the app can absolutely generate a branch, commit code, capture a screenshot, and open a PR +- that is not the risky part of the project +- the real complexity is maintaining a stable host shell while many editable preview sessions are running + +## 5. 🔍 Key Findings + +### 5.1 Electron Is the Correct First Platform + +Electron is where this idea gets dramatically easier because the platform already owns: + +- local git access +- child-process management +- secure token storage +- preview lifecycle control +- screenshot capture +- PR orchestration + +A web-first version would push too much of that into a remote backend. A mobile-first version would be mostly a remote companion, not a true local development host. + +### 5.2 "Self-Editing" Should Really Mean "Shell Controls Mutable Previews" + +The best mental model is not: + +- one Electron app edits the exact files currently powering itself + +The best mental model is: + +- one stable Electron shell manages many editable preview sessions + +Each preview session points at its own worktree and can be safely restarted, reverted, diffed, screenshotted, or destroyed without killing the host shell. + +### 5.3 The OpenCode Approach Is the Right Accelerator + +Your updated instinct is directionally right. + +OpenCode already has the hard parts that are expensive to rebuild: + +- provider integrations +- session model +- streaming responses +- diffs and revert concepts +- web mode +- SDK/server mode + +But the final xNet product should still own: + +- the left rail information architecture +- worktree/branch/PR linkage +- preview lifecycle +- code/data timeline model +- long-press targeting +- file/markdown/diff presentation + +### 5.4 Showing Every Streamed Chat at Once Is Probably the Wrong Default + +The requested middle column could "show streaming in all of the different chats," but that is likely noisier than useful once there are more than a few active sessions. + +Higher-quality default: + +- left rail shows all sessions with unread, running, diff, and error badges +- center panel shows the active session in full +- optional activity drawer or compact ticker shows background streams + +That keeps the interaction closer to Codex without turning the app into a wall of moving text. + +### 5.5 The Timeline Should Be Multi-Track, Not Just Git History + +The strongest version of this product is a unified timeline with multiple tracks: + +- chat checkpoints +- git commits and diffs +- preview screenshots +- xNet data history + +That is much better than a plain commit list because it lets the user ask: + +- "show me the version before that layout prompt" +- "show me the screenshot from the checkpoint where the table changed" +- "show me the data snapshot that went with this UI experiment" + +```mermaid +mindmap + root((Unified Timeline)) + Chat + prompts + streamed outputs + revert points + Code + worktree state + commits + diffs + branches + Preview + screenshots + visual diffs + health events + Data + xNet history + document snapshots + node diffs +``` + +### 5.6 Long-Press Direct Editing Is Feasible, but It Needs Explicit Instrumentation + +A polished "long press a thing and ask for changes" interaction is feasible in Electron, but it needs more than raw DOM inspection. + +Recommended targeting payload: + +- route or screen id +- component or feature id +- source file hint +- selected node/document id when relevant +- screenshot crop +- element bounds +- optional user-written design note + +Without deliberate edit-target metadata, the model will fall back to brittle DOM descriptions. + +### 5.7 Speed Has To Be an Architectural Requirement + +If the goal is "Codex-like, but much snappier," then the main design principle is: + +- **switching sessions must be local** + +That means: + +- the left rail should render entirely from cached local state +- chat history should restore from local storage before any network round-trip +- preview switching should show a warm runtime or the last screenshot instantly +- git refresh, model status, and remote streaming should reconcile in the background + +The slow-feeling version of this product would: + +- embed multiple heavy remote surfaces +- re-fetch chat state on every tab switch +- rebuild file trees and diffs on demand +- boot a fresh preview process every time the user clicks + +The fast-feeling version of this product would: + +- store session metadata and recent transcript state locally in xNet-backed storage +- keep the most recent preview runtimes warm +- snapshot previews at every checkpoint +- use background refresh for git and model state +- render only the active chat stream in full + +Performance implication: + +- Rust is not the first answer to perceived slowness here +- the biggest wins are caching, prewarming, process isolation, and avoiding renderer churn +- if a lower-level service is eventually needed, OpenCode already exposes a server API and has an official Go SDK, but I did not find an official Rust binding + +Suggested product budgets: + +| Interaction | Target | +| --- | --- | +| Switch active session in shell | under 50 ms for visible shell state | +| Restore warm preview | under 250 ms | +| Open diff/file/markdown tab | under 100 ms from cached data | +| Show "assistant is working" after prompt submit | under 50 ms | +| Preserve smooth scrolling with many sessions | no jank at 60 fps on the rail and active chat | + +## 6. ⚖️ Options and Tradeoffs + +### 6.1 OpenCode Integration Options + +| Option | What It Means | Strengths | Weaknesses | Fit | +| --- | --- | --- | --- | --- | +| A. Embed OpenCode web | Run OpenCode web and render it inside the center panel | Fastest path to a polished chat UI | Feels like a separate product, weaker worktree/preview integration, less likely to be the snappiest final UX | Good bootstrap, not best final state | +| B. Native xNet chat UI backed by OpenCode server/SDK | xNet owns the shell UI, OpenCode provides session/model engine | Best product quality, clean session-to-worktree mapping, easiest to blend with preview/diff/PR UX, best path to an instant-feeling shell | More implementation effort | Best long-term option | +| C. Fork or directly import OpenCode frontend internals | Reuse more of OpenCode's frontend | Potentially fast if internals are stable | High drift risk, undocumented integration surface, maintenance burden | Not recommended unless OpenCode publishes an embedding package | + +Recommendation: + +- use **A** only to bootstrap quickly +- build toward **B** as the real product +- avoid betting the architecture on **C** + +### 6.2 Preview Surface Options + +| Option | What It Means | Fidelity | Complexity | Recommendation | +| --- | --- | --- | --- | --- | +| A. Browser preview in the right panel | Run a per-worktree web/dev preview and display it in a standard browser surface | Medium | Medium | Best v1 tradeoff | +| B. Detached preview window | Launch a separate BrowserWindow or Electron instance for the target worktree | High | Medium-High | Good fallback for parity-critical flows | +| C. Embedded `WebContentsView` preview | Native embedded preview with isolated WebContents inside the shell window | Highest | High | Best eventual integrated experience | + +Important nuance: + +- If "the main actual app" must be literal Electron behavior, **C** or **B** will eventually matter. +- If the goal is fast UI iteration first, **A** is the better start. + +### 6.3 Git Integration Options + +| Option | What It Means | Strengths | Weaknesses | +| --- | --- | --- | --- | +| System git via child process | Call `git worktree`, `git status`, `git commit`, `git diff` from main process | Best feature coverage, matches real developer workflows | Requires git to be installed | +| Pure JS git library | Reimplement git flows in JS | Avoids shelling out | Worktree support and edge cases are weaker | + +Recommendation: + +- use the system `git` CLI behind a narrow `GitService` + +### 6.4 PR Creation Options + +| Option | What It Means | Recommendation | +| --- | --- | --- | +| GitHub CLI | Use `gh pr create` from main process | Best default if `gh` is available | +| GitHub REST fallback | Use the GitHub API if `gh` is not installed | Good secondary path | + +### 6.5 Provider Auth Options + +| Option | Meaning | Recommendation | +| --- | --- | --- | +| Reuse OpenCode provider/auth flows | Let OpenCode manage provider auth and session config | Best default | +| Homegrown direct OAuth/token hacks against consumer apps | Try to reuse ChatGPT/Claude consumer sessions directly | Avoid as a core architecture | +| Internal API aggregator | Route all model traffic through your own backend | Good for teams later, unnecessary for the first Electron version | + +Observed fact: + +- OpenCode documents provider configuration directly and the official site advertises broad provider support. + +Inference: + +- leaning on OpenCode's provider/auth model is safer than inventing your own token plumbing on day one + +## 7. ✅ Recommendation + +### 7.1 Product Shape + +Build an Electron-native **Developer Workspace Shell** with three primary panels: + +- left rail: sessions, worktrees, branches, files, PRs, model badges, health badges +- center panel: active chat session, streaming output, message-level diffs, revert/apply controls +- right panel: preview tabs for `App`, `Diff`, `Files`, `Markdown`, `PR`, and `History` + +Add a bottom or top-level **timeline rail** shared across the right side so the user can scrub: + +- prompt checkpoints +- git commits +- data snapshots +- screenshots + +### 7.2 Core Domain Model + +Use this as the central unit: + +```ts +export type DevSession = { + id: string + title: string + repoRoot: string + worktreePath: string + branch: string + baseRef: string + openCodeSessionId: string | null + providerId: string | null + modelId: string | null + preview: { + kind: 'browser' | 'window' | 'webcontents' + url: string | null + port: number | null + state: 'starting' | 'ready' | 'error' | 'stopped' + } + checkpoints: Array<{ + id: string + kind: 'prompt' | 'git-commit' | 'data-snapshot' | 'screenshot' + label: string + createdAt: number + }> +} +``` + +This gives the shell one stable object to bind all UX around. + +Use xNet storage for: + +- session metadata +- left-rail summaries +- checkpoint records +- preview artifacts and status +- cached transcript slices or summaries + +Do not force every token chunk through heavyweight persistence before it can render. Stream first, persist incrementally. + +### 7.3 Stable Shell + Mutable Preview Architecture + +```mermaid +flowchart LR + A["Stable xNet Workspace Shell"] --> B["Left Rail: Sessions / Worktrees / Branches / PRs"] + A --> C["Center Panel: Native Chat UI"] + A --> D["Right Panel: Preview / Diff / Files / Markdown / PR"] + B --> E["GitService"] + C --> F["OpenCode Service or SDK"] + C --> E + E --> G["Per-Session Worktree"] + G --> H["Preview Runtime"] + H --> D + I["xNet History Engine"] --> D + J["Secure Credential Store"] --> F +``` + +Key rule: + +- the shell never runs from the same worktree that the active session is rewriting + +That one rule protects the product from a large class of self-editing failure modes. + +### 7.4 Runtime Interaction Flow + +```mermaid +sequenceDiagram + participant U as User + participant S as Workspace Shell + participant G as GitService + participant O as OpenCode + participant P as Preview Runtime + + U->>P: Long-press element + P-->>S: Target metadata + screenshot crop + U->>S: "Tighten this layout" + S->>O: Run prompt with target context + O-->>S: Stream plan + patch/diff + S->>G: Apply changes in session worktree + G-->>P: Reload preview + P-->>S: Ready + screenshot + S-->>U: Updated UI + diff + checkpoint + U->>S: Create PR + S->>G: Commit branch and open PR flow +``` + +### 7.5 Recommended Phases + +#### Phase 0: prerequisites + +- fix the preload/service IPC mismatch +- introduce `GitService` and `DevSessionStore` +- create a new Electron shell route or mode for the developer workspace + +#### Phase 1: usable internal tool + +- left rail for sessions/worktrees/branches +- center panel using either embedded OpenCode web or a simple native chat panel +- right panel browser-based preview from per-worktree dev servers +- code diffs from git +- markdown/file preview using existing UI components + +#### Phase 2: high-quality version + +- native xNet chat UI backed by OpenCode server/SDK +- unified checkpoint rail +- screenshot capture and PR creation +- long-press targeting +- provider/model switching and session metadata + +#### Phase 3: parity and polish + +- detached real Electron preview for parity-sensitive flows +- optional embedded `WebContentsView` preview +- multi-session recovery, crash recovery, cleanup, and quotas + +### 7.6 Recommended UX Details + +Use the requested structure, but refine a few defaults: + +- left rail should group by repo first, then by branch/worktree/session +- center panel should default to one active chat at a time, with activity badges for the rest +- right panel should have tabs, not a single overloaded surface +- focus mode should collapse left and center panels without destroying session state +- diffs should live next to the preview, not in a totally separate page +- markdown previews should reuse existing markdown rendering for PR body drafts and docs diffs + +### 7.7 Performance-First Architecture Decisions + +To make this feel materially faster than Codex, optimize the following paths: + +- load the left rail from local xNet-backed state only +- denormalize session summaries so the rail never needs to assemble huge transcript objects +- keep the active session and the most recent 1-2 sessions warm +- show the last preview snapshot immediately while the live preview reconnects +- never block tab switches on git status, provider health, or preview boot +- keep OpenCode orchestration off the renderer hot path +- virtualize long rails and long transcripts +- avoid remounting the entire center and right panels on every session change + +If deeper optimization is needed later: + +- use a lower-level sidecar only for clearly CPU-bound tasks such as indexing, diff preparation, or screenshot hashing +- do **not** introduce Rust just to proxy remote LLM calls unless profiling proves the JS path is the bottleneck + +### 7.8 Model / Provider UX + +Recommended approach: + +- ask OpenCode for available providers and models +- expose a per-session selector in the center panel header +- persist model/provider choice on `DevSession` +- show costs or usage only when the underlying provider/session returns reliable numbers + +Do not make consumer-app OAuth token reuse the architectural center of the product. If OpenCode supports a provider/auth path, use that path. + +### 7.9 PR With Screenshot + +This part is very doable. + +Recommended flow: + +1. capture a checkpoint screenshot to `tmp/playwright/` or a session artifact directory +2. generate a PR body markdown file with summary, diff highlights, and screenshot link +3. commit code to the session branch +4. call `gh pr create --title ... --body-file ...` + +If `gh` is unavailable, fall back to the GitHub API. + +### 7.10 State Model + +```mermaid +stateDiagram-v2 + [*] --> Created + Created --> Ready + Ready --> Prompting + Prompting --> Applying + Applying --> Previewing + Previewing --> Checkpointed + Checkpointed --> Prompting + Checkpointed --> PRReady + Checkpointed --> Reverted + PRReady --> Merged + Reverted --> Ready +``` + +## 8. 🛠️ Implementation Checklist + +- [ ] Fix `xnetServices` preload allowlist to match the service client contract +- [ ] Add a `GitService` in Electron main for status, diff, worktree, branch, commit, revert, and PR operations +- [ ] Add a `DevSessionStore` with persistent session metadata +- [ ] Create a new Electron workspace shell layout using `ResizablePanelGroup` +- [ ] Build the left rail using `TreeView` plus branch/worktree/session badges +- [ ] Add session creation flow: base ref -> branch -> worktree -> preview runtime +- [ ] Start OpenCode through a managed service or SDK-backed bridge +- [ ] Decide bootstrap path: embedded OpenCode web vs thin native chat panel +- [ ] Add a preview runtime manager with port allocation, health checks, restart, and cleanup +- [ ] Add local caches and denormalized session summaries so tab switching does not require network or full transcript hydration +- [ ] Keep the active and most recent sessions warm +- [ ] Add right-panel tabs for preview, code diff, file preview, markdown preview, and PR preview +- [ ] Add a code checkpoint timeline track backed by git commits and session checkpoints +- [ ] Integrate the existing xNet data-history timeline as a parallel track +- [ ] Add long-press edit targeting and preview instrumentation metadata +- [ ] Add screenshot capture and storage conventions for checkpoints +- [ ] Add one-click PR creation with `gh` and a REST fallback +- [ ] Store provider credentials using a secure Electron pattern +- [ ] Add performance budgets and telemetry for session switching, preview resume, and diff rendering +- [ ] Add guardrails for dirty trees, merge-base drift, and worktree cleanup +- [ ] Add recovery flows for crashed preview runtimes and failed OpenCode sessions + +## 9. 🧪 Validation Checklist + +- [ ] Create two sessions against the same repo and confirm they get distinct worktrees and preview ports +- [ ] Switch between sessions without losing chat history or preview state +- [ ] Apply a patch from chat and confirm the preview reloads correctly +- [ ] Revert a patch and confirm the preview and git diff both return to the expected state +- [ ] Scrub through data history and verify the data timeline still behaves correctly +- [ ] Scrub through code checkpoints and verify diffs line up with the visible preview +- [ ] Verify session switching feels instant from local cache even with network disabled +- [ ] Verify warm preview restoration stays within the performance budget +- [ ] Capture a screenshot and include it in a generated PR draft +- [ ] Confirm provider/model switching updates the active session only +- [ ] Confirm no provider secrets are exposed directly to the renderer beyond the intended bridge +- [ ] Confirm the host shell survives when the target worktree contains broken code +- [ ] Confirm worktrees and background processes are cleaned up when sessions are deleted +- [ ] Run a manual Electron verification flow with Playwright against the workspace shell + +## 10. 💻 Example Code + +### Example A: Shell Layout + +This is the shape the new shell should take using existing UI primitives. + +```tsx +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup +} from '@xnetjs/ui' + +export function DevWorkspaceShell(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + ) +} +``` + +### Example B: Dev Session Actions + +Use pure, explicit actions so the UI stays deterministic. + +```ts +export type DevSessionAction = + | { type: 'session/create'; repoRoot: string; baseRef: string; title: string } + | { type: 'session/select'; sessionId: string } + | { type: 'chat/run'; sessionId: string; prompt: string; targetId?: string } + | { type: 'preview/restart'; sessionId: string } + | { type: 'checkpoint/create'; sessionId: string; label: string } + | { type: 'checkpoint/revert'; sessionId: string; checkpointId: string } + | { type: 'pr/create'; sessionId: string } +``` + +### Example C: Session-Scoped Service Bootstrapping + +This is intentionally conceptual, but it shows the right process boundary. + +```ts +export async function ensureSessionServices(session: DevSession): Promise { + await gitService.ensureWorktree({ + repoRoot: session.repoRoot, + branch: session.branch, + worktreePath: session.worktreePath, + baseRef: session.baseRef + }) + + await previewRuntimeManager.start({ + sessionId: session.id, + cwd: session.worktreePath + }) + + await openCodeManager.ensureSession({ + sessionId: session.id, + cwd: session.worktreePath, + providerId: session.providerId, + modelId: session.modelId + }) +} +``` + +### Example D: PR Draft Flow + +```ts +export async function createPullRequest(session: DevSession): Promise { + const screenshotPath = `tmp/playwright/${session.id}.png` + await screenshotManager.capture(session.id, screenshotPath) + + const body = await prBodyBuilder.build({ + session, + screenshotPath + }) + + await gitService.commitAll({ + cwd: session.worktreePath, + message: `feat(electron): update ${session.title}` + }) + + await gitHubPrService.create({ + cwd: session.worktreePath, + title: session.title, + body + }) +} +``` + +### Example E: Instant Session Switching + +This is the kind of interaction pattern the shell should prefer. + +```tsx +import { startTransition } from 'react' + +export function selectSession(sessionId: string): void { + // Swap visible UI from local state immediately. + startTransition(() => { + sessionStore.setActive(sessionId) + }) + + // Show the last known preview frame while the live runtime reconnects. + previewCache.showSnapshot(sessionId) + + // Refresh slow or remote things in the background. + void sessionHydrator.reconcile(sessionId) + void previewRuntimeManager.ensureWarm(sessionId) + void gitStatusCache.refresh(sessionId) +} +``` + +## 11. 📚 References + +### Repository References + +- `apps/electron/src/renderer/App.tsx` +- `apps/electron/src/main/index.ts` +- `apps/electron/src/main/service-ipc.ts` +- `apps/electron/src/main/secure-seed.ts` +- `apps/electron/src/preload/index.ts` +- `apps/electron/electron.vite.config.ts` +- `packages/ui/src/primitives/ResizablePanel.tsx` +- `packages/ui/src/composed/TreeView.tsx` +- `packages/ui/src/components/MarkdownContent.tsx` +- `packages/ui/src/composed/CodeBlock.tsx` +- `packages/devtools/src/panels/HistoryPanel/HistoryPanel.tsx` +- `packages/history/src/diff.ts` +- `packages/plugins/src/services/client.ts` + +### Web References + +- [OpenCode Web Docs](https://opencode.ai/docs/web) +- [OpenCode Server Docs](https://opencode.ai/docs/server) +- [OpenCode JavaScript SDK Docs](https://opencode.ai/docs/sdk/javascript) +- [OpenCode Go SDK](https://github.com/sst/opencode-sdk-go) +- [OpenCode Providers Docs](https://opencode.ai/docs/providers) +- [OpenCode Official Site](https://dev.opencode.ai/) +- [Electron WebContentsView Docs](https://www.electronjs.org/docs/latest/api/web-contents-view) +- [Electron webview Tag Docs](https://www.electronjs.org/docs/latest/api/webview-tag) +- [git worktree Docs](https://git-scm.com/docs/git-worktree) +- [GitHub CLI `gh pr create` Manual](https://cli.github.com/manual/gh_pr_create) +- [Playwright Screenshots Docs](https://playwright.dev/docs/screenshots) + +## 12. 🚀 Next Actions + +If this direction is accepted, the next implementation artifact should be a concrete Electron execution plan with these first slices: + +1. fix the Electron service boundary +2. add `GitService` plus `DevSessionStore` +3. replace the current shell layout with a three-panel developer workspace mode +4. wire a single-session preview runtime +5. bootstrap the center panel with OpenCode web or a thin OpenCode-backed native chat + +That sequence gets to a real, testable vertical slice without committing too early to the hardest part of the system. From f2054d5888f1ca07657175de4ab7dadca111d6b3 Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 10:01:52 -0800 Subject: [PATCH 03/19] docs(exploration): outline opencode-first electron MVP - add an MVP-focused exploration for a fast self-editing Electron workspace - recommend reusing OpenCode web as the primary chat UI and coding-agent substrate - detail cheap performance wins built on xNet React/data primitives, warm previews, and thin git worktree wrappers - compare OpenCode with T3 Code and Vercel's coding-agent template using current official sources --- ..._OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md | 787 ++++++++++++++++++ 1 file changed, 787 insertions(+) create mode 100644 docs/explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md diff --git a/docs/explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md b/docs/explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md new file mode 100644 index 000000000..0d0a37796 --- /dev/null +++ b/docs/explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md @@ -0,0 +1,787 @@ +# Electron MVP Self-Editing Workspace with Cheap Performance Wins + +## 1. 🧭 Title and Problem Statement + +This exploration narrows the self-editing xNet idea down to the simplest MVP that is still genuinely usable: + +- keep the Electron shell native and fast +- avoid building a custom chat UI if possible +- avoid building a custom LLM provider layer +- avoid Rust unless profiling proves TypeScript is the bottleneck +- get worktrees, right-click UI context, preview switching, and PR creation working quickly + +The core question is: + +> What is the simplest Electron MVP that feels good, stays fast, and leverages existing tools instead of rebuilding them? + +The specific sub-questions are: + +- Can OpenCode be reused for most of the LLM/chat/interface complexity? +- Can xNet primitives own the fast local state and timeline UX? +- Can right-clicking the live UI pass useful context into OpenCode with minimal glue code? +- Are there better tools than OpenCode for this repo and this UX? + +## 2. ⚡ Executive Summary + +As of **March 7, 2026**, the best MVP path is: + +1. build a small **native xNet Electron shell** +2. run **OpenCode Web** as a managed local service +3. use the **OpenCode web UI directly** for the main chat panel +4. use **system `git worktree`** from Electron main for isolated session branches +5. use **xNet React/data primitives** for fast local shell state, cached summaries, checkpoints, and preview metadata +6. inject selected UI context into OpenCode using either: + - prompt prefill / prompt append + - or a tiny OpenCode custom tool that reads the current selected-element payload + +### MVP Recommendation in One Sentence + +Use **OpenCode as the coding/chat engine and UI**, use **xNet as the fast local shell/state layer**, and use **Electron main** only for worktrees, preview process management, screenshots, and PR creation. + +### What Not To Do for the MVP + +- do **not** build a custom provider aggregator +- do **not** build a full custom chat UI +- do **not** introduce Rust up front +- do **not** build a complex git abstraction beyond thin wrappers around `git` and `gh` +- do **not** force raw token streaming through xNet persistence before rendering + +### Why This Is the Best Tradeoff + +OpenCode already provides: + +- multi-provider model access +- a working chat UI in web/desktop/TUI form +- session APIs +- plugins +- custom tools +- IDE-style context awareness patterns +- session diff/revert primitives + +xNet already provides: + +- local-first React hooks +- cached query subscriptions +- optimistic writes +- virtualization patterns +- history/timeline primitives +- Electron process infrastructure + +So the cheapest usable MVP is not "build another agent app." It is "wrap OpenCode with an xNet-native fast shell." + +## 3. 🧱 Current State in the Repository + +### xNet Already Has a Strong Local Performance Substrate + +The repo already contains the primitives needed for a fast shell: + +- `packages/react/src/hooks/useQuery.ts:11-13` says the DataBridge handles caching and `useSyncExternalStore` subscriptions +- `packages/react/src/hooks/useQuery.ts:195-219` wires queries through a concurrent-safe subscription model +- `packages/react/src/hooks/useMutate.ts:12-17` and `packages/react/src/hooks/useMutate.ts:246-247` emphasize immediate local updates +- `packages/react/src/hooks/useNode.ts` already manages local document state, persistence debounce, and sync lifecycles +- `packages/data-bridge/src/query-cache.ts:1-10` provides in-memory query caching, deduplication, subscriber notification, LRU eviction, and weak-ref cleanup +- `packages/data-bridge/src/main-thread-bridge.ts:89-173` already does query caching plus incremental cache updates from store changes + +Observed implication: + +- the fast left rail, session summaries, cached preview metadata, and local timeline state should sit on xNet primitives, not bespoke React state sprinkled throughout the shell + +### xNet Already Has UI Performance Patterns + +The repo also already contains performance-oriented UI components: + +- `packages/views/src/table/VirtualizedTableView.tsx:1-6` and `:137-152` implement dual-axis virtualization +- `packages/views/src/__tests__/virtualized-table.test.tsx:509-527` includes explicit performance tests for large datasets +- `packages/data/src/database/query-router.ts:67-120` routes small datasets local, medium hybrid, and large hub + +Observed implication: + +- the shell can reuse xNet’s performance philosophy directly: local first, virtualize aggressively, degrade only when scale requires it + +### The Repo Already Exposes Some Targetable UI Metadata + +There is already precedent for metadata-based editable target detection: + +- `packages/views/src/table/TableView.tsx:121-125` marks editable surfaces with `data-xnet-db-editable="true"` +- `packages/views/src/board/BoardView.tsx:316-320` does the same +- `apps/electron/src/renderer/components/DatabaseView.tsx:513-516` already detects editable targets via `closest('[data-xnet-db-editable="true"]')` + +Observed implication: + +- the MVP can add a lightweight `data-xnet-target-*` convention rather than inventing a deep DOM/React inspection system immediately + +### Electron Main Already Has Most of the Service Plumbing + +Useful existing pieces: + +- `apps/electron/src/main/service-ipc.ts:49-129` manages background services +- `apps/electron/src/main/index.ts:31-40` already supports isolated profiles +- `apps/electron/src/main/index.ts:20-25` already exposes dev CDP ports in development +- `apps/electron/src/main/secure-seed.ts:34-76` already shows the secure storage pattern to reuse for provider credentials + +### The Most Relevant Gap Is Still the Service Boundary + +The preload mismatch still matters: + +- `apps/electron/src/main/service-ipc.ts` supports more channels than +- `apps/electron/src/preload/index.ts:240-267` currently allowlists + +If OpenCode is launched and managed as a local service from the renderer, that mismatch needs to be corrected first. + +### Current Repository Summary + +```mermaid +flowchart LR + A["xNet React Hooks"] --> B["Fast Local Shell State"] + C["DataBridge + QueryCache"] --> B + D["Virtualized Views"] --> E["Scalable Rails / Lists / File Views"] + F["History / Diff"] --> G["Checkpoint / Timeline UX"] + H["Electron Service IPC"] --> I["OpenCode + Preview Service Management"] + J["Editable Data Attributes"] --> K["Cheap Right-Click Targeting"] + L["Missing Worktree / PR / OpenCode Glue"] --> M["Main MVP Build Surface"] +``` + +## 4. 🌐 External Research + +### 4.1 OpenCode Is the Strongest Reuse Candidate + +Official OpenCode docs show several things that fit this MVP extremely well: + +- The [Web docs](https://opencode.ai/docs/web) document `opencode web`, a local server-backed web UI. +- Those same docs explicitly mention: + - fixed port configuration + - password protection + - CORS for custom frontends + - attaching a TUI to the running web server while sharing the same sessions and state +- The [Server docs](https://opencode.ai/docs/server) describe a local API with session, diff, revert, and TUI-oriented endpoints. +- The [SDK docs](https://opencode.ai/docs/sdk/) expose session and TUI operations, including prompt/session flows and event subscriptions. +- The [IDE docs](https://opencode.ai/docs/ide/) explicitly call out **context awareness** and automatically sharing the current selection or tab. +- The [Custom Tools docs](https://opencode.ai/docs/custom-tools/) show that tools can be written in TypeScript/JavaScript, can invoke other languages when needed, and receive `directory` and `worktree` in context. +- The [Plugins docs](https://opencode.ai/docs/plugins/) show plugin hooks for: + - `tool.execute.before` + - `tool.execute.after` + - `session.diff` + - `session.idle` + - `tui.prompt.append` + - `tui.command.execute` + +The [OpenCode homepage](https://dev.opencode.ai/) also claims: + +- multi-session support +- desktop app availability +- broad provider support +- ability to connect many providers and models + +Observed implication: + +- OpenCode is not just "an LLM wrapper." It already covers most of the hard surface area this MVP would otherwise have to build. + +### 4.2 OpenCode Also Reduces Integration Work + +OpenCode’s official docs show: + +- config-driven providers +- plugin support +- custom tools +- MCP server support +- ACP support for IDE/editor integration + +That means the MVP does **not** need to build: + +- a new provider abstraction layer +- a new tool/plugin protocol +- a new chat message/event model + +### 4.3 T3 Code Is a Useful Signal, but Not the Right Core Stack + +As of **March 7, 2026**, I could verify an official [T3 Code site](https://t3.codes/) and the public [pingdotgg/t3code](https://github.com/pingdotgg/t3code) repo. + +Observed facts from the official repo README: + +- T3 Code describes itself as a **minimal web GUI for coding agents** +- it is **currently Codex-first** +- Claude Code support is listed as coming soon +- it has a desktop app and is still described as very early + +This is useful directional evidence: + +- there is real product demand for a lightweight coding-agent GUI +- the product shape is converging on "thin shell around an existing coding agent" + +But it is **not** the best backend fit for this repo because: + +- it is Codex-first rather than provider-flexible +- it does not obviously solve the worktree/preview problem for xNet +- it does not reduce the need for custom glue nearly as much as OpenCode + +### 4.4 T3 Chat Is More a Product Signal Than a Stack Reuse Opportunity + +I did **not** verify a public Theo "T3 code app" beyond T3 Code itself. + +The closest adjacent public T3 signal I found was the [T3 Chat Cloneathon](https://cloneathon.t3.chat/), which focused on building fast, flexible AI chat apps. That is useful as a product signal, but it does not directly provide a coding-agent/worktree stack. + +Inference: + +- T3’s work points toward UX expectations +- OpenCode is still the better technical substrate for this repo’s MVP + +### 4.5 Vercel’s Coding Agent Template Shows a Different, Heavier Path + +The official [vercel-labs/coding-agent-template](https://github.com/vercel-labs/coding-agent-template) is relevant as an adjacent architecture: + +- it is centered on agent execution and git operations +- it assumes more remote/sandbox/backend orchestration than this xNet MVP wants + +Observed implication: + +- useful inspiration for task/session modeling +- too infrastructure-heavy for a simple local-first Electron MVP + +### 4.6 Git and PR Tooling Are Already Good Enough + +Official docs already cover the git/PR workflow the MVP needs: + +- [git worktree](https://git-scm.com/docs/git-worktree) +- [gh pr create](https://cli.github.com/manual/gh_pr_create) + +Observed implication: + +- no new git library is required for the MVP +- a thin `GitService` around system CLI commands is the correct move + +### 4.7 Electron Guidance Still Favors Simpler Embedding + +The Electron docs still steer away from `` and toward alternatives like `WebContentsView`. + +Observed implication: + +- for the MVP, prefer an `iframe` or a separate BrowserWindow for OpenCode Web and preview content +- avoid making `` the foundation + +## 5. 🔍 Key Findings + +### 5.1 The Simplest Good MVP Uses OpenCode Web as the Main Chat UI + +This is the single most important simplification. + +If the center panel is just OpenCode Web: + +- no custom message renderer +- no custom token stream transport +- no custom provider selector from scratch +- no custom slash command / permissions / tool runtime UI + +That removes a huge amount of work. + +### 5.2 xNet Should Own the Fast Paths, Not the LLM UI + +xNet already has the right primitives for: + +- session summary storage +- local subscriptions +- optimistic shell updates +- checkpoint metadata +- preview artifact storage +- diff/timeline composition + +So the shell should rely on xNet for: + +- left rail +- right-side metadata tabs +- checkpoint rail +- cached file/preview summaries + +But **not** for every streamed token in the main chat hot path. + +### 5.3 The Cheapest Performance Wins Are Mostly Architectural, Not Language-Level + +The cheapest big wins are: + +- keep OpenCode Web running as one long-lived local service +- keep the active preview and one recent preview warm +- restore session rails from local xNet-backed state +- store denormalized session summaries +- show last preview snapshots immediately +- background-refresh git/model/preview state +- virtualize any long lists + +Rust is not necessary to achieve any of those. + +### 5.4 Right-Click Context Is Easier Than It Looks If the Scope Is Narrow + +For the MVP, right-click context can be: + +- route id +- selected document/database id +- target label +- source file hint +- element bounds +- screenshot crop +- optional nearby text + +That payload can either: + +- be appended directly into the active OpenCode prompt +- or be exposed through a tiny OpenCode custom tool like `xnet_selected_context` + +No React-fiber introspection is required for v1. + +### 5.5 OpenCode’s Plugin and Tool Model Makes the Glue Small + +OpenCode custom tools and plugins are the biggest leverage points for MVP simplicity. + +Because custom tools get `directory` and `worktree`, a project-level tool can read: + +- the current selection context +- preview metadata +- the active screenshot path +- the current route or document id + +That means you do **not** need to deeply fork OpenCode to get contextual editing. + +### 5.6 T3 Code Validates the Thin-Shell Direction + +T3 Code is useful mainly because it confirms that a minimal GUI around a coding agent is already a viable product direction. + +But OpenCode is still the better fit here because it already covers: + +- multiple providers +- web UI +- plugins/tools +- sessions +- context awareness patterns + +### 5.7 No Better Candidate Emerged for This MVP + +For this repo and this product shape, I did **not** find a clearly better alternative than OpenCode. + +Reasons: + +- T3 Code is too provider/opinionated +- Vercel’s template is too cloud-heavy +- building directly on raw provider SDKs would recreate too much surface area + +## 6. ⚖️ Options and Tradeoffs + +### 6.1 Chat / Agent Stack Options + +| Option | Reuse Level | Complexity | Performance Risk | Recommendation | +| --- | --- | --- | --- | --- | +| A. OpenCode Web embedded in the shell | Very high | Low | Medium-low | **Best MVP option** | +| B. OpenCode server/SDK with native xNet chat UI | Medium | Medium-high | Lowest eventual ceiling risk | Best later evolution | +| C. T3 Code as the core agent shell | Low-Medium | Medium | Medium | Interesting reference, not the best fit | +| D. Raw OpenAI/Anthropic/etc. integrations | Low | High | High | Avoid | + +### 6.2 Context Injection Options + +| Option | What It Does | Cost | Recommendation | +| --- | --- | --- | --- | +| A. Prefill prompt with selected context | Writes structured text into the active prompt | Lowest | **Best first step** | +| B. OpenCode custom tool for `xnet_selected_context` | Lets the model fetch current selection context on demand | Low-Medium | Best second step | +| C. Deep React component inspection | Extract component tree/source automatically | High | Avoid in MVP | + +### 6.3 Preview Options + +| Option | What It Means | Cost | Fidelity | Recommendation | +| --- | --- | --- | --- | --- | +| A. Browser preview in right panel | Load renderer/dev preview as a browser surface | Low | Medium | **Best MVP option** | +| B. Pop-out parity window | Launch a separate BrowserWindow for Electron-specific checks | Medium | High | Good escape hatch | +| C. Embedded `WebContentsView` parity preview | Native embedded preview inside shell | High | Highest | Later only | + +### 6.4 Worktree Options + +| Option | Cost | Reliability | Recommendation | +| --- | --- | --- | --- | +| System `git worktree` CLI | Low | High | **Use this** | +| JS git abstraction | Medium | Medium | Unnecessary | +| Cloud sandbox branches | High | Medium | Too much for MVP | + +### 6.5 Cheap Performance Wins by Impact + +| Win | Cost | Impact | Keep for MVP | +| --- | --- | --- | --- | +| Long-lived OpenCode web service on fixed port | Low | High | Yes | +| xNet-backed denormalized `SessionSummary` records | Low | High | Yes | +| Warm preview runtime for active + recent sessions | Low-Medium | High | Yes | +| Last-screenshot instant restore | Low | High | Yes | +| Virtualized session/file rails | Low | Medium | Yes | +| Background git status refresh | Low | Medium | Yes | +| Native chat UI rewrite | High | Medium | No | +| Rust sidecar | High | Unknown | No | +| Embedded `WebContentsView` preview | High | Medium | No | + +## 7. ✅ Recommendation + +### MVP Architecture + +### 7.1 Product Shape + +Build a three-panel Electron MVP with: + +- left panel: native xNet session rail +- center panel: **OpenCode Web UI** +- right panel: native xNet preview/diff/files/checkpoints panel + +The xNet shell owns: + +- sessions +- worktrees +- preview processes +- screenshots +- checkpoint metadata +- PR generation + +OpenCode owns: + +- chat UI +- provider/model management +- coding agent runtime +- tool/plugin execution + +### 7.2 Recommended MVP Stack + +```mermaid +flowchart LR + A["xNet Electron Shell"] --> B["Session Rail (xNet state)"] + A --> C["OpenCode Web Panel"] + A --> D["Preview / Diff / Files / PR Panel"] + B --> E["xNet React + DataBridge"] + A --> F["GitService"] + A --> G["PreviewManager"] + A --> H["ScreenshotManager"] + A --> I["PrService"] + C --> J["OpenCode Web Server"] + J --> K["Providers / Models"] + F --> L["git worktree + branch"] + G --> M["Per-session preview runtime"] + D --> M + D --> E +``` + +### 7.3 Session Model + +Use one session record per worktree: + +```ts +export type SessionSummary = { + id: string + title: string + branch: string + worktreePath: string + openCodeUrl: string + previewUrl: string | null + lastMessagePreview: string + lastScreenshotPath: string | null + changedFilesCount: number + state: 'idle' | 'running' | 'previewing' | 'error' + updatedAt: number +} +``` + +This should live in xNet storage and be what powers instant left-rail switching. + +### 7.4 Right-Click Context Flow + +For MVP, use a simple context payload and inject it into OpenCode. + +```mermaid +sequenceDiagram + participant U as User + participant P as Preview + participant X as xNet Shell + participant O as OpenCode + + U->>P: Right-click selected element + P-->>X: target metadata + screenshot crop + X->>X: Store selected-context record + X->>O: Append/prefill structured prompt context + X-->>U: Focus OpenCode panel + U->>O: "Make this layout tighter" + O-->>U: Streamed response in OpenCode UI +``` + +Recommended payload: + +- `routeId` +- `targetId` +- `documentId` +- `fileHint` +- `bounds` +- `nearbyText` +- `screenshotPath` + +### 7.5 Two Good Ways to Bridge xNet Context into OpenCode + +#### Option A: prompt prefill + +Cheapest first implementation: + +- right-click builds a structured text block +- shell appends it into the OpenCode prompt +- user can edit or immediately send + +Why this is good: + +- no plugin required +- easy to debug +- very fast to ship + +#### Option B: custom tool + +Cleaner second implementation: + +- add `.opencode/tools/xnet-selected-context.ts` +- tool reads the latest selected context from a file or local endpoint +- instruct agent/rules to call it when prompt references "this UI", "selected element", or "the thing I clicked" + +Why this is good: + +- lower prompt noise +- more structured +- easier to evolve later + +### 7.6 How xNet Should Be Used for Performance + +Use xNet for the parts where it already has leverage: + +- `SessionSummary` query + subscription +- checkpoint records +- preview artifact metadata +- changed file summaries +- recent chat summaries, not raw token streams +- diff/timeline composition + +Do **not** use xNet as the first destination of every streaming token. + +Best pattern: + +- stream into OpenCode UI directly +- persist chunks or completed message parts asynchronously +- store summary records in xNet for instant shell restore + +### 7.7 What the MVP Should Explicitly Skip + +- no custom provider layer +- no native chat UI rewrite +- no Rust sidecar +- no deep component tree introspection +- no embedded `WebContentsView` work unless browser preview proves inadequate +- no ambitious unified cross-session streaming wall in the center panel + +### 7.8 Why This Should Feel Fast + +If implemented this way, the expensive things are mostly off the click path: + +- OpenCode service is already running +- preview runtime is warm or recently snapshotted +- left rail is local xNet state +- right panel metadata is local xNet state +- git status updates reconcile in the background + +This makes tab/session switches mostly UI state swaps, not process boot sequences. + +## 8. 🛠️ Implementation Checklist + +- [ ] Fix the Electron preload/service IPC mismatch +- [ ] Add a `GitService` wrapper around `git worktree`, `git status`, `git diff`, `git commit`, and `gh pr create` +- [ ] Add an `OpenCodeService` wrapper that starts `opencode web` on a fixed localhost port with auth +- [ ] Add a `SessionSummary` schema in xNet to persist worktree/session metadata +- [ ] Build the left rail from `useQuery(SessionSummarySchema)` rather than bespoke state +- [ ] Add a right-panel preview manager for browser-based preview URLs plus last screenshot +- [ ] Add a right-panel file/diff/markdown view using existing xNet UI components +- [ ] Add basic session switching with local cached summaries and screenshot fallback +- [ ] Add right-click capture using `data-xnet-target-*` attributes on targetable UI surfaces +- [ ] Add prompt prefill into the active OpenCode session +- [ ] Add a second-step OpenCode custom tool for selected context +- [ ] Add one-click session creation: branch + worktree + preview + OpenCode session +- [ ] Add one-click PR creation with screenshot attachment flow +- [ ] Add performance telemetry for session switch, preview restore, and rail render time +- [ ] Add cleanup for stale worktrees and background services + +## 9. 🧪 Validation Checklist + +- [ ] Start the shell and confirm OpenCode Web loads without writing a custom chat UI +- [ ] Create two worktree sessions and confirm left-rail switching is instant from local state +- [ ] Verify OpenCode remains connected while switching sessions +- [ ] Verify preview snapshots show immediately before live preview is ready +- [ ] Right-click a tagged UI element and confirm structured context reaches OpenCode +- [ ] Apply an OpenCode-generated change in a worktree and confirm preview reloads +- [ ] Confirm shell responsiveness remains high while OpenCode is streaming +- [ ] Confirm file and diff views stay responsive with many changed files +- [ ] Confirm worktrees can be removed and recreated cleanly +- [ ] Confirm PR creation works through `gh` when available +- [ ] Confirm no custom provider aggregator is needed for a full MVP loop +- [ ] Confirm no Rust is needed to meet the MVP performance budget + +## 10. 💻 Example Code + +These examples are intentionally **conceptual glue-code sketches**. They show the recommended boundaries and data flow, but they do not assume unpublished OpenCode package names or exact local wrapper implementations. + +### Example A: Start OpenCode Web as a Managed Service + +```ts +export async function ensureOpenCodeWeb(): Promise<{ + baseUrl: string +}> { + const port = 4096 + + await serviceClient.start({ + id: 'opencode-web', + name: 'OpenCode Web', + process: { + command: 'opencode', + args: ['web', '--port', String(port)], + env: { + OPENCODE_SERVER_PASSWORD: process.env.OPENCODE_SERVER_PASSWORD ?? 'dev-password' + } + }, + lifecycle: { restart: 'on-failure' }, + communication: { protocol: 'http', port } + }) + + return { baseUrl: `http://127.0.0.1:${port}` } +} +``` + +### Example B: Create a Worktree Session + +```ts +export async function createSession(input: { + repoRoot: string + baseRef: string + branch: string + worktreePath: string +}): Promise { + await git.run(input.repoRoot, [ + 'worktree', + 'add', + '-b', + input.branch, + input.worktreePath, + input.baseRef + ]) + + await sessionMutate.create(SessionSummarySchema, { + title: input.branch, + branch: input.branch, + worktreePath: input.worktreePath, + openCodeUrl: 'http://127.0.0.1:4096', + previewUrl: null, + lastMessagePreview: '', + lastScreenshotPath: null, + changedFilesCount: 0, + state: 'idle', + updatedAt: Date.now() + }) +} +``` + +### Example C: Capture and Prefill Selected UI Context + +```ts +export function buildSelectedContext(target: HTMLElement): string { + const targetId = target.dataset.xnetTargetId ?? 'unknown' + const routeId = document.body.dataset.xnetRouteId ?? 'unknown' + const fileHint = target.dataset.xnetFileHint ?? 'unknown' + + return [ + 'Selected UI context:', + `- route: ${routeId}`, + `- target: ${targetId}`, + `- fileHint: ${fileHint}` + ].join('\n') +} + +export async function handleEditThis(target: HTMLElement): Promise { + const prompt = `${buildSelectedContext(target)}\n\nPlease improve this UI.` + await openCodeBridge.appendPrompt(prompt) + shellState.focusCenterPanel() +} +``` + +### Example D: OpenCode Custom Tool for Selected Context + +```ts +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export default { + description: 'Read the current xNet selected UI context', + args: {}, + async execute(_args, context) { + const filePath = join(context.worktree, '.opencode', 'tmp', 'selected-context.json') + return readFile(filePath, 'utf8') + } +} +``` + +### Example E: Instant Session Switching + +```tsx +import { startTransition } from 'react' + +export function selectSession(sessionId: string): void { + startTransition(() => { + sessionStore.setActive(sessionId) + }) + + previewSnapshotStore.showLastSnapshot(sessionId) + + void previewManager.ensureWarm(sessionId) + void gitStatusStore.refresh(sessionId) +} +``` + +## 11. 📚 References + +### Repository References + +- `apps/electron/src/main/index.ts` +- `apps/electron/src/main/service-ipc.ts` +- `apps/electron/src/preload/index.ts` +- `apps/electron/src/main/secure-seed.ts` +- `apps/electron/src/renderer/components/DatabaseView.tsx` +- `packages/react/src/hooks/useQuery.ts` +- `packages/react/src/hooks/useMutate.ts` +- `packages/react/src/hooks/useNode.ts` +- `packages/react/src/instrumentation.ts` +- `packages/data-bridge/src/main-thread-bridge.ts` +- `packages/data-bridge/src/query-cache.ts` +- `packages/views/src/table/TableView.tsx` +- `packages/views/src/board/BoardView.tsx` +- `packages/views/src/table/VirtualizedTableView.tsx` +- `packages/views/src/__tests__/virtualized-table.test.tsx` +- `packages/data/src/database/query-router.ts` + +### Web References + +- [OpenCode Web Docs](https://opencode.ai/docs/web) +- [OpenCode Server Docs](https://opencode.ai/docs/server) +- [OpenCode SDK Docs](https://opencode.ai/docs/sdk/) +- [OpenCode IDE Docs](https://opencode.ai/docs/ide/) +- [OpenCode Plugins Docs](https://opencode.ai/docs/plugins/) +- [OpenCode Custom Tools Docs](https://opencode.ai/docs/custom-tools/) +- [OpenCode ACP Support Docs](https://opencode.ai/docs/acp/) +- [OpenCode Config Docs](https://opencode.ai/docs/config) +- [OpenCode Homepage](https://dev.opencode.ai/) +- [T3 Code](https://t3.codes/) +- [pingdotgg/t3code](https://github.com/pingdotgg/t3code) +- [T3 Chat Cloneathon](https://cloneathon.t3.chat/) +- [vercel-labs/coding-agent-template](https://github.com/vercel-labs/coding-agent-template) +- [git worktree Docs](https://git-scm.com/docs/git-worktree) +- [GitHub CLI `gh pr create`](https://cli.github.com/manual/gh_pr_create) +- [Electron WebContentsView Docs](https://www.electronjs.org/docs/latest/api/web-contents-view) +- [Electron webview Tag Docs](https://www.electronjs.org/docs/latest/api/webview-tag) + +## 12. 🚀 Next Actions + +The next implementation artifact should be a concrete MVP plan with these steps: + +1. fix the Electron service boundary +2. start OpenCode Web from Electron main +3. add `SessionSummary` to xNet and build the fast left rail on it +4. add `git worktree` session creation +5. add browser preview plus snapshot fallback +6. add right-click target metadata plus OpenCode prompt prefill + +That gets to a usable MVP with the fewest moving parts and the cheapest performance wins. From 34987c5e4a51340774278c49d74fd0eb8e58811e Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 10:12:22 -0800 Subject: [PATCH 04/19] docs(plan): add llm coding ui rollout - add plan03_9_83LLMCodingUI as a six-step Electron MVP implementation plan - structure the rollout around OpenCode reuse, xNet-backed fast shell state, git worktrees, previews, and PR drafting - document current repo touchpoints, official OpenCode and Electron guidance, and MVP performance guardrails --- .../01-service-boundary-and-opencode-host.md | 211 +++++++++++++++++ ...02-session-summary-and-fast-shell-state.md | 218 ++++++++++++++++++ .../03-shell-layout-and-opencode-panel.md | 169 ++++++++++++++ .../04-worktree-and-preview-orchestration.md | 189 +++++++++++++++ .../05-context-bridge-diff-pr-surface.md | 190 +++++++++++++++ ...06-hardening-and-performance-validation.md | 164 +++++++++++++ docs/plans/plan03_9_83LLMCodingUI/README.md | 196 ++++++++++++++++ 7 files changed, 1337 insertions(+) create mode 100644 docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/03-shell-layout-and-opencode-panel.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/04-worktree-and-preview-orchestration.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/05-context-bridge-diff-pr-surface.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/06-hardening-and-performance-validation.md create mode 100644 docs/plans/plan03_9_83LLMCodingUI/README.md diff --git a/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md b/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md new file mode 100644 index 000000000..9d2564535 --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md @@ -0,0 +1,211 @@ +# 01: Service Boundary and OpenCode Host + +> Fix the Electron service bridge, make OpenCode a managed local dependency, and prepare the renderer to host the OpenCode web UI safely. + +**Dependencies:** None + +## Objective + +Create a reliable OpenCode host layer that the rest of the MVP can assume exists. + +This step should leave the repo with: + +- a corrected Electron service boundary +- a managed `OpenCodeService` +- fixed localhost CSP rules for OpenCode and preview surfaces +- a clear missing-binary / bad-config fallback path + +## Scope and Dependencies + +In scope: + +- Electron main process service lifecycle +- preload allowlist alignment +- renderer CSP updates +- OpenCode binary detection and startup +- health checks and error states + +Out of scope: + +- session schemas +- shell layout +- worktree creation +- right-click context + +## Relevant Codebase Touchpoints + +- `apps/electron/src/main/service-ipc.ts` +- `apps/electron/src/preload/index.ts` +- `packages/plugins/src/services/client.ts` +- `apps/electron/src/main/index.ts` +- `apps/electron/src/main/secure-seed.ts` +- `apps/electron/src/renderer/index.html` +- `apps/electron/electron.vite.config.ts` +- `apps/electron/package.json` + +## Proposed Design + +### 1. Align the service contract + +The preload layer currently blocks service channels that the renderer client expects. + +Required contract alignment: + +- allow `restart` +- allow `list-all` +- allow `call` +- allow subscriptions for `status-update` and `output` + +This should be done by making the preload allowlist reference the shared channel constants rather than duplicating string literals. + +### 2. Add `OpenCodeService` + +Create a small main-process module, for example: + +- `apps/electron/src/main/opencode-service.ts` + +Responsibilities: + +- detect whether `opencode` is available on `PATH` +- start `opencode web` on a fixed or configurable localhost port +- provide renderer-readable status +- expose last error and recovery instructions +- stop cleanly on app shutdown + +For the MVP, prefer **system CLI dependency + clear UX** over bundling. + +### 3. Renderer embedding mode + +Target MVP mode: + +- render OpenCode Web in an `iframe` or similar browser surface in the center panel + +Fallback mode: + +- if iframe/CSP integration is problematic, open a dedicated BrowserWindow for OpenCode while preserving the same session model + +Do **not** base the MVP on ``. + +### 4. CSP updates + +The renderer currently only frames a fixed allowlist of public embed origins. + +Add controlled support for: + +- `http://127.0.0.1:*` +- `http://localhost:*` +- any preview port range used by the MVP + +Also ensure `connect-src` allows the required localhost HTTP/SSE/WebSocket flows. + +## Service Startup Flow + +```mermaid +sequenceDiagram + participant U as User + participant R as Renderer + participant P as Preload Bridge + participant M as Electron Main + participant O as OpenCode CLI + + U->>R: Open coding workspace + R->>P: xnetServices.start(opencode-web) + P->>M: service start IPC + M->>O: spawn "opencode web ..." + O-->>M: health endpoint ready + M-->>R: running + base URL + R-->>U: center panel ready +``` + +## Proposed API Shape + +```ts +export type OpenCodeHostStatus = + | { state: 'missing-binary'; error: string } + | { state: 'starting'; port: number } + | { state: 'ready'; port: number; baseUrl: string } + | { state: 'error'; error: string } + +export type OpenCodeHostController = { + ensure(): Promise + stop(): Promise + status(): Promise +} +``` + +## Concrete Implementation Notes + +### Suggested file additions + +- `apps/electron/src/main/opencode-service.ts` +- `apps/electron/src/main/git-utils.ts` only if shared subprocess helpers are needed here + +### Suggested settings / env model + +For MVP: + +- `XNET_OPENCODE_PORT` optional +- `XNET_OPENCODE_PASSWORD` optional +- allow explicit binary path later, but do not make it step 1 if `PATH` detection is sufficient + +### Secure storage + +Reuse the same secure-storage pattern seen in `secure-seed.ts` only if the shell itself stores OpenCode-specific secrets. + +If provider auth stays inside OpenCode’s own flows, keep the shell ignorant of provider credentials. + +### Example service definition sketch + +```ts +const openCodeService = { + id: 'opencode-web', + name: 'OpenCode Web', + process: { + command: 'opencode', + args: ['web', '--port', String(port)], + env: { + OPENCODE_SERVER_PASSWORD: password + } + }, + lifecycle: { + restart: 'on-failure', + startTimeoutMs: 15_000, + healthCheck: { + type: 'http', + url: `http://127.0.0.1:${port}` + } + }, + communication: { + protocol: 'http', + port + } +} +``` + +## Testing and Validation Approach + +- Add a targeted test for the preload allowlist contract if practical. +- Manual validation: + - start Electron + - open the coding workspace + - verify OpenCode starts once + - verify reloads do not spawn duplicates + - verify service output/status is visible from the renderer +- Failure validation: + - rename/remove `opencode` from `PATH` + - confirm the shell shows actionable recovery text instead of failing silently + +## Risks, Edge Cases, and Migration Concerns + +- OpenCode CLI version drift may break startup flags or health behavior. +- Overly broad localhost CSP can reduce renderer isolation; keep it narrow. +- If OpenCode Web requires auth flows that do not frame cleanly, fallback to BrowserWindow mode. + +## Step Checklist + +- [ ] Replace hard-coded preload allowlist strings with shared service channel constants +- [ ] Expose the full service client contract to the renderer +- [ ] Add OpenCode CLI detection and friendly failure UX +- [ ] Add `OpenCodeService` lifecycle wrapper in Electron main +- [ ] Add CSP allowances for localhost OpenCode and preview surfaces +- [ ] Validate clean startup/shutdown and duplicate-start protection diff --git a/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md b/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md new file mode 100644 index 000000000..032b0d618 --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md @@ -0,0 +1,218 @@ +# 02: Session Summary and Fast Shell State + +> Use xNet primitives to back the session rail, shell status, and instant-switch behavior before wiring full git and preview orchestration. + +**Dependencies:** Step 01 + +## Objective + +Create a local-first shell state model using xNet data primitives, not a bespoke store. + +This step should leave the repo with: + +- one or more app-local schemas for coding sessions +- local React hooks for the session rail and active-session state +- denormalized summary data optimized for instant switching +- a clear rule for what belongs in xNet and what stays ephemeral + +## Scope and Dependencies + +In scope: + +- app-local schema definitions +- hooks built on `useQuery` / `useMutate` +- session-summary denormalization +- active-session selection state +- cached summary restoration + +Out of scope: + +- real worktree creation +- OpenCode panel embedding +- full preview management + +## Relevant Codebase Touchpoints + +- `packages/react/src/hooks/useQuery.ts` +- `packages/react/src/hooks/useMutate.ts` +- `packages/react/src/hooks/useNode.ts` +- `packages/react/src/context.ts` +- `packages/data-bridge/src/main-thread-bridge.ts` +- `packages/data-bridge/src/query-cache.ts` +- `apps/electron/src/renderer/main.tsx` +- `apps/electron/src/renderer/App.tsx` + +## Proposed Design + +### 1. Add app-local session schemas + +Start app-local because this feature is Electron-only in the MVP. + +Suggested location: + +- `apps/electron/src/renderer/workspace/schemas.ts` + +Suggested schemas: + +- `SessionSummarySchema` +- `SessionArtifactSchema` (optional in this step if needed for screenshots/diff summaries) + +### 2. Denormalize aggressively + +Do not store raw chat transcripts or large diff payloads as the source of truth for the left rail. + +Store rail-friendly fields directly: + +- title +- branch +- worktree path +- preview URL +- changed files count +- last message preview +- last screenshot path +- current state +- updatedAt + +This makes `useQuery(SessionSummarySchema)` cheap and stable. + +### 3. Keep raw streaming out of the hot path + +The user explicitly wants the speed gains to come from xNet primitives where possible. + +For MVP, that should mean: + +- use xNet for durable local shell state +- use xNet for summary projections and artifacts +- do **not** persist every token before the UI can display it + +### 4. Avoid growing `@xnetjs/react` too early + +Use existing hooks first. + +Only promote new shared hook APIs if a pattern is clearly reusable after the MVP is proven. Candidate later extractions: + +- `prefetchQuery(...)` +- query-warming helpers +- summary-projection helpers + +Those should remain follow-on work unless blocked. + +## Data Model Sketch + +```ts +const SessionSummarySchema = defineSchema({ + name: 'SessionSummary', + namespace: 'xnet://xnet.dev/coding-ui/', + properties: { + title: text({ required: true }), + branch: text({ required: true }), + worktreePath: text({ required: true }), + openCodeUrl: text({ required: true }), + previewUrl: text(), + lastMessagePreview: text(), + lastScreenshotPath: text(), + changedFilesCount: integer(), + state: select({ + options: [ + { id: 'idle', name: 'Idle' }, + { id: 'running', name: 'Running' }, + { id: 'previewing', name: 'Previewing' }, + { id: 'error', name: 'Error' } + ] as const + }), + updatedAt: integer({ required: true }) + } +}) +``` + +## Proposed Hook Layer + +```mermaid +flowchart TD + A["useQuery(SessionSummarySchema)"] --> B["useSessionSummaries()"] + C["useMutate()"] --> D["useSessionCommands()"] + B --> E["Session Rail"] + D --> E + D --> F["Active Session Controller"] +``` + +Suggested hooks: + +- `useSessionSummaries()` +- `useActiveSessionId()` +- `useSessionCommands()` + +Keep these app-local in `apps/electron/src/renderer/workspace/hooks/`. + +## Concrete Implementation Notes + +### Suggested directory layout + +```text +apps/electron/src/renderer/workspace/ + schemas.ts + hooks/ + useSessionSummaries.ts + useSessionCommands.ts + useActiveSession.ts + state/ + active-session.ts +``` + +### What belongs in xNet + +- session summaries +- artifact metadata +- recent checkpoints +- file-change counts +- last screenshot path + +### What stays ephemeral or external + +- active token stream +- in-flight OpenCode panel DOM state +- transient right-click popover state +- preview boot promises + +### Example local command wrapper + +```ts +export function useSessionCommands() { + const { create, update } = useMutate() + + return { + async createSummary(input: SessionSummaryInput) { + return create(SessionSummarySchema, input) + }, + async markRunning(id: string) { + return update(SessionSummarySchema, id, { + state: 'running', + updatedAt: Date.now() + }) + } + } +} +``` + +## Testing and Validation Approach + +- Add hook-level tests around summary creation and update if practical. +- Manual validation: + - launch app + - seed a few session summaries + - verify the rail renders them from local state + - switch active session offline and confirm the shell still renders correctly + +## Risks, Edge Cases, and Migration Concerns + +- Summary drift is possible if preview or git state updates fail silently. +- App-local schemas may need promotion later if the feature grows beyond Electron. +- Over-modeling the session state too early can slow down future iterations. + +## Step Checklist + +- [ ] Add app-local session summary schema(s) +- [ ] Add app-local hooks for session summaries and commands +- [ ] Use denormalized summary records for rail and shell state +- [ ] Keep token streaming out of xNet’s hot path +- [ ] Validate instant local restoration of session summaries diff --git a/docs/plans/plan03_9_83LLMCodingUI/03-shell-layout-and-opencode-panel.md b/docs/plans/plan03_9_83LLMCodingUI/03-shell-layout-and-opencode-panel.md new file mode 100644 index 000000000..a54915cde --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/03-shell-layout-and-opencode-panel.md @@ -0,0 +1,169 @@ +# 03: Shell Layout and OpenCode Panel + +> Add the three-panel coding workspace mode in the Electron renderer and host OpenCode Web as the center panel without building a custom chat UI. + +**Dependencies:** Steps 01-02 + +## Objective + +Introduce a developer-workspace shell mode that coexists with the existing canvas-first app while proving the final interaction model: + +- left rail for sessions +- center panel for OpenCode +- right panel for preview and supporting artifacts + +## Scope and Dependencies + +In scope: + +- shell-mode entry point in the renderer +- panel composition and resizing +- session rail rendering +- center-panel OpenCode surface +- right-panel tab shell + +Out of scope: + +- real worktree creation +- real preview runtime management +- diff/PR data sources + +## Relevant Codebase Touchpoints + +- `apps/electron/src/renderer/App.tsx` +- `apps/electron/src/renderer/main.tsx` +- `packages/ui/src/primitives/ResizablePanel.tsx` +- `packages/ui/src/composed/TreeView.tsx` +- `packages/ui/src/components/MarkdownContent.tsx` +- `apps/electron/src/renderer/index.html` + +## Proposed Design + +### 1. Add a dedicated workspace shell mode + +Because the Electron renderer is not currently route-based, add a new top-level mode to `App.tsx` rather than introducing a full routing migration here. + +Recommended shape: + +- keep current canvas shell as default or sibling mode +- add a `coding-workspace` shell state +- allow entry from `SystemMenu` or a temporary developer toggle + +### 2. Use existing UI primitives + +Build the layout from `ResizablePanelGroup`, `ResizablePanel`, and `ResizableHandle`. + +Use `TreeView` for the left rail to avoid inventing a rail component. + +Use simple tabs on the right for: + +- Preview +- Diff +- Files +- Markdown +- PR + +### 3. Center panel strategy + +For MVP: + +- host OpenCode Web directly + +Preferred order: + +1. `iframe` to the local OpenCode web origin +2. fallback BrowserWindow if iframe integration is insufficient + +### 4. Right panel is native, not delegated + +Do not delegate the whole app shell to OpenCode. + +The right panel should remain native xNet UI because it will later own: + +- preview controls +- diff summaries +- markdown preview +- PR generation state +- checkpoint metadata + +## Shell Layout Diagram + +```mermaid +flowchart LR + A["Session Rail
TreeView + badges"] --> B["OpenCode Panel
iframe or BrowserWindow shell control"] + B --> C["Preview Workspace
tabs for Preview / Diff / Files / PR"] +``` + +## Concrete Implementation Notes + +### Suggested component structure + +```text +apps/electron/src/renderer/workspace/ + DevWorkspaceShell.tsx + SessionRail.tsx + OpenCodePanel.tsx + PreviewWorkspace.tsx + PreviewTabs.tsx +``` + +### Example shell composition + +```tsx +export function DevWorkspaceShell(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + ) +} +``` + +### Session rail behavior + +- selected session highlighted +- compact status badges (`idle`, `running`, `error`) +- changed file count and last screenshot indicators where available +- no expensive derived work during render + +### Right panel initial behavior + +In this step, allow stub or placeholder tabs if the underlying data is not wired yet. The goal is to land the shell shape early. + +## Testing and Validation Approach + +- Manual validation: + - open shell mode + - resize all three panels + - switch left-rail selection + - confirm the center panel remains mounted and does not thrash unnecessarily + - confirm placeholder right tabs render without blocking shell interaction + +## Risks, Edge Cases, and Migration Concerns + +- `iframe` integration may expose focus or keyboard shortcut conflicts with the surrounding shell. +- `App.tsx` could become too large if the new mode is not extracted quickly into separate workspace components. +- Over-eager remounting of the OpenCode panel will make the shell feel slower than necessary. + +## Step Checklist + +- [ ] Add a dedicated coding-workspace shell mode in the renderer +- [ ] Extract shell UI into workspace-specific components +- [ ] Build the left rail from app-local session hooks +- [ ] Add the OpenCode center panel host +- [ ] Add right-panel tabs for preview, diff, files, markdown, and PR +- [ ] Validate low-churn panel switching and resize behavior diff --git a/docs/plans/plan03_9_83LLMCodingUI/04-worktree-and-preview-orchestration.md b/docs/plans/plan03_9_83LLMCodingUI/04-worktree-and-preview-orchestration.md new file mode 100644 index 000000000..8dd829a31 --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/04-worktree-and-preview-orchestration.md @@ -0,0 +1,189 @@ +# 04: Worktree and Preview Orchestration + +> Replace placeholder sessions with real git worktrees, preview runtimes, and lifecycle management that keep switching fast without overcomplicating the stack. + +**Dependencies:** Steps 01-03 + +## Objective + +Give each coding session real backing resources: + +- a git branch +- a git worktree +- a preview runtime +- status and cleanup rules + +This is the step where the shell becomes a real development tool instead of a static shell prototype. + +## Scope and Dependencies + +In scope: + +- system git wrappers +- worktree create/list/remove flows +- branch naming conventions +- preview startup/restart/cleanup +- warm-preview behavior +- last-screenshot fallback + +Out of scope: + +- right-click context injection +- PR creation UI + +## Relevant Codebase Touchpoints + +- `apps/electron/src/main/index.ts` +- `apps/electron/src/main/service-ipc.ts` +- `apps/electron/electron.vite.config.ts` +- `apps/electron/src/main/secure-seed.ts` +- `apps/electron/src/renderer/App.tsx` +- `packages/plugins/src/services/process-manager.ts` + +## Proposed Design + +### 1. `GitService` should stay thin + +Use the system CLI and keep the wrapper very small. + +Needed commands: + +- `git worktree add` +- `git worktree list --porcelain` +- `git worktree remove` +- `git status --porcelain` +- `git diff --stat` +- `git rev-parse --show-toplevel` +- `git branch --show-current` +- `git commit` +- `gh pr create` + +### 2. Branch naming + +Default generated branch prefix: + +- `codex/` + +Examples: + +- `codex/layout-tighten-header` +- `codex/fix-table-density` + +### 3. Preview strategy + +For MVP, use a browser-based preview first. + +The preview manager should: + +- allocate per-session ports +- start one preview runtime per active session +- keep the active and most recent session warm +- store `lastScreenshotPath` +- expose health and restart controls to the shell + +### 4. Cleanup rules + +At minimum: + +- stale preview processes must stop when a session is deleted +- worktrees should not be removed automatically if dirty +- shell should surface explicit cleanup actions + +## Session Lifecycle Diagram + +```mermaid +stateDiagram-v2 + [*] --> Created + Created --> WorktreeReady + WorktreeReady --> PreviewStarting + PreviewStarting --> PreviewReady + PreviewReady --> Active + Active --> PreviewRestarting + PreviewRestarting --> PreviewReady + Active --> Error + Error --> PreviewRestarting + Active --> Archived +``` + +## Proposed API Shapes + +```ts +export type GitSessionInput = { + repoRoot: string + baseRef: string + branchSlug: string +} + +export type PreviewStatus = + | { state: 'starting'; port: number } + | { state: 'ready'; port: number; url: string } + | { state: 'error'; error: string } + +export interface GitService { + createWorktree(input: GitSessionInput): Promise<{ + branch: string + worktreePath: string + }> + listWorktrees(repoRoot: string): Promise + removeWorktree(worktreePath: string): Promise + getStatus(cwd: string): Promise +} +``` + +## Concrete Implementation Notes + +### Suggested file additions + +```text +apps/electron/src/main/ + git-service.ts + preview-manager.ts + workspace-session-ipc.ts +``` + +### Preview command strategy + +Keep this configurable enough to evolve, but start with one canonical preview mode. + +For MVP: + +- choose one preview command per repo +- start it from the session worktree directory +- capture the resulting port/URL + +If Electron parity is needed for a given bug or workflow: + +- add a pop-out parity window as a fallback mode later + +### Last-screenshot fallback + +Store screenshot metadata in the session summary so switching sessions can display something immediately while the preview reconnects. + +Recommended artifact path: + +- `tmp/playwright/.png` + +## Testing and Validation Approach + +- Unit or parser tests for status/diff parsing if the wrapper grows. +- Manual validation: + - create two sessions against the same repo + - confirm distinct worktrees are created + - confirm two preview URLs are distinct + - switch active sessions and verify warm preview behavior + - delete a clean session and confirm worktree/process cleanup + +## Risks, Edge Cases, and Migration Concerns + +- Missing `git` or `gh` binaries must produce actionable errors. +- Dirty worktrees are the biggest cleanup hazard; do not auto-delete them. +- Some repos may require different preview commands or bootstrap delays. + +## Step Checklist + +- [ ] Add `GitService` wrappers around worktree, status, diff, commit, and PR commands +- [ ] Use `codex/` as the default generated branch prefix +- [ ] Add `PreviewManager` with per-session port allocation +- [ ] Keep active and recent preview sessions warm +- [ ] Persist `previewUrl`, `changedFilesCount`, and `lastScreenshotPath` back into session summaries +- [ ] Add safe cleanup flows for stopped previews and removable worktrees diff --git a/docs/plans/plan03_9_83LLMCodingUI/05-context-bridge-diff-pr-surface.md b/docs/plans/plan03_9_83LLMCodingUI/05-context-bridge-diff-pr-surface.md new file mode 100644 index 000000000..056799384 --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/05-context-bridge-diff-pr-surface.md @@ -0,0 +1,190 @@ +# 05: Context Bridge, Diff, and PR Surface + +> Make the MVP useful: send right-click UI context into OpenCode, expose files/diffs/markdown in the shell, and generate PRs with screenshots. + +**Dependencies:** Steps 01-04 + +## Objective + +Connect the live preview to the coding agent with minimal custom glue while giving the user enough visibility to trust and review changes. + +This step should leave the MVP able to: + +- right-click part of the UI and send structured context to OpenCode +- inspect changed files and markdown previews in the shell +- capture a screenshot +- draft a PR from the active worktree + +## Scope and Dependencies + +In scope: + +- target metadata conventions +- right-click capture flow +- OpenCode prompt prefill +- optional custom-tool follow-up +- file/diff/markdown views +- screenshot and PR draft flow + +Out of scope: + +- deep component-tree introspection +- fully polished diff visualizations + +## Relevant Codebase Touchpoints + +- `apps/electron/src/renderer/components/DatabaseView.tsx` +- `packages/views/src/table/TableView.tsx` +- `packages/views/src/board/BoardView.tsx` +- `packages/ui/src/components/MarkdownContent.tsx` +- `packages/ui/src/composed/CodeBlock.tsx` +- `apps/electron/src/main/index.ts` +- `apps/electron/src/main/service-ipc.ts` + +## Proposed Design + +### 1. Minimal target metadata convention + +Add small, explicit target hints instead of trying to derive meaning from raw DOM shape. + +Recommended attributes: + +- `data-xnet-target-id` +- `data-xnet-target-label` +- `data-xnet-file-hint` +- `data-xnet-route-id` + +This should be added only to targetable shell/preview surfaces where the metadata is genuinely helpful. + +### 2. Two-stage OpenCode context bridge + +#### Stage A: prompt prefill + +Cheapest path: + +- right-click builds structured context text +- shell appends it to the active OpenCode prompt +- user can review before sending + +#### Stage B: custom tool + +Cleaner follow-up: + +- add a project-level OpenCode custom tool for selected context +- tool reads from a known file or local endpoint written by the shell + +MVP should ship Stage A first, and Stage B only if the prefill flow is too noisy. + +### 3. Right panel content strategy + +Keep the initial right-panel surface simple and reliable: + +- `Files`: changed file list with path + status +- `Diff`: textual diff summary or raw patch view +- `Markdown`: render docs/PR bodies with `MarkdownContent` +- `PR`: title/body draft and screenshot summary + +Do not block MVP on a rich visual diff library. + +### 4. PR flow + +The shell should: + +- capture screenshot +- assemble markdown body file +- stage and commit changes if requested +- call `gh pr create --title ... --body-file ...` + +If `gh` is missing: + +- show the generated PR title/body in the UI and fall back to manual copy flow + +## Right-Click Context Flow + +```mermaid +sequenceDiagram + participant U as User + participant P as Preview + participant S as Shell + participant O as OpenCode + participant G as Git/PR Layer + + U->>P: Right-click target + P-->>S: data-xnet-target payload + S->>S: capture screenshot + build context + S->>O: prefill prompt or expose selected-context tool + U->>O: Send prompt + O-->>S: code changes in worktree + S->>G: diff + screenshot + PR draft + S-->>U: review in Files / Diff / PR tabs +``` + +## Concrete Implementation Notes + +### Suggested selected-context payload + +```ts +export type SelectedContext = { + sessionId: string + routeId: string | null + targetId: string | null + targetLabel: string | null + fileHint: string | null + documentId: string | null + bounds: { x: number; y: number; width: number; height: number } | null + nearbyText: string | null + screenshotPath: string | null + capturedAt: number +} +``` + +### Suggested file additions + +```text +apps/electron/src/renderer/workspace/ + context/ + selected-context.ts + target-attributes.ts + panels/ + ChangedFilesPanel.tsx + DiffPanel.tsx + MarkdownPreviewPanel.tsx + PrDraftPanel.tsx +``` + +### Example prompt prefill shape + +```text +Selected UI context: +- route: database-view +- target: row-density-toggle +- fileHint: apps/electron/src/renderer/components/DatabaseView.tsx +- screenshot: tmp/playwright/session-123.png + +Please improve this UI while keeping the current interaction model. +``` + +## Testing and Validation Approach + +- Manual validation: + - right-click a tagged surface + - confirm context appears in the OpenCode prompt or is readable by the custom tool + - apply a change + - verify changed files populate in the shell + - verify markdown preview and PR draft render correctly + - create screenshot and dry-run or complete PR creation + +## Risks, Edge Cases, and Migration Concerns + +- Cross-origin restrictions may limit direct automation of an embedded OpenCode panel, depending on host mode. +- Right-clicking untagged UI should degrade gracefully instead of producing low-quality garbage context. +- PR creation must not assume the current branch is already pushed. + +## Step Checklist + +- [ ] Add `data-xnet-target-*` conventions to targetable surfaces +- [ ] Add selected-context capture and screenshot support +- [ ] Implement prompt prefill into the active OpenCode session +- [ ] Add a follow-up custom-tool path for selected context +- [ ] Build Files / Diff / Markdown / PR tabs with existing xNet UI primitives +- [ ] Add screenshot-backed PR draft generation and `gh pr create` flow diff --git a/docs/plans/plan03_9_83LLMCodingUI/06-hardening-and-performance-validation.md b/docs/plans/plan03_9_83LLMCodingUI/06-hardening-and-performance-validation.md new file mode 100644 index 000000000..671b49a12 --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/06-hardening-and-performance-validation.md @@ -0,0 +1,164 @@ +# 06: Hardening and Performance Validation + +> Turn the MVP into something stable enough to dogfood: add budgets, instrumentation, cleanup, and criteria for what should or should not be promoted into shared xNet APIs. + +**Dependencies:** Steps 01-05 + +## Objective + +Finish the MVP by making sure it is: + +- fast enough to feel good +- resilient enough to survive normal failure modes +- narrow enough to avoid premature abstraction + +## Scope and Dependencies + +In scope: + +- performance budgets +- instrumentation and telemetry +- failure handling and cleanup +- validation matrix +- decisions about shared xNet extraction + +Out of scope: + +- fully generalized cross-platform productization +- OpenCode replacement + +## Relevant Codebase Touchpoints + +- `packages/react/src/instrumentation.ts` +- `packages/views/src/__tests__/virtualized-table.test.tsx` +- `packages/data-bridge/src/query-cache.ts` +- `apps/electron/src/main/index.ts` +- `apps/electron/src/renderer/main.tsx` +- `apps/electron/README.md` + +## Proposed Design + +### 1. Set explicit budgets + +Target budgets for the MVP: + +| Interaction | Budget | +| --- | --- | +| Switch active session rail state | < 50 ms visible update | +| Show cached preview snapshot | < 100 ms | +| Reconnect warm preview | < 250 ms | +| Open Files / Diff / Markdown tab from cached state | < 100 ms | +| Show OpenCode “ready / streaming” feedback after prompt submit | < 50 ms | + +### 2. Measure the right things + +At minimum instrument: + +- session switch start/end +- OpenCode panel ready time +- preview ready time +- screenshot capture time +- diff generation time +- PR draft generation time + +Do not start by adding a complex telemetry backend just for this feature; reuse existing instrumentation hooks or local logging where appropriate. + +### 3. Cleanup and failure modes + +Must-have failure handling: + +- missing `opencode` +- missing `git` +- missing `gh` +- crashed preview process +- dirty worktree during delete +- broken code in preview worktree + +The host shell must survive all of those. + +### 4. Shared xNet API extraction rule + +The user explicitly wants performance leverage to live in xNet primitives where possible. + +Recommendation: + +- use existing xNet primitives in the MVP first +- only extract new shared hook or bridge APIs after the MVP proves the pattern + +Examples of potential later promotions: + +- query prefetch / warming helpers +- session-summary projection helpers +- shell-performance instrumentation helpers + +Examples that should **not** be promoted prematurely: + +- OpenCode-specific session hooks +- worktree-specific shell logic +- PR-generation helpers tied to GitHub CLI + +## Rollout / Stabilization Diagram + +```mermaid +flowchart TD + A["MVP features complete"] --> B["Measure budgets"] + B --> C{"Within budget?"} + C -->|Yes| D["Dogfood internally"] + C -->|No| E["Profile hot path"] + E --> F["Fix cache / remount / process churn"] + F --> B + D --> G["Promote only proven primitives into xNet"] +``` + +## Concrete Implementation Notes + +### Suggested validation matrix + +- clean repo +- dirty repo +- missing OpenCode +- missing gh +- one session +- two sessions +- preview crash +- offline or disconnected network + +### Suggested documentation updates + +- `apps/electron/README.md` for workspace shell usage and local dependencies +- a short “OpenCode required” setup note if system installation remains the MVP requirement + +### Suggested test split + +- keep most UI verification manual in Electron +- add targeted unit tests for session reducers, command parsing, and branch/worktree naming + +## Testing and Validation Approach + +- Manual end-to-end path: + - launch Electron + - create session + - right-click target + - send coding prompt + - inspect diff + - reload preview + - capture screenshot + - create PR +- Performance pass: + - measure session switching and preview resume with instrumentation enabled + - verify no obvious renderer jank while OpenCode is streaming + +## Risks, Edge Cases, and Migration Concerns + +- The temptation to add new shared APIs too early is the biggest architectural risk. +- OpenCode upgrades may change web behavior or CLI flows; keep the integration boundary narrow. +- If performance misses budget, fix lifecycle churn and local state shape before considering a language/runtime rewrite. + +## Step Checklist + +- [ ] Add instrumentation for session switching, preview restore, and panel readiness +- [ ] Verify the shell survives missing binaries and crashed child processes +- [ ] Add dirty-worktree protection and explicit cleanup UX +- [ ] Document local dependencies and recovery flows +- [ ] Run a manual Electron MVP validation matrix +- [ ] Decide which performance helpers, if any, deserve promotion into shared xNet packages diff --git a/docs/plans/plan03_9_83LLMCodingUI/README.md b/docs/plans/plan03_9_83LLMCodingUI/README.md new file mode 100644 index 000000000..eab51217c --- /dev/null +++ b/docs/plans/plan03_9_83LLMCodingUI/README.md @@ -0,0 +1,196 @@ +# Plan 03.9.83: LLM Coding UI + +> Electron-first MVP for a fast self-editing xNet workspace that reuses OpenCode for the coding agent and chat UI, uses xNet primitives for fast local shell state, and keeps git/worktree/preview orchestration thin. + +**Plan folder name:** `plan03_9_83LLMCodingUI` (supplied by the user) + +## 1. Title and Short Summary + +This plan turns the MVP exploration in [0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md](../../explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md) into an execution sequence for the repository. + +The target product is an Electron-native coding workspace with: + +- a fast xNet-backed left rail for sessions, worktrees, and status +- an OpenCode-powered center panel for coding chat +- a native preview/diff/files/PR panel on the right +- right-click UI context capture for “edit this part of the app” +- git worktree isolation, preview restart, screenshot capture, and PR creation + +The plan stays deliberately narrow: + +- reuse OpenCode wherever possible +- avoid a custom provider layer +- avoid a custom chat UI in the MVP +- avoid Rust unless profiling proves it is necessary +- keep performance wins primarily in xNet-backed local state and warm process reuse + +## 2. Problem Statement / Motivation + +The repo already has strong local-first data, sync, and UI primitives, but it does not yet have a developer-facing coding workspace shell. The desired outcome is a tool that feels closer to Codex in workflow while being faster and more interactive for xNet-specific development. + +The product problem is not “how do we talk to LLMs?” The product problem is: + +- how do we make session switching feel instant? +- how do we tie coding sessions to git worktrees cleanly? +- how do we let users click or right-click on the live UI and push that context into the coding agent? +- how do we do this without rebuilding provider integrations and a full chat experience from scratch? + +The plan therefore treats **OpenCode as the coding substrate** and **xNet as the local performance substrate**. + +## 3. Current State in the Repository + +Observed facts from the codebase: + +- The current Electron renderer shell is canvas-first, not developer-workspace-first. `apps/electron/src/renderer/App.tsx:432-498` +- `@xnetjs/react` already provides local-first hooks on top of DataBridge and `useSyncExternalStore`. `packages/react/src/hooks/useQuery.ts:11-13`, `packages/react/src/hooks/useQuery.ts:195-219` +- `useMutate` already assumes immediate local updates for subscribers. `packages/react/src/hooks/useMutate.ts:12-17`, `packages/react/src/hooks/useMutate.ts:246-247` +- `QueryCache` already supports query deduplication, in-memory sync access, subscriber notification, and LRU eviction. `packages/data-bridge/src/query-cache.ts:1-10` +- `MainThreadBridge` already performs incremental cache updates from store changes. `packages/data-bridge/src/main-thread-bridge.ts:89-173` +- UI primitives for a three-panel shell already exist in `@xnetjs/ui`. `packages/ui/src/primitives/ResizablePanel.tsx:12-47`, `packages/ui/src/composed/TreeView.tsx:22-99`, `packages/ui/src/components/MarkdownContent.tsx:109-122` +- The Electron main process already has process management and service IPC, but the preload allowlist is narrower than the service client contract. `apps/electron/src/main/service-ipc.ts:49-129`, `apps/electron/src/preload/index.ts:240-267`, `packages/plugins/src/services/client.ts:78-117` +- The renderer CSP currently does **not** allow framing localhost OpenCode or preview origins. `apps/electron/src/renderer/index.html:6-8` +- The repo already uses metadata-based target detection for editable surfaces in database views. `apps/electron/src/renderer/components/DatabaseView.tsx:513-516`, `packages/views/src/table/TableView.tsx:121-125`, `packages/views/src/board/BoardView.tsx:316-320` + +Observed gaps: + +- no git/worktree shell integration +- no OpenCode integration +- no preview-runtime manager +- no coding-workspace shell mode +- no PR drafting flow +- no first-class right-click context bridge + +## 4. Goals and Non-Goals + +### Goals + +- Build an Electron-only MVP that is usable and fast enough to dogfood. +- Reuse OpenCode for the coding agent, provider integrations, and primary chat UI. +- Use xNet React/data primitives for fast local shell state, cached summaries, and timeline metadata. +- Tie each coding session to a git worktree and preview runtime. +- Support right-click UI context capture with minimal instrumentation. +- Support one-click screenshot capture and PR creation. + +### Non-Goals + +- Building a custom provider router or model marketplace. +- Replacing OpenCode with a custom chat UI in the MVP. +- Supporting web, mobile, or Expo as first-class local editing hosts. +- Deep React-fiber inspection for component targeting. +- Introducing Rust sidecars before profiling shows a concrete need. +- Building a polished embedded `WebContentsView` parity preview in the MVP. + +## 5. Architecture / Phase Overview + +```mermaid +flowchart LR + A["Electron Shell"] --> B["Left Rail
xNet SessionSummary state"] + A --> C["Center Panel
OpenCode Web"] + A --> D["Right Panel
Preview / Diff / Files / PR"] + B --> E["@xnetjs/react + DataBridge"] + A --> F["GitService"] + A --> G["PreviewManager"] + A --> H["Screenshot + PR services"] + C --> I["OpenCode Web Service"] + I --> J["Providers / Tools / Sessions"] + F --> K["git worktree branches"] + G --> L["Warm preview runtimes"] + D --> E + D --> L +``` + +### Phase Sequence + +1. Foundation: fix service boundary and start OpenCode reliably. +2. Local shell state: model sessions in xNet and make switching fast. +3. Native shell UI: add the three-panel coding workspace mode. +4. Real session plumbing: create worktrees, previews, and lifecycle management. +5. Context bridge and PR surface: right-click context, diffs, markdown, screenshots, PRs. +6. Hardening: performance budgets, cleanup, failure handling, and extraction decisions. + +## 6. Step Index + +- [01-service-boundary-and-opencode-host.md](./01-service-boundary-and-opencode-host.md) +- [02-session-summary-and-fast-shell-state.md](./02-session-summary-and-fast-shell-state.md) +- [03-shell-layout-and-opencode-panel.md](./03-shell-layout-and-opencode-panel.md) +- [04-worktree-and-preview-orchestration.md](./04-worktree-and-preview-orchestration.md) +- [05-context-bridge-diff-pr-surface.md](./05-context-bridge-diff-pr-surface.md) +- [06-hardening-and-performance-validation.md](./06-hardening-and-performance-validation.md) + +## 7. Risks and Open Questions + +### Risks + +- OpenCode Web session switching may require a small amount of server/API glue even if the UI is reused wholesale. +- Embedding OpenCode Web and local preview surfaces requires CSP updates and careful localhost origin handling. +- Worktree lifecycle management gets messy quickly if dirty trees, crashed previews, or missing binaries are not handled early. +- It is easy to overreach and start rewriting OpenCode instead of integrating it. + +### Open Questions + +- What is the cleanest way to tell OpenCode Web to focus or open a specific session from the outer shell? +- Should the MVP keep OpenCode as a single long-lived service or allow per-repo service instances if session routing proves awkward? +- Is storing `SelectedContext` best done in xNet, in memory, or in a small per-worktree file consumed by OpenCode custom tools? +- Which exact preview command should be canonical for a session: browser-only preview, Electron preview, or a mixed mode? + +### Decision Guardrails + +- Prefer app-local wrappers over new shared package APIs until at least two real call sites validate the abstraction. +- Prefer system `git` and `gh` CLIs over JavaScript re-implementations. +- Prefer browser/iframe or BrowserWindow embedding over ``. + +## 8. Implementation Checklist + +- [ ] Fix the Electron service IPC / preload mismatch. +- [ ] Add OpenCode service lifecycle management in Electron main. +- [ ] Add xNet-backed `SessionSummary` storage and hooks. +- [ ] Add a coding-workspace shell mode to the renderer. +- [ ] Embed or host OpenCode Web in the center panel. +- [ ] Add `GitService` worktree lifecycle commands. +- [ ] Add `PreviewManager` with warm-session behavior. +- [ ] Add right-click target metadata conventions and capture flow. +- [ ] Add diff/file/markdown/PR tabs on the right panel. +- [ ] Add screenshot capture and PR drafting. +- [ ] Add cleanup, failure handling, and performance instrumentation. + +## 9. Validation Checklist + +- [ ] Session switching restores from local xNet-backed state without a network round trip. +- [ ] OpenCode stays usable without building a custom chat UI. +- [ ] Two sessions can target separate worktrees without clobbering each other. +- [ ] Preview switching shows an immediate cached state before the live runtime is ready. +- [ ] Right-click UI context reaches the active OpenCode session with clear structured metadata. +- [ ] Diff and markdown preview stay responsive under moderate repo churn. +- [ ] `gh pr create` can build a PR body from the generated artifacts and screenshot. +- [ ] The host shell survives broken preview code, crashed services, and deleted worktrees. + +## 10. References + +### Existing Repo Docs + +- [0108 Electron-First Self-Editing Workspace Shell Exploration](../../explorations/0108_[_]_ELECTRON_FIRST_SELF_EDITING_WORKSPACE_SHELL_CODEX_LIKE_CHATS_WORKTREES_BRANCHES_PREVIEW_DIFF_PRS_OPENCODE_INTEGRATION.md) +- [0109 Electron MVP Self-Editing Workspace Exploration](../../explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md) +- [plan03_7History README](../plan03_7History/README.md) +- [plan03_5Plugins 09 Services (Electron)](../plan03_5Plugins/09-services-electron.md) + +### Web References + +- [OpenCode Install Docs](https://opencode.ai/docs/install) +- [OpenCode Web Docs](https://opencode.ai/docs/web) +- [OpenCode Server Docs](https://opencode.ai/docs/server) +- [OpenCode SDK Docs](https://opencode.ai/docs/sdk/) +- [OpenCode IDE Docs](https://opencode.ai/docs/ide/) +- [OpenCode Plugins Docs](https://opencode.ai/docs/plugins/) +- [OpenCode Custom Tools Docs](https://opencode.ai/docs/custom-tools/) +- [OpenCode Config Docs](https://opencode.ai/docs/config) +- [OpenCode Providers Docs](https://opencode.ai/docs/providers/) +- [OpenCode ACP Docs](https://opencode.ai/docs/acp/) +- [OpenCode Homepage](https://dev.opencode.ai/) +- [T3 Code](https://t3.codes/) +- [pingdotgg/t3code](https://github.com/pingdotgg/t3code) +- [T3 Chat Cloneathon](https://cloneathon.t3.chat/) +- [Vercel Coding Agent Template](https://github.com/vercel-labs/coding-agent-template) +- [git worktree Docs](https://git-scm.com/docs/git-worktree) +- [GitHub CLI `gh pr create`](https://cli.github.com/manual/gh_pr_create) +- [Electron WebContentsView Docs](https://www.electronjs.org/docs/latest/api/web-contents-view) +- [Electron webview Tag Docs](https://www.electronjs.org/docs/latest/api/webview-tag) From 0626d7f7605d20bb87d6bfb8538b65acd507e331 Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 10:33:38 -0800 Subject: [PATCH 05/19] feat(electron): add opencode host foundation - align the preload service bridge with the full service IPC contract - fix service listener teardown and add OpenCode host status and control APIs - add local OpenCode lifecycle management, localhost CSP support, and Step 01 progress --- apps/electron/electron.vite.config.ts | 10 +- .../opencode-host-controller.test.ts | 133 ++++++++++ .../src/__tests__/service-ipc.test.ts | 13 + apps/electron/src/main/index.ts | 3 + .../src/main/opencode-host-controller.ts | 208 +++++++++++++++ apps/electron/src/main/opencode-service.ts | 215 +++++++++++++++ apps/electron/src/main/service-ipc.ts | 8 +- apps/electron/src/preload/index.ts | 100 ++++++- apps/electron/src/renderer/index.html | 2 +- apps/electron/src/shared/opencode-host.ts | 244 ++++++++++++++++++ apps/electron/src/shared/service-ipc.ts | 21 ++ .../src/types/xnetjs-plugins-node.d.ts | 40 +++ apps/electron/tsconfig.node.json | 2 + apps/electron/tsconfig.web.json | 8 +- apps/electron/vitest.config.ts | 1 + .../01-service-boundary-and-opencode-host.md | 10 +- docs/plans/plan03_9_83LLMCodingUI/README.md | 6 +- 17 files changed, 995 insertions(+), 29 deletions(-) create mode 100644 apps/electron/src/__tests__/opencode-host-controller.test.ts create mode 100644 apps/electron/src/__tests__/service-ipc.test.ts create mode 100644 apps/electron/src/main/opencode-host-controller.ts create mode 100644 apps/electron/src/main/opencode-service.ts create mode 100644 apps/electron/src/shared/opencode-host.ts create mode 100644 apps/electron/src/shared/service-ipc.ts create mode 100644 apps/electron/src/types/xnetjs-plugins-node.d.ts diff --git a/apps/electron/electron.vite.config.ts b/apps/electron/electron.vite.config.ts index bc5445672..1c39bd087 100644 --- a/apps/electron/electron.vite.config.ts +++ b/apps/electron/electron.vite.config.ts @@ -22,6 +22,11 @@ function stripCspInDev(): Plugin { // Support running multiple instances with different ports const rendererPort = parseInt(process.env.VITE_PORT || '5177', 10) +const xnetPluginNodeAlias = { + find: '@xnetjs/plugins/node', + replacement: resolve(__dirname, '../../packages/plugins/src/services/node.ts') +} + // Common xNet packages to bundle (not externalize) const xnetPackages = [ '@xnetjs/sdk', @@ -72,10 +77,7 @@ export default defineConfig({ } }, resolve: { - alias: { - // Resolve better-sqlite3 to local rebuilt version during bundling - 'better-sqlite3': betterSqlite3Path - } + alias: [xnetPluginNodeAlias, { find: 'better-sqlite3', replacement: betterSqlite3Path }] } }, preload: { diff --git a/apps/electron/src/__tests__/opencode-host-controller.test.ts b/apps/electron/src/__tests__/opencode-host-controller.test.ts new file mode 100644 index 000000000..0ee54ec48 --- /dev/null +++ b/apps/electron/src/__tests__/opencode-host-controller.test.ts @@ -0,0 +1,133 @@ +import type { ServiceDefinition, ServiceStatus } from '@xnetjs/plugins/node' +import { describe, expect, it, vi } from 'vitest' +import { createOpenCodeHostController } from '../main/opencode-host-controller' +import { + OPENCODE_SERVICE_ID, + createOpenCodeHostConfig, + type OpenCodeBinaryResolution, + type OpenCodeHostConfig, + type OpenCodeHostStatus +} from '../shared/opencode-host' + +const createServiceStatus = ( + config: OpenCodeHostConfig, + overrides: Partial = {} +): ServiceStatus => ({ + id: OPENCODE_SERVICE_ID, + state: 'running', + port: config.port, + pid: 4242, + startedAt: 1, + restartCount: 0, + ...overrides +}) + +const createBinaryResolution = (path: string): OpenCodeBinaryResolution => ({ + found: true, + path, + source: 'path' +}) + +describe('createOpenCodeHostController', () => { + it('should return a missing-binary status without starting a service', async () => { + const config = createOpenCodeHostConfig({ + XNET_OPENCODE_PORT: '4100' + }) + + const startService = vi.fn<(_: ServiceDefinition) => Promise>() + const publishStatus = vi.fn<(status: OpenCodeHostStatus) => void>() + + const controller = createOpenCodeHostController({ + getConfig: () => config, + getServiceStatus: () => undefined, + startService, + restartService: vi.fn(), + stopService: vi.fn(), + resolveBinary: vi.fn(async () => ({ + found: false, + checkedPaths: [], + error: 'OpenCode CLI was not found on PATH', + recovery: 'Install OpenCode' + })), + probeHealth: vi.fn(async () => null), + publishStatus + }) + + const status = await controller.ensure() + + expect(status.state).toBe('missing-binary') + expect(startService).not.toHaveBeenCalled() + expect(publishStatus).toHaveBeenCalledWith(status) + }) + + it('should dedupe concurrent ensure calls', async () => { + const config = createOpenCodeHostConfig({ + XNET_OPENCODE_PORT: '4101' + }) + + let serviceStatus: ServiceStatus | undefined + let resolveStart: ((status: ServiceStatus) => void) | null = null + + const startService = vi.fn(async (_definition: ServiceDefinition) => { + const nextStatus = await new Promise((resolve) => { + resolveStart = resolve + }) + serviceStatus = nextStatus + return nextStatus + }) + + const controller = createOpenCodeHostController({ + getConfig: () => config, + getServiceStatus: () => serviceStatus, + startService, + restartService: vi.fn(), + stopService: vi.fn(), + resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), + probeHealth: vi.fn(async () => ({ healthy: true, version: '1.0.0' })), + publishStatus: vi.fn() + }) + + const first = controller.ensure() + const second = controller.ensure() + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(startService).toHaveBeenCalledTimes(1) + + resolveStart?.(createServiceStatus(config)) + + const [firstStatus, secondStatus] = await Promise.all([first, second]) + + expect(firstStatus.state).toBe('ready') + expect(secondStatus).toEqual(firstStatus) + expect(startService).toHaveBeenCalledTimes(1) + }) + + it('should reuse an existing running service', async () => { + const config = createOpenCodeHostConfig({ + XNET_OPENCODE_PORT: '4102' + }) + + const serviceStatus = createServiceStatus(config) + const startService = vi.fn() + const restartService = vi.fn() + + const controller = createOpenCodeHostController({ + getConfig: () => config, + getServiceStatus: () => serviceStatus, + startService, + restartService, + stopService: vi.fn(), + resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), + probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.3' })), + publishStatus: vi.fn() + }) + + const status = await controller.ensure() + + expect(status.state).toBe('ready') + expect(status.version).toBe('1.2.3') + expect(startService).not.toHaveBeenCalled() + expect(restartService).not.toHaveBeenCalled() + }) +}) diff --git a/apps/electron/src/__tests__/service-ipc.test.ts b/apps/electron/src/__tests__/service-ipc.test.ts new file mode 100644 index 000000000..9134e7dfe --- /dev/null +++ b/apps/electron/src/__tests__/service-ipc.test.ts @@ -0,0 +1,13 @@ +import { SERVICE_IPC_CHANNELS } from '@xnetjs/plugins' +import { describe, expect, it } from 'vitest' +import { ALLOWED_SERVICE_CHANNELS, isAllowedServiceChannel } from '../shared/service-ipc' + +describe('service IPC allowlist', () => { + it('should expose the full shared service contract', () => { + expect([...ALLOWED_SERVICE_CHANNELS].sort()).toEqual(Object.values(SERVICE_IPC_CHANNELS).sort()) + }) + + it('should reject stale channel names', () => { + expect(isAllowedServiceChannel('xnet:service:list')).toBe(false) + }) +}) diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 555a5dfba..c73592acf 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -14,6 +14,7 @@ import { import { setupIPC, getOrCreateStorage } from './ipc' import { startLocalAPI, stopLocalAPI, setupLocalAPIIPC } from './local-api' import { createMenu } from './menu' +import { setupOpenCodeIPC, stopOpenCodeHost } from './opencode-service' import { setupServiceIPC, cleanupServices } from './service-ipc' import { initAutoUpdater } from './updater' @@ -193,6 +194,7 @@ app.whenReady().then(async () => { // Setup service IPC for plugin background processes setupServiceIPC() + setupOpenCodeIPC() // Setup Local API IPC handlers setupLocalAPIIPC() @@ -236,6 +238,7 @@ app.on('before-quit', async () => { await stopLocalAPI() // Stop all plugin services + await stopOpenCodeHost() await cleanupServices() // Stop cloudflare tunnel process diff --git a/apps/electron/src/main/opencode-host-controller.ts b/apps/electron/src/main/opencode-host-controller.ts new file mode 100644 index 000000000..211bbea31 --- /dev/null +++ b/apps/electron/src/main/opencode-host-controller.ts @@ -0,0 +1,208 @@ +/** + * OpenCode host controller for Electron main. + */ + +import type { ServiceDefinition, ServiceOutputEvent, ServiceStatus } from '@xnetjs/plugins/node' +import { + OPENCODE_SERVICE_ID, + createOpenCodeErrorStatus, + createOpenCodeMissingBinaryStatus, + createOpenCodeReadyStatus, + createOpenCodeRuntimeRecovery, + createOpenCodeServiceDefinition, + createOpenCodeStartingStatus, + createOpenCodeStoppedStatus, + type OpenCodeBinaryResolution, + type OpenCodeHealthPayload, + type OpenCodeHostConfig, + type OpenCodeHostStatus +} from '../shared/opencode-host' + +export type OpenCodeHostController = { + ensure(): Promise + status(): Promise + stop(): Promise + refresh(): Promise + recordOutput(event: ServiceOutputEvent): void +} + +type OpenCodeHostControllerDependencies = { + getConfig(): OpenCodeHostConfig + getServiceStatus(serviceId: string): ServiceStatus | undefined + startService(definition: ServiceDefinition): Promise + restartService(serviceId: string): Promise + stopService(serviceId: string): Promise + resolveBinary(config: OpenCodeHostConfig): Promise + probeHealth(config: OpenCodeHostConfig): Promise + publishStatus(status: OpenCodeHostStatus): void +} + +type OpenCodeHostRuntimeState = { + binaryPath?: string + lastError?: string + lastOutput?: string +} + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const normalizeOutput = (data: string): string | undefined => { + const value = data.trim() + return value ? value : undefined +} + +export function createOpenCodeHostController( + deps: OpenCodeHostControllerDependencies +): OpenCodeHostController { + const runtime: OpenCodeHostRuntimeState = {} + let ensurePromise: Promise | null = null + + const buildIdleStatus = async (config: OpenCodeHostConfig): Promise => { + const resolution = await deps.resolveBinary(config) + + if (!resolution.found) { + runtime.binaryPath = undefined + return createOpenCodeMissingBinaryStatus(config, resolution) + } + + runtime.binaryPath = resolution.path + return createOpenCodeStoppedStatus(config, resolution.path) + } + + const buildStatusFromService = async ( + config: OpenCodeHostConfig, + serviceStatus?: ServiceStatus + ): Promise => { + if (!serviceStatus) { + return buildIdleStatus(config) + } + + switch (serviceStatus.state) { + case 'running': { + const health = await deps.probeHealth(config).catch(() => null) + return createOpenCodeReadyStatus(config, { + binaryPath: runtime.binaryPath, + pid: serviceStatus.pid, + startedAt: serviceStatus.startedAt, + version: health?.version + }) + } + + case 'starting': + case 'stopping': + return createOpenCodeStartingStatus(config, runtime.binaryPath) + + case 'error': + return createOpenCodeErrorStatus(config, { + binaryPath: runtime.binaryPath, + error: serviceStatus.lastError ?? runtime.lastError ?? 'OpenCode failed to start', + recovery: createOpenCodeRuntimeRecovery(config), + lastOutput: runtime.lastOutput, + pid: serviceStatus.pid + }) + + case 'stopped': + default: + return buildIdleStatus(config) + } + } + + const publish = (status: OpenCodeHostStatus): OpenCodeHostStatus => { + deps.publishStatus(status) + return status + } + + const status = async (): Promise => { + const config = deps.getConfig() + const current = deps.getServiceStatus(OPENCODE_SERVICE_ID) + return buildStatusFromService(config, current) + } + + const refresh = async (): Promise => publish(await status()) + + const ensure = async (): Promise => { + const current = deps.getServiceStatus(OPENCODE_SERVICE_ID) + if (current && current.state !== 'error') { + return refresh() + } + + if (ensurePromise) { + return ensurePromise + } + + const config = deps.getConfig() + + ensurePromise = (async () => { + const resolution = await deps.resolveBinary(config) + + if (!resolution.found) { + runtime.binaryPath = undefined + return publish(createOpenCodeMissingBinaryStatus(config, resolution)) + } + + runtime.binaryPath = resolution.path + runtime.lastError = undefined + publish(createOpenCodeStartingStatus(config, resolution.path)) + + try { + const nextServiceStatus = current + ? await deps.restartService(OPENCODE_SERVICE_ID) + : await deps.startService( + createOpenCodeServiceDefinition({ ...config, binaryPath: resolution.path }) + ) + + const nextStatus = await buildStatusFromService(config, nextServiceStatus) + return publish(nextStatus) + } catch (error) { + const message = getErrorMessage(error) + runtime.lastError = message + return publish( + createOpenCodeErrorStatus(config, { + binaryPath: resolution.path, + error: message, + recovery: createOpenCodeRuntimeRecovery(config), + lastOutput: runtime.lastOutput + }) + ) + } finally { + ensurePromise = null + } + })() + + return ensurePromise + } + + const stop = async (): Promise => { + const config = deps.getConfig() + const current = deps.getServiceStatus(OPENCODE_SERVICE_ID) + + if (current) { + await deps.stopService(OPENCODE_SERVICE_ID) + } + + runtime.lastError = undefined + runtime.lastOutput = undefined + return publish(createOpenCodeStoppedStatus(config, runtime.binaryPath)) + } + + const recordOutput = (event: ServiceOutputEvent): void => { + if (event.serviceId !== OPENCODE_SERVICE_ID) { + return + } + + const normalized = normalizeOutput(event.data) + if (!normalized) { + return + } + + runtime.lastOutput = normalized + } + + return { + ensure, + status, + stop, + refresh, + recordOutput + } +} diff --git a/apps/electron/src/main/opencode-service.ts b/apps/electron/src/main/opencode-service.ts new file mode 100644 index 000000000..7bb2fdf76 --- /dev/null +++ b/apps/electron/src/main/opencode-service.ts @@ -0,0 +1,215 @@ +/** + * Electron main-process OpenCode host management. + */ + +import type { ServiceOutputEvent, ServiceStatusEvent } from '@xnetjs/plugins/node' +import { accessSync, constants } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, join } from 'node:path' +import { BrowserWindow, ipcMain } from 'electron' +import { + OPENCODE_HOST_IPC_CHANNELS, + OPENCODE_INSTALL_URL, + OPENCODE_SERVICE_ID, + createOpenCodeHostConfig, + createOpenCodeMissingBinaryRecovery, + type OpenCodeBinaryResolution, + type OpenCodeHealthPayload, + type OpenCodeHostConfig, + type OpenCodeHostStatus +} from '../shared/opencode-host' +import { createOpenCodeHostController } from './opencode-host-controller' +import { getProcessManager } from './service-ipc' + +const OPENCODE_HEALTH_PATH = '/global/health' + +let ipcRegistered = false +let serviceEventsRegistered = false + +const publishOpenCodeStatus = (status: OpenCodeHostStatus): void => { + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send(OPENCODE_HOST_IPC_CHANNELS.STATUS_CHANGE, status) + }) +} + +const publishOpenCodeOutput = (event: ServiceOutputEvent): void => { + const payload = { + serviceId: OPENCODE_SERVICE_ID, + stream: event.stream, + data: event.data, + timestamp: event.timestamp + } as const + + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send(OPENCODE_HOST_IPC_CHANNELS.OUTPUT, payload) + }) +} + +const controller = createOpenCodeHostController({ + getConfig: () => createOpenCodeHostConfig(process.env), + getServiceStatus: (serviceId) => getProcessManager().getStatus(serviceId), + startService: (definition) => getProcessManager().start(definition), + restartService: (serviceId) => getProcessManager().restart(serviceId), + stopService: (serviceId) => getProcessManager().stop(serviceId), + resolveBinary: async (config) => resolveOpenCodeBinary(config), + probeHealth: async (config) => probeOpenCodeHealth(config), + publishStatus: publishOpenCodeStatus +}) + +const isExecutableFile = (path: string): boolean => { + try { + accessSync(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + return true + } catch { + return false + } +} + +const getBinaryNames = (): string[] => + process.platform === 'win32' ? ['opencode.exe', 'opencode.cmd', 'opencode.bat'] : ['opencode'] + +const getCommonBinaryLocations = (): string[] => { + const home = homedir() + const binaryNames = getBinaryNames() + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA + const scoop = join(home, 'scoop', 'shims') + const base = localAppData ? [join(localAppData, 'Programs')] : [] + return [...base, scoop].flatMap((dir) => binaryNames.map((name) => join(dir, name))) + } + + const directories = + process.platform === 'darwin' + ? ['/opt/homebrew/bin', '/usr/local/bin', join(home, '.local', 'bin'), join(home, 'bin')] + : ['/usr/local/bin', '/usr/bin', join(home, '.local', 'bin'), join(home, 'bin')] + + return directories.flatMap((dir) => binaryNames.map((name) => join(dir, name))) +} + +const getPathCandidates = (): string[] => { + const pathDirs = (process.env.PATH || '') + .split(delimiter) + .map((value) => value.trim()) + .filter(Boolean) + + return pathDirs.flatMap((dir) => getBinaryNames().map((name) => join(dir, name))) +} + +async function resolveOpenCodeBinary( + config: OpenCodeHostConfig +): Promise { + const uniqueCandidates = new Set() + + if (config.binaryPathOverride) { + uniqueCandidates.add(config.binaryPathOverride) + } + + const pathCandidates = getPathCandidates() + pathCandidates.forEach((candidate) => uniqueCandidates.add(candidate)) + + const commonCandidates = getCommonBinaryLocations() + commonCandidates.forEach((candidate) => uniqueCandidates.add(candidate)) + + for (const candidate of uniqueCandidates) { + if (!isExecutableFile(candidate)) { + continue + } + + if (config.binaryPathOverride && candidate === config.binaryPathOverride) { + return { found: true, path: candidate, source: 'override' } + } + + if (pathCandidates.includes(candidate)) { + return { found: true, path: candidate, source: 'path' } + } + + return { found: true, path: candidate, source: 'common' } + } + + return { + found: false, + checkedPaths: [...uniqueCandidates], + error: config.binaryPathOverride + ? `OpenCode CLI was not found at XNET_OPENCODE_BINARY (${config.binaryPathOverride})` + : 'OpenCode CLI was not found on PATH', + recovery: createOpenCodeMissingBinaryRecovery(config.binaryPathOverride) + } +} + +async function probeOpenCodeHealth( + config: OpenCodeHostConfig +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const headers = config.password + ? { + authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}` + } + : undefined + + const response = await fetch(`${config.baseUrl}${OPENCODE_HEALTH_PATH}`, { + headers, + signal: controller.signal + }) + + if (!response.ok) { + return null + } + + return (await response.json()) as OpenCodeHealthPayload + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +const registerServiceEventForwarding = (): void => { + if (serviceEventsRegistered) { + return + } + + const manager = getProcessManager() + + manager.on('service:status', (event: ServiceStatusEvent) => { + if (event.serviceId !== OPENCODE_SERVICE_ID) { + return + } + + void controller.refresh() + }) + + manager.on('service:output', (event: ServiceOutputEvent) => { + if (event.serviceId !== OPENCODE_SERVICE_ID) { + return + } + + controller.recordOutput(event) + publishOpenCodeOutput(event) + }) + + serviceEventsRegistered = true +} + +export function setupOpenCodeIPC(): void { + if (ipcRegistered) { + return + } + + registerServiceEventForwarding() + + ipcMain.handle(OPENCODE_HOST_IPC_CHANNELS.ENSURE, async () => controller.ensure()) + ipcMain.handle(OPENCODE_HOST_IPC_CHANNELS.STATUS, async () => controller.status()) + ipcMain.handle(OPENCODE_HOST_IPC_CHANNELS.STOP, async () => controller.stop()) + + ipcRegistered = true +} + +export async function stopOpenCodeHost(): Promise { + await controller.stop() +} + +export { OPENCODE_INSTALL_URL } diff --git a/apps/electron/src/main/service-ipc.ts b/apps/electron/src/main/service-ipc.ts index 20859a49f..0e433f4bd 100644 --- a/apps/electron/src/main/service-ipc.ts +++ b/apps/electron/src/main/service-ipc.ts @@ -8,7 +8,9 @@ import { ProcessManager, SERVICE_IPC_CHANNELS, type ServiceDefinition, - type ServiceStatus + type ServiceOutputEvent, + type ServiceStatus, + type ServiceStatusEvent } from '@xnetjs/plugins/node' import { ipcMain, BrowserWindow } from 'electron' @@ -24,14 +26,14 @@ export function getProcessManager(): ProcessManager { processManager = new ProcessManager() // Forward status events to all renderer windows - processManager.on('service:status', (event) => { + processManager.on('service:status', (event: ServiceStatusEvent) => { BrowserWindow.getAllWindows().forEach((win) => { win.webContents.send(SERVICE_IPC_CHANNELS.STATUS_UPDATE, event) }) }) // Forward output events to all renderer windows - processManager.on('service:output', (event) => { + processManager.on('service:output', (event: ServiceOutputEvent) => { BrowserWindow.getAllWindows().forEach((win) => { win.webContents.send(SERVICE_IPC_CHANNELS.OUTPUT, event) }) diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 7b445bde0..1e75a7242 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -1,8 +1,15 @@ /** * Preload script - exposes xNet API to renderer */ +import type { ServiceIpcChannel } from '../shared/service-ipc' import type { SyncReplicationConfig } from '@xnetjs/sync' import { contextBridge, ipcRenderer } from 'electron' +import { + OPENCODE_HOST_IPC_CHANNELS, + type OpenCodeHostOutputEvent, + type OpenCodeHostStatus +} from '../shared/opencode-host' +import { isAllowedServiceChannel } from '../shared/service-ipc' // Expose xNet API to renderer contextBridge.exposeInMainWorld('xnet', { @@ -237,32 +244,90 @@ contextBridge.exposeInMainWorld('xnetBSM', { getDebug: () => ipcRenderer.invoke('xnet:bsm:get-debug') }) -// Allowed IPC channels for xnetServices (SEC-02: prevent arbitrary IPC access) -const ALLOWED_SERVICE_CHANNELS = new Set([ - // Plugin service channels - 'xnet:service:start', - 'xnet:service:stop', - 'xnet:service:status', - 'xnet:service:list' -]) +const serviceChannelListeners = new Map< + string, + Map<(...args: unknown[]) => void, (_event: unknown, ...args: unknown[]) => void> +>() + +const getServiceChannelListeners = ( + channel: string +): Map<(...args: unknown[]) => void, (_event: unknown, ...args: unknown[]) => void> => { + const existing = serviceChannelListeners.get(channel) + if (existing) { + return existing + } + + const next = new Map< + (...args: unknown[]) => void, + (_event: unknown, ...args: unknown[]) => void + >() + serviceChannelListeners.set(channel, next) + return next +} // Expose service API for plugin background processes contextBridge.exposeInMainWorld('xnetServices', { invoke: (channel: string, ...args: unknown[]): Promise => { - if (!ALLOWED_SERVICE_CHANNELS.has(channel)) { + if (!isAllowedServiceChannel(channel)) { return Promise.reject(new Error(`IPC channel not allowed: ${channel}`)) } return ipcRenderer.invoke(channel, ...args) }, on: (channel: string, handler: (...args: unknown[]) => void): void => { - if (!ALLOWED_SERVICE_CHANNELS.has(channel)) { + if (!isAllowedServiceChannel(channel)) { console.warn(`IPC channel not allowed for subscription: ${channel}`) return } - ipcRenderer.on(channel, (_event, ...args) => handler(...args)) + + const listeners = getServiceChannelListeners(channel) + if (listeners.has(handler)) { + return + } + + const wrapped = (_event: unknown, ...args: unknown[]) => handler(...args) + listeners.set(handler, wrapped) + ipcRenderer.on(channel, wrapped as (...args: unknown[]) => void) }, off: (channel: string, handler: (...args: unknown[]) => void): void => { - ipcRenderer.removeListener(channel, handler) + if (!isAllowedServiceChannel(channel)) { + return + } + + const listeners = getServiceChannelListeners(channel) + const wrapped = listeners.get(handler) + if (!wrapped) { + return + } + + ipcRenderer.removeListener(channel, wrapped as (...args: unknown[]) => void) + listeners.delete(handler) + } +}) + +contextBridge.exposeInMainWorld('xnetOpenCode', { + ensure: (): Promise => ipcRenderer.invoke(OPENCODE_HOST_IPC_CHANNELS.ENSURE), + status: (): Promise => ipcRenderer.invoke(OPENCODE_HOST_IPC_CHANNELS.STATUS), + stop: (): Promise => ipcRenderer.invoke(OPENCODE_HOST_IPC_CHANNELS.STOP), + onStatusChange: (callback: (status: OpenCodeHostStatus) => void) => { + const handler = (_: unknown, status: OpenCodeHostStatus) => callback(status) + ipcRenderer.on( + OPENCODE_HOST_IPC_CHANNELS.STATUS_CHANGE, + handler as (...args: unknown[]) => void + ) + return () => + ipcRenderer.removeListener( + OPENCODE_HOST_IPC_CHANNELS.STATUS_CHANGE, + handler as (...args: unknown[]) => void + ) + }, + onOutput: (callback: (event: OpenCodeHostOutputEvent) => void) => { + const handler = (_: unknown, event: OpenCodeHostOutputEvent) => callback(event) + ipcRenderer.on(OPENCODE_HOST_IPC_CHANNELS.OUTPUT, handler as (...args: unknown[]) => void) + return () => + ipcRenderer.removeListener( + OPENCODE_HOST_IPC_CHANNELS.OUTPUT, + handler as (...args: unknown[]) => void + ) } }) @@ -450,6 +515,16 @@ export interface XNetServicesAPI { off(channel: string, handler: (...args: unknown[]) => void): void } +export type XNetServiceChannel = ServiceIpcChannel + +export interface XNetOpenCodeAPI { + ensure(): Promise + status(): Promise + stop(): Promise + onStatusChange(callback: (status: OpenCodeHostStatus) => void): () => void + onOutput(callback: (event: OpenCodeHostOutputEvent) => void): () => void +} + export interface XNetLocalAPIStatus { running: boolean port: number @@ -526,6 +601,7 @@ declare global { xnet: XNetAPI xnetBSM: XNetBSMAPI xnetServices: XNetServicesAPI + xnetOpenCode: XNetOpenCodeAPI xnetLocalAPI: XNetLocalAPIAPI xnetTunnel: XNetTunnelAPI xnetNodes: XNetNodesAPI diff --git a/apps/electron/src/renderer/index.html b/apps/electron/src/renderer/index.html index 14b6cf3f8..ead97c1d6 100644 --- a/apps/electron/src/renderer/index.html +++ b/apps/electron/src/renderer/index.html @@ -4,7 +4,7 @@ xNet diff --git a/apps/electron/src/shared/opencode-host.ts b/apps/electron/src/shared/opencode-host.ts new file mode 100644 index 000000000..d84c11b5e --- /dev/null +++ b/apps/electron/src/shared/opencode-host.ts @@ -0,0 +1,244 @@ +/** + * Shared OpenCode host configuration and status helpers. + */ + +import type { ServiceDefinition } from '@xnetjs/plugins' + +export const OPENCODE_SERVICE_ID = 'opencode-web' as const +export const DEFAULT_OPENCODE_HOST = '127.0.0.1' as const +export const DEFAULT_OPENCODE_PORT = 4096 +export const DEFAULT_OPENCODE_USERNAME = 'opencode' as const +export const OPENCODE_INSTALL_URL = 'https://opencode.ai/docs/install' as const + +export const OPENCODE_HOST_IPC_CHANNELS = { + ENSURE: 'xnet:opencode:ensure', + STATUS: 'xnet:opencode:status', + STOP: 'xnet:opencode:stop', + STATUS_CHANGE: 'xnet:opencode:status-change', + OUTPUT: 'xnet:opencode:output' +} as const + +export type OpenCodeHostConfig = { + host: string + port: number + baseUrl: string + username: string + password: string | null + binaryPathOverride: string | null +} + +export type OpenCodeHealthPayload = { + healthy?: boolean + version?: string +} + +type OpenCodeHostStatusBase = { + host: string + port: number + baseUrl: string + requiresAuth: boolean + binaryPath?: string +} + +export type OpenCodeHostStatus = + | (OpenCodeHostStatusBase & { + state: 'stopped' + }) + | (OpenCodeHostStatusBase & { + state: 'starting' + }) + | (OpenCodeHostStatusBase & { + state: 'ready' + pid?: number + startedAt?: number + version?: string + }) + | (OpenCodeHostStatusBase & { + state: 'missing-binary' + error: string + recovery: string + installUrl: string + }) + | (OpenCodeHostStatusBase & { + state: 'error' + error: string + recovery?: string + lastOutput?: string + pid?: number + }) + +export type OpenCodeBinaryResolution = + | { + found: true + path: string + source: 'override' | 'path' | 'common' + } + | { + found: false + checkedPaths: string[] + error: string + recovery: string + } + +export type OpenCodeHostOutputEvent = { + serviceId: typeof OPENCODE_SERVICE_ID + stream: 'stdout' | 'stderr' + data: string + timestamp: number +} + +const createStatusBase = ( + config: OpenCodeHostConfig, + binaryPath?: string +): OpenCodeHostStatusBase => ({ + host: config.host, + port: config.port, + baseUrl: config.baseUrl, + requiresAuth: Boolean(config.password), + ...(binaryPath ? { binaryPath } : {}) +}) + +export const parseOpenCodePort = (value: string | undefined): number => { + if (!value) { + return DEFAULT_OPENCODE_PORT + } + + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65_535) { + return DEFAULT_OPENCODE_PORT + } + + return parsed +} + +export const resolveOpenCodeBaseUrl = (host: string, port: number): string => + `http://${host}:${String(port)}` + +export const createOpenCodeHostConfig = ( + env: Readonly> +): OpenCodeHostConfig => { + const host = env.XNET_OPENCODE_HOST?.trim() || DEFAULT_OPENCODE_HOST + const port = parseOpenCodePort(env.XNET_OPENCODE_PORT) + return { + host, + port, + baseUrl: resolveOpenCodeBaseUrl(host, port), + username: env.XNET_OPENCODE_USERNAME?.trim() || DEFAULT_OPENCODE_USERNAME, + password: env.XNET_OPENCODE_PASSWORD?.trim() || null, + binaryPathOverride: env.XNET_OPENCODE_BINARY?.trim() || null + } +} + +export const createOpenCodeMissingBinaryRecovery = (binaryPathOverride: string | null): string => { + const installHint = + 'Install OpenCode from https://opencode.ai/docs/install. macOS: brew install sst/tap/opencode. Linux: curl -fsSL https://opencode.ai/install | bash. Windows: powershell -ExecutionPolicy Bypass -c "irm https://opencode.ai/install.ps1 | iex".' + + if (!binaryPathOverride) { + return installHint + } + + return `Check XNET_OPENCODE_BINARY (${binaryPathOverride}) or unset it so xNet can resolve the CLI from PATH. ${installHint}` +} + +export const createOpenCodeRuntimeRecovery = (config: OpenCodeHostConfig): string => + `Check that the OpenCode CLI can start in web mode and that port ${String(config.port)} is available. If another local instance is already using this port, stop it or set XNET_OPENCODE_PORT to a different value.` + +export const createOpenCodeStoppedStatus = ( + config: OpenCodeHostConfig, + binaryPath?: string +): OpenCodeHostStatus => ({ + state: 'stopped', + ...createStatusBase(config, binaryPath) +}) + +export const createOpenCodeStartingStatus = ( + config: OpenCodeHostConfig, + binaryPath?: string +): OpenCodeHostStatus => ({ + state: 'starting', + ...createStatusBase(config, binaryPath) +}) + +export const createOpenCodeReadyStatus = ( + config: OpenCodeHostConfig, + options: { + binaryPath?: string + pid?: number + startedAt?: number + version?: string + } = {} +): OpenCodeHostStatus => ({ + state: 'ready', + ...createStatusBase(config, options.binaryPath), + ...(options.pid ? { pid: options.pid } : {}), + ...(options.startedAt ? { startedAt: options.startedAt } : {}), + ...(options.version ? { version: options.version } : {}) +}) + +export const createOpenCodeMissingBinaryStatus = ( + config: OpenCodeHostConfig, + resolution: Extract +): OpenCodeHostStatus => ({ + state: 'missing-binary', + ...createStatusBase(config), + error: resolution.error, + recovery: resolution.recovery, + installUrl: OPENCODE_INSTALL_URL +}) + +export const createOpenCodeErrorStatus = ( + config: OpenCodeHostConfig, + options: { + binaryPath?: string + error: string + recovery?: string + lastOutput?: string + pid?: number + } +): OpenCodeHostStatus => ({ + state: 'error', + ...createStatusBase(config, options.binaryPath), + error: options.error, + ...(options.recovery ? { recovery: options.recovery } : {}), + ...(options.lastOutput ? { lastOutput: options.lastOutput } : {}), + ...(options.pid ? { pid: options.pid } : {}) +}) + +export const createOpenCodeServiceDefinition = ( + config: OpenCodeHostConfig & { binaryPath: string } +): ServiceDefinition => ({ + id: OPENCODE_SERVICE_ID, + name: 'OpenCode Web', + description: 'Managed local OpenCode web host for the coding workspace', + process: { + command: config.binaryPath, + args: ['web', '--hostname', config.host, '--port', String(config.port)], + env: { + BROWSER: 'none', + ...(config.password + ? { + OPENCODE_SERVER_USERNAME: config.username, + OPENCODE_SERVER_PASSWORD: config.password + } + : {}) + } + }, + lifecycle: { + restart: 'on-failure', + maxRestarts: 2, + restartDelayMs: 1000, + startTimeoutMs: 30_000, + shutdownTimeoutMs: 5000, + healthCheck: { + type: 'tcp', + port: config.port, + timeoutMs: 1000, + intervalMs: 5000 + } + }, + communication: { + protocol: 'http', + host: config.host, + port: config.port + } +}) diff --git a/apps/electron/src/shared/service-ipc.ts b/apps/electron/src/shared/service-ipc.ts new file mode 100644 index 000000000..5c1f03645 --- /dev/null +++ b/apps/electron/src/shared/service-ipc.ts @@ -0,0 +1,21 @@ +/** + * Shared Electron service IPC helpers. + */ + +export const SERVICE_IPC_CHANNELS = { + START: 'xnet:service:start', + STOP: 'xnet:service:stop', + RESTART: 'xnet:service:restart', + STATUS: 'xnet:service:status', + LIST_ALL: 'xnet:service:list-all', + CALL: 'xnet:service:call', + STATUS_UPDATE: 'xnet:service:status-update', + OUTPUT: 'xnet:service:output' +} as const + +export const ALLOWED_SERVICE_CHANNELS = new Set(Object.values(SERVICE_IPC_CHANNELS)) + +export const isAllowedServiceChannel = (channel: string): boolean => + ALLOWED_SERVICE_CHANNELS.has(channel) + +export type ServiceIpcChannel = (typeof SERVICE_IPC_CHANNELS)[keyof typeof SERVICE_IPC_CHANNELS] diff --git a/apps/electron/src/types/xnetjs-plugins-node.d.ts b/apps/electron/src/types/xnetjs-plugins-node.d.ts new file mode 100644 index 000000000..75f4d4208 --- /dev/null +++ b/apps/electron/src/types/xnetjs-plugins-node.d.ts @@ -0,0 +1,40 @@ +declare module '@xnetjs/plugins/node' { + export { + LocalAPIServer, + createLocalAPI + } from '../../../../packages/plugins/src/services/local-api' + export type { + LocalAPIConfig, + NodeChangeEventData, + NodeData, + NodeStoreAPI, + SchemaData, + SchemaRegistryAPI + } from '../../../../packages/plugins/src/services/local-api' + export { MCPServer, createMCPServer } from '../../../../packages/plugins/src/services/mcp-server' + export type { + MCPPropertySchema, + MCPRequest, + MCPResource, + MCPResponse, + MCPServerConfig, + MCPTool + } from '../../../../packages/plugins/src/services/mcp-server' + export { ProcessManager } from '../../../../packages/plugins/src/services/process-manager' + export { SERVICE_IPC_CHANNELS } from '../../../../packages/plugins/src/services/client' + export type { + IProcessManager, + ProcessManagerEvents, + ServiceClient, + ServiceCommunication, + ServiceDefinition, + ServiceHealthCheck, + ServiceLifecycle, + ServiceOutputEvent, + ServiceProcessConfig, + ServiceProvides, + ServiceState, + ServiceStatus, + ServiceStatusEvent + } from '../../../../packages/plugins/src/services/types' +} diff --git a/apps/electron/tsconfig.node.json b/apps/electron/tsconfig.node.json index 0862abc73..2e8c48015 100644 --- a/apps/electron/tsconfig.node.json +++ b/apps/electron/tsconfig.node.json @@ -11,6 +11,8 @@ "include": [ "src/main/**/*.ts", "src/preload/**/*.ts", + "src/shared/**/*.ts", + "src/types/**/*.d.ts", "electron.vite.config.ts" ] } diff --git a/apps/electron/tsconfig.web.json b/apps/electron/tsconfig.web.json index aaa337160..5948e2087 100644 --- a/apps/electron/tsconfig.web.json +++ b/apps/electron/tsconfig.web.json @@ -6,5 +6,11 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true }, - "include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/preload/index.ts"] + "include": [ + "src/renderer/**/*.ts", + "src/renderer/**/*.tsx", + "src/preload/index.ts", + "src/shared/**/*.ts", + "src/types/**/*.d.ts" + ] } diff --git a/apps/electron/vitest.config.ts b/apps/electron/vitest.config.ts index b69f2d5bc..61fd264ec 100644 --- a/apps/electron/vitest.config.ts +++ b/apps/electron/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ '@xnetjs/data': resolve(__dirname, '../../packages/data/src/index.ts'), '@xnetjs/devtools': resolve(__dirname, '../../packages/devtools/src/index.ts'), '@xnetjs/identity': resolve(__dirname, '../../packages/identity/src/index.ts'), + '@xnetjs/plugins/node': resolve(__dirname, '../../packages/plugins/src/services/node.ts'), '@xnetjs/react/internal': resolve(__dirname, '../../packages/react/src/internal.ts'), '@xnetjs/react': resolve(__dirname, '../../packages/react/src/index.ts'), '@xnetjs/ui': resolve(__dirname, '../../packages/ui/src/index.ts'), diff --git a/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md b/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md index 9d2564535..091a1aa18 100644 --- a/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md +++ b/docs/plans/plan03_9_83LLMCodingUI/01-service-boundary-and-opencode-host.md @@ -203,9 +203,9 @@ const openCodeService = { ## Step Checklist -- [ ] Replace hard-coded preload allowlist strings with shared service channel constants -- [ ] Expose the full service client contract to the renderer -- [ ] Add OpenCode CLI detection and friendly failure UX -- [ ] Add `OpenCodeService` lifecycle wrapper in Electron main -- [ ] Add CSP allowances for localhost OpenCode and preview surfaces +- [x] Replace hard-coded preload allowlist strings with shared service channel constants +- [x] Expose the full service client contract to the renderer +- [x] Add OpenCode CLI detection and friendly failure UX +- [x] Add `OpenCodeService` lifecycle wrapper in Electron main +- [x] Add CSP allowances for localhost OpenCode and preview surfaces - [ ] Validate clean startup/shutdown and duplicate-start protection diff --git a/docs/plans/plan03_9_83LLMCodingUI/README.md b/docs/plans/plan03_9_83LLMCodingUI/README.md index eab51217c..a1d655a6f 100644 --- a/docs/plans/plan03_9_83LLMCodingUI/README.md +++ b/docs/plans/plan03_9_83LLMCodingUI/README.md @@ -6,7 +6,7 @@ ## 1. Title and Short Summary -This plan turns the MVP exploration in [0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md](../../explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md) into an execution sequence for the repository. +This plan turns the MVP exploration in [0109*[*]\_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md](../../explorations/0109_[_]_ELECTRON_MVP_SELF_EDITING_WORKSPACE_CHEAP_PERFORMANCE_WINS_OPENCODE_WORKTREES_RIGHT_CLICK_CONTEXT.md) into an execution sequence for the repository. The target product is an Electron-native coding workspace with: @@ -141,8 +141,8 @@ flowchart LR ## 8. Implementation Checklist -- [ ] Fix the Electron service IPC / preload mismatch. -- [ ] Add OpenCode service lifecycle management in Electron main. +- [x] Fix the Electron service IPC / preload mismatch. +- [x] Add OpenCode service lifecycle management in Electron main. - [ ] Add xNet-backed `SessionSummary` storage and hooks. - [ ] Add a coding-workspace shell mode to the renderer. - [ ] Embed or host OpenCode Web in the center panel. From 1e08629927f0fe97400077c8dc5d6912e94583f6 Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 10:41:28 -0800 Subject: [PATCH 06/19] feat(electron): add workspace session shell state - add app-local xNet schemas for coding session summaries and shell selection state - add workspace hooks and bootstrap wiring for denormalized summaries and active-session restore - add renderer tests and update the LLM coding UI plan checklist for Step 02 --- apps/electron/src/renderer/main.tsx | 2 + .../workspace/WorkspaceStateBootstrap.tsx | 22 +++ .../workspace/hooks/useActiveSession.ts | 58 +++++++ .../workspace/hooks/useSessionCommands.ts | 116 +++++++++++++ .../workspace/hooks/useSessionSummaries.ts | 32 ++++ .../hooks/useWorkspaceSessions.test.tsx | 154 ++++++++++++++++++ .../src/renderer/workspace/schemas.ts | 41 +++++ .../workspace/state/active-session.ts | 133 +++++++++++++++ ...02-session-summary-and-fast-shell-state.md | 10 +- docs/plans/plan03_9_83LLMCodingUI/README.md | 4 +- 10 files changed, 565 insertions(+), 7 deletions(-) create mode 100644 apps/electron/src/renderer/workspace/WorkspaceStateBootstrap.tsx create mode 100644 apps/electron/src/renderer/workspace/hooks/useActiveSession.ts create mode 100644 apps/electron/src/renderer/workspace/hooks/useSessionCommands.ts create mode 100644 apps/electron/src/renderer/workspace/hooks/useSessionSummaries.ts create mode 100644 apps/electron/src/renderer/workspace/hooks/useWorkspaceSessions.test.tsx create mode 100644 apps/electron/src/renderer/workspace/schemas.ts create mode 100644 apps/electron/src/renderer/workspace/state/active-session.ts diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 1c2fc975e..ac7044de1 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -15,6 +15,7 @@ import { App } from './App' import { createIPCBlobStore } from './lib/ipc-blob-store' import { IPCNodeStorageAdapter } from './lib/ipc-node-storage' import { createIPCSyncManager, type IPCSyncManager } from './lib/ipc-sync-manager' +import { WorkspaceStateBootstrap } from './workspace/WorkspaceStateBootstrap' import './styles.css' type LocalAPIStoreNode = { @@ -265,6 +266,7 @@ async function init() { > + diff --git a/apps/electron/src/renderer/workspace/WorkspaceStateBootstrap.tsx b/apps/electron/src/renderer/workspace/WorkspaceStateBootstrap.tsx new file mode 100644 index 000000000..0160f0d12 --- /dev/null +++ b/apps/electron/src/renderer/workspace/WorkspaceStateBootstrap.tsx @@ -0,0 +1,22 @@ +/** + * Ensures the workspace shell state exists before the coding UI mounts. + */ + +import React, { useEffect } from 'react' +import { useActiveSessionId } from './hooks/useActiveSession' +import { useSessionCommands } from './hooks/useSessionCommands' + +export function WorkspaceStateBootstrap(): React.ReactElement | null { + const { shellState, loading } = useActiveSessionId() + const { ensureWorkspaceShellState } = useSessionCommands() + + useEffect(() => { + if (loading || shellState) { + return + } + + void ensureWorkspaceShellState() + }, [ensureWorkspaceShellState, loading, shellState]) + + return null +} diff --git a/apps/electron/src/renderer/workspace/hooks/useActiveSession.ts b/apps/electron/src/renderer/workspace/hooks/useActiveSession.ts new file mode 100644 index 000000000..8291ddae7 --- /dev/null +++ b/apps/electron/src/renderer/workspace/hooks/useActiveSession.ts @@ -0,0 +1,58 @@ +/** + * Hooks for the active coding workspace session. + */ + +import { useQuery } from '@xnetjs/react' +import { useCallback, useMemo } from 'react' +import { SessionSummarySchema, WorkspaceShellStateSchema } from '../schemas' +import { + orderSessionSummaries, + SESSION_SUMMARY_QUERY, + WORKSPACE_SHELL_STATE_NODE_ID +} from '../state/active-session' + +export function useActiveSessionId() { + const query = useQuery(WorkspaceShellStateSchema, WORKSPACE_SHELL_STATE_NODE_ID) + + return { + shellState: query.data, + activeSessionId: query.data?.activeSession ?? null, + loading: query.loading, + error: query.error, + reload: query.reload + } +} + +export function useActiveSession() { + const shellStateQuery = useActiveSessionId() + const sessionSummaryQuery = useQuery(SessionSummarySchema, SESSION_SUMMARY_QUERY) + + const activeSession = useMemo(() => { + if (!shellStateQuery.activeSessionId) { + return null + } + + return ( + sessionSummaryQuery.data.find((session) => session.id === shellStateQuery.activeSessionId) ?? + null + ) + }, [sessionSummaryQuery.data, shellStateQuery.activeSessionId]) + + const orderedSessions = useMemo( + () => orderSessionSummaries(sessionSummaryQuery.data, shellStateQuery.activeSessionId), + [sessionSummaryQuery.data, shellStateQuery.activeSessionId] + ) + + const reload = useCallback(async () => { + await Promise.all([shellStateQuery.reload(), sessionSummaryQuery.reload()]) + }, [sessionSummaryQuery, shellStateQuery]) + + return { + ...shellStateQuery, + activeSession, + summaries: orderedSessions, + summariesLoading: sessionSummaryQuery.loading, + summariesError: sessionSummaryQuery.error, + reload + } +} diff --git a/apps/electron/src/renderer/workspace/hooks/useSessionCommands.ts b/apps/electron/src/renderer/workspace/hooks/useSessionCommands.ts new file mode 100644 index 000000000..f2521b7cb --- /dev/null +++ b/apps/electron/src/renderer/workspace/hooks/useSessionCommands.ts @@ -0,0 +1,116 @@ +/** + * Mutations for workspace session summaries and active-session state. + */ + +import type { + SessionSummaryNode, + WorkspaceShellStateNode, + createSessionSummaryInput, + createSessionSummaryPatch, + createWorkspaceShellStateInput, + type SessionSummaryInput, + WORKSPACE_SHELL_STATE_NODE_ID +} from '../state/active-session' +import { useMutate, useQuery } from '@xnetjs/react' +import { useCallback } from 'react' +import { SessionSummarySchema, WorkspaceShellStateSchema } from '../schemas' + +type CreateSessionOptions = { + id?: string + select?: boolean +} + +let pendingShellStateCreation: Promise | null = null + +export function useSessionCommands() { + const { create, update, remove } = useMutate() + const shellStateQuery = useQuery(WorkspaceShellStateSchema, WORKSPACE_SHELL_STATE_NODE_ID) + + const ensureWorkspaceShellState = + useCallback(async (): Promise => { + if (shellStateQuery.data) { + return shellStateQuery.data + } + + if (pendingShellStateCreation) { + return pendingShellStateCreation + } + + pendingShellStateCreation = create( + WorkspaceShellStateSchema, + createWorkspaceShellStateInput(), + WORKSPACE_SHELL_STATE_NODE_ID + ).finally(() => { + pendingShellStateCreation = null + }) + + return pendingShellStateCreation + }, [create, shellStateQuery.data]) + + const selectSession = useCallback( + async (sessionId: string | null): Promise => { + const shellState = await ensureWorkspaceShellState() + if (!shellState) { + return null + } + + return update(WorkspaceShellStateSchema, shellState.id, { + activeSession: sessionId ?? undefined + }) + }, + [ensureWorkspaceShellState, update] + ) + + const createSessionSummary = useCallback( + async ( + input: SessionSummaryInput, + options: CreateSessionOptions = {} + ): Promise => { + const session = await create( + SessionSummarySchema, + createSessionSummaryInput(input), + options.id + ) + + if (!session) { + return null + } + + if (options.select ?? true) { + await selectSession(session.id) + } + + return session + }, + [create, selectSession] + ) + + const updateSessionSummary = useCallback( + async ( + sessionId: string, + patch: Partial + ): Promise => { + return update(SessionSummarySchema, sessionId, createSessionSummaryPatch(patch)) + }, + [update] + ) + + const removeSessionSummary = useCallback( + async (sessionId: string): Promise => { + if (shellStateQuery.data?.activeSession === sessionId) { + await selectSession(null) + } + + await remove(sessionId) + }, + [remove, selectSession, shellStateQuery.data?.activeSession] + ) + + return { + ensureWorkspaceShellState, + createSessionSummary, + updateSessionSummary, + removeSessionSummary, + selectSession + } +} diff --git a/apps/electron/src/renderer/workspace/hooks/useSessionSummaries.ts b/apps/electron/src/renderer/workspace/hooks/useSessionSummaries.ts new file mode 100644 index 000000000..3219f6b9c --- /dev/null +++ b/apps/electron/src/renderer/workspace/hooks/useSessionSummaries.ts @@ -0,0 +1,32 @@ +/** + * Hook for reading denormalized session summaries for the workspace rail. + */ + +import { useQuery } from '@xnetjs/react' +import { useCallback, useMemo } from 'react' +import { SessionSummarySchema } from '../schemas' +import { orderSessionSummaries, SESSION_SUMMARY_QUERY } from '../state/active-session' +import { useActiveSessionId } from './useActiveSession' + +export function useSessionSummaries() { + const shellStateQuery = useActiveSessionId() + const sessionSummaryQuery = useQuery(SessionSummarySchema, SESSION_SUMMARY_QUERY) + + const data = useMemo( + () => orderSessionSummaries(sessionSummaryQuery.data, shellStateQuery.activeSessionId), + [sessionSummaryQuery.data, shellStateQuery.activeSessionId] + ) + + const reload = useCallback(async () => { + await Promise.all([shellStateQuery.reload(), sessionSummaryQuery.reload()]) + }, [sessionSummaryQuery, shellStateQuery]) + + return { + data, + activeSessionId: shellStateQuery.activeSessionId, + shellState: shellStateQuery.shellState, + loading: shellStateQuery.loading || sessionSummaryQuery.loading, + error: shellStateQuery.error ?? sessionSummaryQuery.error, + reload + } +} diff --git a/apps/electron/src/renderer/workspace/hooks/useWorkspaceSessions.test.tsx b/apps/electron/src/renderer/workspace/hooks/useWorkspaceSessions.test.tsx new file mode 100644 index 000000000..c9a3fa14d --- /dev/null +++ b/apps/electron/src/renderer/workspace/hooks/useWorkspaceSessions.test.tsx @@ -0,0 +1,154 @@ +/** + * @vitest-environment jsdom + */ + +import type { SessionSummaryNode } from '../state/active-session' +import { renderHook, act, waitFor } from '@testing-library/react' +import { MemoryNodeStorageAdapter } from '@xnetjs/data' +import { generateIdentity } from '@xnetjs/identity' +import { XNetProvider } from '@xnetjs/react' +import React, { type ReactNode, useMemo } from 'react' +import { describe, expect, it } from 'vitest' +import { useActiveSession } from './useActiveSession' +import { useSessionCommands } from './useSessionCommands' +import { useSessionSummaries } from './useSessionSummaries' + +type WorkspaceHooks = { + active: ReturnType + commands: ReturnType + summaries: ReturnType +} + +function createWrapper(storage: MemoryNodeStorageAdapter) { + const identity = generateIdentity() + + return function Wrapper({ children }: { children: ReactNode }) { + const stableStorage = useMemo(() => storage, []) + + return ( + + {children} + + ) + } +} + +function renderWorkspaceHooks(storage = new MemoryNodeStorageAdapter()) { + return renderHook( + () => ({ + active: useActiveSession(), + commands: useSessionCommands(), + summaries: useSessionSummaries() + }), + { + wrapper: createWrapper(storage) + } + ) +} + +async function waitForWorkspaceReady(result: { current: WorkspaceHooks }) { + await waitFor(() => { + expect(result.current.active.loading).toBe(false) + expect(result.current.active.summariesLoading).toBe(false) + }) +} + +function getSessionTitles(summaries: readonly SessionSummaryNode[]): string[] { + return summaries.map((session) => session.title ?? '') +} + +describe('workspace session hooks', () => { + it('orders denormalized session summaries with the active session first', async () => { + const { result } = renderWorkspaceHooks() + + await waitForWorkspaceReady(result) + + await act(async () => { + await result.current.commands.ensureWorkspaceShellState() + }) + + let alphaId = '' + + await act(async () => { + const alpha = await result.current.commands.createSessionSummary( + { + title: 'Alpha', + branch: 'codex/alpha', + worktreePath: '/tmp/worktrees/alpha', + openCodeUrl: 'http://127.0.0.1:4096', + changedFilesCount: 2 + }, + { select: false } + ) + + alphaId = alpha?.id ?? '' + }) + + await act(async () => { + await result.current.commands.createSessionSummary({ + title: 'Beta', + branch: 'codex/beta', + worktreePath: '/tmp/worktrees/beta', + openCodeUrl: 'http://127.0.0.1:4096', + changedFilesCount: 4, + lastMessagePreview: 'Preview the new shell layout' + }) + }) + + expect(getSessionTitles(result.current.summaries.data)).toEqual(['Beta', 'Alpha']) + expect(result.current.active.activeSession?.title).toBe('Beta') + + await act(async () => { + await result.current.commands.selectSession(alphaId) + }) + + expect(result.current.summaries.activeSessionId).toBe(alphaId) + expect(getSessionTitles(result.current.summaries.data)).toEqual(['Alpha', 'Beta']) + expect(result.current.active.activeSession?.title).toBe('Alpha') + }) + + it('restores session summaries and active selection from local xNet state after remount', async () => { + const storage = new MemoryNodeStorageAdapter() + const initialRender = renderWorkspaceHooks(storage) + + await waitForWorkspaceReady(initialRender.result) + + let restoredId = '' + + await act(async () => { + await initialRender.result.current.commands.ensureWorkspaceShellState() + const session = await initialRender.result.current.commands.createSessionSummary({ + title: 'Restored session', + branch: 'codex/restored-session', + worktreePath: '/tmp/worktrees/restored-session', + openCodeUrl: 'http://127.0.0.1:4096', + previewUrl: 'http://127.0.0.1:5173' + }) + + restoredId = session?.id ?? '' + }) + + expect(initialRender.result.current.active.activeSession?.id).toBe(restoredId) + + initialRender.unmount() + + const remounted = renderWorkspaceHooks(storage) + await waitForWorkspaceReady(remounted.result) + + expect(remounted.result.current.summaries.data).toHaveLength(1) + expect(remounted.result.current.summaries.data[0]?.title).toBe('Restored session') + expect(remounted.result.current.summaries.activeSessionId).toBe(restoredId) + expect(remounted.result.current.active.activeSession?.id).toBe(restoredId) + }) +}) diff --git a/apps/electron/src/renderer/workspace/schemas.ts b/apps/electron/src/renderer/workspace/schemas.ts new file mode 100644 index 000000000..2adcfa83f --- /dev/null +++ b/apps/electron/src/renderer/workspace/schemas.ts @@ -0,0 +1,41 @@ +/** + * App-local schemas for the Electron coding workspace shell. + */ + +import { defineSchema, number, relation, select, text } from '@xnetjs/data' + +export const SESSION_SUMMARY_STATE_OPTIONS = [ + { id: 'idle', name: 'Idle' }, + { id: 'running', name: 'Running' }, + { id: 'previewing', name: 'Previewing' }, + { id: 'error', name: 'Error' } +] as const + +export const SessionSummarySchema = defineSchema({ + name: 'WorkspaceSessionSummary', + namespace: 'xnet://xnet.dev/electron/workspace/', + properties: { + title: text({ required: true }), + branch: text({ required: true }), + worktreeName: text({ required: true }), + worktreePath: text({ required: true }), + openCodeUrl: text({ required: true }), + previewUrl: text(), + lastMessagePreview: text(), + lastScreenshotPath: text(), + changedFilesCount: number({ integer: true, min: 0 }), + state: select({ + options: SESSION_SUMMARY_STATE_OPTIONS, + default: 'idle' + }), + modelId: text() + } +}) + +export const WorkspaceShellStateSchema = defineSchema({ + name: 'WorkspaceShellState', + namespace: 'xnet://xnet.dev/electron/workspace/', + properties: { + activeSession: relation({ target: SessionSummarySchema._schemaId }) + } +}) diff --git a/apps/electron/src/renderer/workspace/state/active-session.ts b/apps/electron/src/renderer/workspace/state/active-session.ts new file mode 100644 index 000000000..df2c3ea44 --- /dev/null +++ b/apps/electron/src/renderer/workspace/state/active-session.ts @@ -0,0 +1,133 @@ +/** + * Shared state helpers for workspace session selection. + */ + +import type { InferCreateProps } from '@xnetjs/data' +import type { FlatNode } from '@xnetjs/react' +import { SessionSummarySchema, WorkspaceShellStateSchema } from '../schemas' + +export const WORKSPACE_SHELL_STATE_NODE_ID = 'xnet:electron:workspace-shell-state' + +export const SESSION_SUMMARY_QUERY = Object.freeze({ + limit: 200, + orderBy: { updatedAt: 'desc' as const } +}) + +export type SessionSummaryNode = FlatNode<(typeof SessionSummarySchema)['_properties']> +export type WorkspaceShellStateNode = FlatNode<(typeof WorkspaceShellStateSchema)['_properties']> +export type SessionSummaryState = SessionSummaryNode['state'] + +export type SessionSummaryInput = { + title: string + branch: string + worktreeName?: string + worktreePath: string + openCodeUrl: string + previewUrl?: string | null + lastMessagePreview?: string | null + lastScreenshotPath?: string | null + changedFilesCount?: number + state?: SessionSummaryState + modelId?: string | null +} + +export function createWorkspaceShellStateInput( + activeSessionId: string | null = null +): InferCreateProps<(typeof WorkspaceShellStateSchema)['_properties']> { + return activeSessionId ? { activeSession: activeSessionId } : {} +} + +export function createSessionSummaryInput( + input: SessionSummaryInput +): InferCreateProps<(typeof SessionSummarySchema)['_properties']> { + return { + title: input.title.trim(), + branch: input.branch.trim(), + worktreeName: (input.worktreeName ?? input.title).trim(), + worktreePath: input.worktreePath.trim(), + openCodeUrl: input.openCodeUrl.trim(), + previewUrl: input.previewUrl?.trim() || undefined, + lastMessagePreview: input.lastMessagePreview?.trim() || undefined, + lastScreenshotPath: input.lastScreenshotPath?.trim() || undefined, + changedFilesCount: Math.max(0, Math.trunc(input.changedFilesCount ?? 0)), + state: input.state ?? 'idle', + modelId: input.modelId?.trim() || undefined + } +} + +export function createSessionSummaryPatch( + patch: Partial +): Partial> { + const next: Partial> = {} + + if (patch.title !== undefined) { + next.title = patch.title.trim() + } + + if (patch.branch !== undefined) { + next.branch = patch.branch.trim() + } + + if (patch.worktreeName !== undefined) { + next.worktreeName = patch.worktreeName.trim() + } + + if (patch.worktreePath !== undefined) { + next.worktreePath = patch.worktreePath.trim() + } + + if (patch.openCodeUrl !== undefined) { + next.openCodeUrl = patch.openCodeUrl.trim() + } + + if (patch.previewUrl !== undefined) { + next.previewUrl = patch.previewUrl?.trim() || undefined + } + + if (patch.lastMessagePreview !== undefined) { + next.lastMessagePreview = patch.lastMessagePreview?.trim() || undefined + } + + if (patch.lastScreenshotPath !== undefined) { + next.lastScreenshotPath = patch.lastScreenshotPath?.trim() || undefined + } + + if (patch.changedFilesCount !== undefined) { + next.changedFilesCount = Math.max(0, Math.trunc(patch.changedFilesCount)) + } + + if (patch.state !== undefined) { + next.state = patch.state + } + + if (patch.modelId !== undefined) { + next.modelId = patch.modelId?.trim() || undefined + } + + return next +} + +export function getSessionActivityAt(session: SessionSummaryNode): number { + return session.updatedAt || session.createdAt || 0 +} + +export function orderSessionSummaries( + sessions: readonly T[], + activeSessionId: string | null +): T[] { + return [...sessions].sort((left, right) => { + const leftActive = left.id === activeSessionId + const rightActive = right.id === activeSessionId + + if (leftActive !== rightActive) { + return leftActive ? -1 : 1 + } + + const activityDelta = getSessionActivityAt(right) - getSessionActivityAt(left) + if (activityDelta !== 0) { + return activityDelta + } + + return (left.title ?? '').localeCompare(right.title ?? '') + }) +} diff --git a/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md b/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md index 032b0d618..cb33318cb 100644 --- a/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md +++ b/docs/plans/plan03_9_83LLMCodingUI/02-session-summary-and-fast-shell-state.md @@ -211,8 +211,8 @@ export function useSessionCommands() { ## Step Checklist -- [ ] Add app-local session summary schema(s) -- [ ] Add app-local hooks for session summaries and commands -- [ ] Use denormalized summary records for rail and shell state -- [ ] Keep token streaming out of xNet’s hot path -- [ ] Validate instant local restoration of session summaries +- [x] Add app-local session summary schema(s) +- [x] Add app-local hooks for session summaries and commands +- [x] Use denormalized summary records for rail and shell state +- [x] Keep token streaming out of xNet’s hot path +- [x] Validate instant local restoration of session summaries diff --git a/docs/plans/plan03_9_83LLMCodingUI/README.md b/docs/plans/plan03_9_83LLMCodingUI/README.md index a1d655a6f..aa5ac5b2e 100644 --- a/docs/plans/plan03_9_83LLMCodingUI/README.md +++ b/docs/plans/plan03_9_83LLMCodingUI/README.md @@ -143,7 +143,7 @@ flowchart LR - [x] Fix the Electron service IPC / preload mismatch. - [x] Add OpenCode service lifecycle management in Electron main. -- [ ] Add xNet-backed `SessionSummary` storage and hooks. +- [x] Add xNet-backed `SessionSummary` storage and hooks. - [ ] Add a coding-workspace shell mode to the renderer. - [ ] Embed or host OpenCode Web in the center panel. - [ ] Add `GitService` worktree lifecycle commands. @@ -155,7 +155,7 @@ flowchart LR ## 9. Validation Checklist -- [ ] Session switching restores from local xNet-backed state without a network round trip. +- [x] Session switching restores from local xNet-backed state without a network round trip. - [ ] OpenCode stays usable without building a custom chat UI. - [ ] Two sessions can target separate worktrees without clobbering each other. - [ ] Preview switching shows an immediate cached state before the live runtime is ready. From 46bc8b9578f409199a68500e6cdbba30c87e64ad Mon Sep 17 00:00:00 2001 From: crs48 Date: Sat, 7 Mar 2026 10:49:03 -0800 Subject: [PATCH 07/19] feat(electron): add coding workspace shell - add a three-panel coding workspace mode with a session rail and OpenCode center panel - wire the canvas shell menu and command palette into the workspace entry point - add renderer tests and update the Step 03 plan checklist progress --- apps/electron/src/renderer/App.tsx | 57 ++++ .../src/renderer/components/SystemMenu.tsx | 12 +- .../renderer/workspace/DevWorkspaceShell.tsx | 130 +++++++++ .../src/renderer/workspace/OpenCodePanel.tsx | 247 ++++++++++++++++++ .../renderer/workspace/PreviewWorkspace.tsx | 238 +++++++++++++++++ .../renderer/workspace/SessionRail.test.tsx | 89 +++++++ .../src/renderer/workspace/SessionRail.tsx | 194 ++++++++++++++ .../workspace/hooks/useSessionCommands.ts | 10 +- .../03-shell-layout-and-opencode-panel.md | 10 +- docs/plans/plan03_9_83LLMCodingUI/README.md | 6 +- 10 files changed, 980 insertions(+), 13 deletions(-) create mode 100644 apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx create mode 100644 apps/electron/src/renderer/workspace/OpenCodePanel.tsx create mode 100644 apps/electron/src/renderer/workspace/PreviewWorkspace.tsx create mode 100644 apps/electron/src/renderer/workspace/SessionRail.test.tsx create mode 100644 apps/electron/src/renderer/workspace/SessionRail.tsx diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index 584917d3d..75e54164c 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -16,6 +16,7 @@ import { DatabaseView } from './components/DatabaseView' import { PageView } from './components/PageView' import { SettingsView } from './components/SettingsView' import { SystemMenu } from './components/SystemMenu' +import { DevWorkspaceShell } from './workspace/DevWorkspaceShell' type DocType = 'page' | 'database' | 'canvas' @@ -39,6 +40,8 @@ type DocumentItem = { updatedAt?: number } +type AppMode = 'canvas' | 'coding-workspace' + const OVERLAY_OPEN_DELAY_MS = 180 function toError(error: unknown): Error { @@ -46,6 +49,7 @@ function toError(error: unknown): Error { } export function App(): React.ReactElement { + const [appMode, setAppMode] = useState('canvas') const [homeCanvasId, setHomeCanvasId] = useState(null) const [homeCanvasBootstrapError, setHomeCanvasBootstrapError] = useState(null) const [shellState, setShellState] = useState({ kind: 'canvas-home' }) @@ -307,6 +311,27 @@ export function App(): React.ReactElement { setShellState({ kind: 'settings' }) }, [clearTransitionTimer]) + const handleOpenCodingWorkspace = useCallback(() => { + clearTransitionTimer() + setShowAddSharedDialog(false) + setPrefilledShareValue('') + setShellState({ kind: 'canvas-home' }) + setAppMode('coding-workspace') + }, [clearTransitionTimer]) + + const handleReturnToCanvasWorkspace = useCallback(() => { + clearTransitionTimer() + setAppMode('canvas') + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(homeCanvasId) + }, [clearTransitionTimer, homeCanvasId, setActiveNodeId]) + + const handleOpenSettingsFromWorkspace = useCallback(() => { + clearTransitionTimer() + setAppMode('canvas') + setShellState({ kind: 'settings' }) + }, [clearTransitionTimer]) + const paletteCommands = useMemo( () => [ { @@ -337,6 +362,13 @@ export function App(): React.ReactElement { icon: 'settings', execute: handleOpenSettings }, + { + id: 'open-coding-workspace', + name: 'Open Coding Workspace', + description: 'Switch to the three-panel coding shell', + icon: 'code', + execute: handleOpenCodingWorkspace + }, ...recentDocuments.map((document) => ({ id: `open-${document.id}`, name: document.title, @@ -354,6 +386,7 @@ export function App(): React.ReactElement { [ handleCreateCanvasNote, handleCreateLinkedDocument, + handleOpenCodingWorkspace, handleOpenDocument, handleOpenSettings, recentDocuments @@ -429,6 +462,29 @@ export function App(): React.ReactElement { ) } + if (appMode === 'coding-workspace') { + return ( + <> + + + { + setShowAddSharedDialog(false) + setPrefilledShareValue('') + }} + onAdd={handleAddShared} + initialValue={prefilledShareValue} + /> + + + + ) + } + return (
@@ -438,6 +494,7 @@ export function App(): React.ReactElement { recentDocuments={recentDocuments} onOpenDocument={handleOpenDocument} onOpenSettings={handleOpenSettings} + onOpenCodingWorkspace={handleOpenCodingWorkspace} onAddShared={() => { setPrefilledShareValue('') setShowAddSharedDialog(true) diff --git a/apps/electron/src/renderer/components/SystemMenu.tsx b/apps/electron/src/renderer/components/SystemMenu.tsx index cc1dfc422..d85888e81 100644 --- a/apps/electron/src/renderer/components/SystemMenu.tsx +++ b/apps/electron/src/renderer/components/SystemMenu.tsx @@ -4,7 +4,7 @@ import type { Theme } from '@xnetjs/ui' import { Menu, MenuItem, MenuLabel, MenuSeparator, useTheme } from '@xnetjs/ui' -import { Bug, Check, Ellipsis, Monitor, Moon, Settings, Share2, Sun } from 'lucide-react' +import { Bug, Check, Code2, Ellipsis, Monitor, Moon, Settings, Share2, Sun } from 'lucide-react' import React from 'react' interface RecentDocument { @@ -17,6 +17,7 @@ interface SystemMenuProps { recentDocuments: RecentDocument[] onOpenDocument: (docId: string) => void onOpenSettings: () => void + onOpenCodingWorkspace?: () => void onAddShared: () => void onToggleDebugPanel: () => void } @@ -58,6 +59,7 @@ export function SystemMenu({ recentDocuments, onOpenDocument, onOpenSettings, + onOpenCodingWorkspace, onAddShared, onToggleDebugPanel }: SystemMenuProps): React.ReactElement { @@ -83,6 +85,14 @@ export function SystemMenu({ className="min-w-[240px]" > Workspace + {onOpenCodingWorkspace ? ( + + + + Coding workspace + + + ) : null} diff --git a/apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx b/apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx new file mode 100644 index 000000000..2a8e9ab79 --- /dev/null +++ b/apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx @@ -0,0 +1,130 @@ +/** + * Three-panel coding workspace shell for the Electron app. + */ + +import type { SessionSummaryInput } from './state/active-session' +import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@xnetjs/ui' +import { ArrowLeft, Code2, RefreshCcw, Settings } from 'lucide-react' +import React, { useCallback } from 'react' +import { useActiveSession } from './hooks/useActiveSession' +import { useSessionCommands } from './hooks/useSessionCommands' +import { OpenCodePanel } from './OpenCodePanel' +import { PreviewWorkspace } from './PreviewWorkspace' +import { SessionRail } from './SessionRail' + +type DevWorkspaceShellProps = { + onReturnToCanvas: () => void + onOpenSettings: () => void +} + +function createPlaceholderSessionInput(index: number): SessionSummaryInput { + const paddedIndex = String(index).padStart(2, '0') + const slug = `workspace-session-${paddedIndex}` + + return { + title: `Workspace Session ${paddedIndex}`, + branch: `codex/${slug}`, + worktreeName: slug, + worktreePath: `.xnet/worktrees/${slug}`, + openCodeUrl: 'http://127.0.0.1:4096', + lastMessagePreview: 'Describe the layout or interaction you want to change.', + changedFilesCount: 0, + state: 'idle' + } +} + +export function DevWorkspaceShell({ + onReturnToCanvas, + onOpenSettings +}: DevWorkspaceShellProps): React.ReactElement { + const { activeSession, activeSessionId, summaries, summariesLoading, summariesError, reload } = + useActiveSession() + const { createSessionSummary, selectSession } = useSessionCommands() + + const handleCreateSession = useCallback(async () => { + const nextIndex = summaries.length + 1 + await createSessionSummary(createPlaceholderSessionInput(nextIndex)) + }, [createSessionSummary, summaries.length]) + + return ( +
+
+ +
+
+
+
+
+ + Coding Workspace +
+ {activeSession ? ( +
+ {activeSession.title ?? 'Untitled session'} + / + {activeSession.branch ?? 'no-branch'} +
+ ) : null} +
+ +
+ {String(summaries.length)} sessions + + + +
+
+
+ +
+ + + { + void handleCreateSession() + }} + onSelectSession={(sessionId) => { + void selectSession(sessionId) + }} + /> + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/electron/src/renderer/workspace/OpenCodePanel.tsx b/apps/electron/src/renderer/workspace/OpenCodePanel.tsx new file mode 100644 index 000000000..fe69d02c1 --- /dev/null +++ b/apps/electron/src/renderer/workspace/OpenCodePanel.tsx @@ -0,0 +1,247 @@ +/** + * Center-panel host for the local OpenCode web UI. + */ + +import type { SessionSummaryNode } from './state/active-session' +import type { OpenCodeHostOutputEvent, OpenCodeHostStatus } from '../../shared/opencode-host' +import { Badge, Button } from '@xnetjs/ui' +import { Code2, LoaderCircle, RefreshCcw, SquareTerminal } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { OPENCODE_INSTALL_URL } from '../../shared/opencode-host' + +type OpenCodePanelProps = { + activeSession: SessionSummaryNode | null +} + +function badgeVariantForStatus(state: OpenCodeHostStatus['state']) { + switch (state) { + case 'ready': + return 'success' + case 'starting': + return 'secondary' + case 'missing-binary': + case 'error': + return 'destructive' + default: + return 'outline' + } +} + +function getLastOutputLine(event: OpenCodeHostOutputEvent): string { + const trimmed = event.data.trim() + if (!trimmed) { + return '' + } + + const segments = trimmed + .split('\n') + .map((part) => part.trim()) + .filter(Boolean) + return segments.at(-1) ?? trimmed +} + +export function OpenCodePanel({ activeSession }: OpenCodePanelProps): React.ReactElement { + const [status, setStatus] = useState(null) + const [lastOutput, setLastOutput] = useState('') + const [busy, setBusy] = useState(false) + + const ensureHost = useCallback(async () => { + setBusy(true) + + try { + const nextStatus = await window.xnetOpenCode.ensure() + setStatus(nextStatus) + return nextStatus + } finally { + setBusy(false) + } + }, []) + + useEffect(() => { + let active = true + + void window.xnetOpenCode.status().then((nextStatus) => { + if (!active) { + return + } + + setStatus(nextStatus) + }) + + void ensureHost() + + const stopStatus = window.xnetOpenCode.onStatusChange((nextStatus) => { + if (!active) { + return + } + + setStatus(nextStatus) + }) + + const stopOutput = window.xnetOpenCode.onOutput((event) => { + if (!active) { + return + } + + const nextLine = getLastOutputLine(event) + if (nextLine) { + setLastOutput(nextLine) + } + }) + + return () => { + active = false + stopStatus() + stopOutput() + } + }, [ensureHost]) + + const chrome = useMemo(() => { + if (!status) { + return { + heading: 'Starting OpenCode', + body: 'Preparing the local OpenCode web host for the center panel.' + } + } + + if (status.state === 'ready') { + return { + heading: activeSession?.title ?? 'OpenCode is ready', + body: activeSession + ? `${activeSession.branch ?? 'no-branch'} · ${activeSession.worktreePath ?? 'pending worktree'}` + : 'Select a session from the rail to tie this chat surface to a worktree.' + } + } + + if (status.state === 'missing-binary') { + return { + heading: 'Install OpenCode to continue', + body: status.error + } + } + + if (status.state === 'error') { + return { + heading: 'OpenCode host needs attention', + body: status.error + } + } + + return { + heading: 'Preparing OpenCode', + body: lastOutput || 'Waiting for the local host to become ready.' + } + }, [activeSession, lastOutput, status]) + + return ( +
+
+
+
+ + OpenCode +
+

{chrome.heading}

+

{chrome.body}

+
+ +
+ {status ? ( + + {status.state} + + ) : null} + + +
+
+ +
+ {!status || status.state === 'starting' || status.state === 'stopped' ? ( +
+ +
+

Booting the local coding agent

+

+ {lastOutput || + 'OpenCode Web will stay mounted here so session switching does not thrash the chat UI.'} +

+
+
+ ) : status.state === 'missing-binary' ? ( +
+ OpenCode missing +
+

{status.error}

+

{status.recovery}

+
+
+ + +
+
+ ) : status.state === 'error' ? ( +
+ Host error +
+

{status.error}

+ {status.recovery ? ( +

+ {status.recovery} +

+ ) : null} + {status.lastOutput || lastOutput ? ( +
+                  {status.lastOutput || lastOutput}
+                
+ ) : null} +
+ +
+ ) : ( +
+
+
+

+ {activeSession?.title ?? 'Shared chat surface'} +

+

+ {status.baseUrl} + {status.requiresAuth ? ' · local auth enabled' : ' · no local auth'} +

+
+ {lastOutput ? ( + {lastOutput} + ) : null} +
+