diff --git a/apps/electron/README.md b/apps/electron/README.md index 7dd3b55b..a2601a02 100644 --- a/apps/electron/README.md +++ b/apps/electron/README.md @@ -7,6 +7,8 @@ Electron desktop app for macOS, Windows, and Linux -- the primary development ta ```bash pnpm dev # Start hub + app concurrently pnpm dev:both # Two instances for sync testing +pnpm run deps:node # Rebuild native deps for plain Node tests +pnpm run deps:electron # Rebuild native deps for Electron runtime ``` ## Build @@ -48,3 +50,26 @@ Dev server runs at `http://localhost:5177`. Connect with Playwright MCP for auto # Enable sync debug logs in the browser console localStorage.setItem('xnet:sync:debug', 'true') ``` + +## Coding Workspace Shell + +The Electron coding workspace shell is the dogfood target for the self-editing MVP. + +- Left rail: xNet-backed session summaries, dirty-state badges, and worktree selection +- Center panel: shared OpenCode Web host +- Right panel: preview, diff, files, markdown, screenshots, and PR draft flows + +### Local dependencies + +- `git` on PATH for worktrees, diffs, and cleanup +- `pnpm` on PATH for preview runtimes +- `gh` on PATH plus `gh auth login` for PR creation +- `opencode` on PATH, or `XNET_OPENCODE_BINARY=/absolute/path/to/opencode` + +### Recovery flows + +- OpenCode missing: install from [OpenCode docs](https://opencode.ai/docs/install), then refresh the center panel +- Preview startup failure: run `pnpm install`, then `pnpm run deps:electron`, then restart the preview from the right panel +- If `pnpm run deps:electron` fails inside `@electron/rebuild` with `util.styleText is not a function`, rebuild the native module directly with `npm rebuild better-sqlite3 --runtime=electron --target=33.4.11 --arch=arm64 --dist-url=https://electronjs.org/headers` +- PR creation failure: ensure GitHub CLI is installed and authenticated with `gh auth login` +- Worktree removal blocked: review the diff, commit, or revert local changes before removing the session diff --git a/apps/electron/electron.vite.config.ts b/apps/electron/electron.vite.config.ts index bc544567..1c39bd08 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 00000000..584ea8a7 --- /dev/null +++ b/apps/electron/src/__tests__/opencode-host-controller.test.ts @@ -0,0 +1,157 @@ +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() + }) + + it('should restart a stopped service instead of returning an idle status', async () => { + const config = createOpenCodeHostConfig({ + XNET_OPENCODE_PORT: '4103' + }) + + const restartService = vi.fn(async () => createServiceStatus(config)) + + const controller = createOpenCodeHostController({ + getConfig: () => config, + getServiceStatus: () => createServiceStatus(config, { state: 'stopped' }), + startService: vi.fn(), + restartService, + stopService: vi.fn(), + resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), + probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.4' })), + publishStatus: vi.fn() + }) + + const status = await controller.ensure() + + expect(status.state).toBe('ready') + expect(restartService).toHaveBeenCalledTimes(1) + }) +}) 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 00000000..9134e7df --- /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/command-errors.test.ts b/apps/electron/src/main/command-errors.test.ts new file mode 100644 index 00000000..4a2c9a84 --- /dev/null +++ b/apps/electron/src/main/command-errors.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { formatCommandFailure } from './command-errors' + +describe('command-errors', () => { + it('formats missing command failures with recovery guidance', () => { + const error = Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) + + expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain('Install Git') + }) + + it('preserves the original failure message for non-ENOENT errors', () => { + const error = new Error('fatal: not a git repository') + + expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain( + 'fatal: not a git repository' + ) + }) +}) diff --git a/apps/electron/src/main/command-errors.ts b/apps/electron/src/main/command-errors.ts new file mode 100644 index 00000000..14dbd730 --- /dev/null +++ b/apps/electron/src/main/command-errors.ts @@ -0,0 +1,40 @@ +/** + * Shared command-failure formatting for Electron workspace services. + */ + +const COMMAND_RECOVERY_HINTS: Record = { + git: 'Install Git and ensure `git` is available on PATH before using the coding workspace shell.', + gh: 'Install GitHub CLI (`gh`) and run `gh auth login` before creating pull requests from the workspace shell.', + pnpm: 'Install pnpm and run `pnpm install` in this repository before starting worktree previews.' +} + +function getErrorCode(error: unknown): string | null { + if (!error || typeof error !== 'object' || !('code' in error)) { + return null + } + + const code = Reflect.get(error, 'code') + return typeof code === 'string' ? code : null +} + +export function formatCommandFailure( + command: string, + args: readonly string[], + cwd: string, + error: unknown +): string { + const errorCode = getErrorCode(error) + const fallbackMessage = error instanceof Error ? error.message : String(error) + const commandLabel = `${command} ${args.join(' ')}`.trim() + const recovery = COMMAND_RECOVERY_HINTS[command] + + if (errorCode === 'ENOENT') { + return recovery + ? `${command} is required but was not found while running ${commandLabel} in ${cwd}. ${recovery}` + : `${command} is required but was not found while running ${commandLabel} in ${cwd}.` + } + + return recovery + ? `${commandLabel} failed in ${cwd}: ${fallbackMessage}. ${recovery}` + : `${commandLabel} failed in ${cwd}: ${fallbackMessage}` +} diff --git a/apps/electron/src/main/git-service.test.ts b/apps/electron/src/main/git-service.test.ts new file mode 100644 index 00000000..b541799d --- /dev/null +++ b/apps/electron/src/main/git-service.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + deriveWorktreeContainerPath, + deriveWorktreeName, + isManagedWorktreePath, + parseGitStatusSummary, + parseWorktreeListOutput +} from './git-service' + +describe('git-service', () => { + describe('parseWorktreeListOutput', () => { + it('parses porcelain worktree output', () => { + const output = [ + 'worktree /tmp/xnet', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /tmp/xnet-feature', + 'HEAD def456', + 'branch refs/heads/codex/layout-pass', + 'locked', + '' + ].join('\n') + + expect(parseWorktreeListOutput(output)).toEqual([ + { + path: '/tmp/xnet', + head: 'abc123', + branch: 'main', + bare: false, + detached: false, + locked: false, + prunable: false + }, + { + path: '/tmp/xnet-feature', + head: 'def456', + branch: 'codex/layout-pass', + bare: false, + detached: false, + locked: true, + prunable: false + } + ]) + }) + + it('ignores unexpected branch formats instead of slicing corrupt names', () => { + const output = ['worktree /tmp/xnet', 'HEAD abc123', 'branch detached-head', ''].join('\n') + + expect(parseWorktreeListOutput(output)).toEqual([ + { + path: '/tmp/xnet', + head: 'abc123', + branch: null, + bare: false, + detached: false, + locked: false, + prunable: false + } + ]) + }) + }) + + describe('parseGitStatusSummary', () => { + it('counts unique changed files', () => { + const output = [ + 'M apps/electron/src/main/index.ts', + '?? docs/notes.md', + 'M docs/notes.md' + ].join('\n') + + expect(parseGitStatusSummary(output)).toEqual({ + changedFilesCount: 2, + isDirty: true, + files: ['apps/electron/src/main/index.ts', 'docs/notes.md'] + }) + }) + }) + + describe('deriveWorktreeName', () => { + it('builds a stable worktree name from the branch and session id', () => { + expect(deriveWorktreeName('codex/layout-pass', 'xnet:workspace-session:abc123')).toBe( + 'layout-pass-xnet-wor' + ) + }) + }) + + describe('managed worktree paths', () => { + it('derives the managed worktree container from the repo root', () => { + expect(deriveWorktreeContainerPath('/Users/crs/src/xNet')).toBe( + '/Users/crs/src/.xnet-worktrees/xnet' + ) + }) + + it('accepts only worktrees inside the managed container', () => { + const repoRoot = '/Users/crs/src/xNet' + + expect( + isManagedWorktreePath(repoRoot, '/Users/crs/src/.xnet-worktrees/xnet/layout-pass-xnet-wor') + ).toBe(true) + expect(isManagedWorktreePath(repoRoot, '/Users/crs/src/xNet')).toBe(false) + expect(isManagedWorktreePath(repoRoot, '/tmp/layout-pass-xnet-wor')).toBe(false) + }) + }) +}) diff --git a/apps/electron/src/main/git-service.ts b/apps/electron/src/main/git-service.ts new file mode 100644 index 00000000..2637b873 --- /dev/null +++ b/apps/electron/src/main/git-service.ts @@ -0,0 +1,484 @@ +/** + * Thin git CLI wrappers for coding-workspace sessions. + */ + +import { execFile } from 'node:child_process' +import { access, constants, cp, mkdir, readFile, symlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { promisify } from 'node:util' +import { + normalizeWorkspaceBranchSlug, + sanitizeWorkspaceBranchSegment, + WORKSPACE_SESSION_BRANCH_PREFIX +} from '../shared/workspace-session' +import { formatCommandFailure } from './command-errors' + +const execFileAsync = promisify(execFile) + +const WORKTREE_CONTAINER_DIRNAME = '.xnet-worktrees' +const WORKTREE_BOOTSTRAP_MARKER = '.xnet-workspace-bootstrap' + +export type WorktreeInfo = { + path: string + head: string | null + branch: string | null + bare: boolean + detached: boolean + locked: boolean + prunable: boolean +} + +export type GitStatusSummary = { + changedFilesCount: number + isDirty: boolean + files: string[] +} + +export type GitFileChange = { + path: string + status: string +} + +export type GitRepoContext = { + repoRoot: string + baseRef: string + currentBranch: string | null +} + +export type GitSessionInput = { + sessionId: string + title: string + branchSlug?: string | null + baseRef?: string | null +} + +export type CreatedWorktree = { + repoRoot: string + baseRef: string + branch: string + worktreeName: string + worktreePath: string +} + +type GitServiceOptions = { + repoRootOverride?: string | null +} + +type CommandResult = { + stdout: string + stderr: string +} + +type ParsedWorktreeEntry = Partial & { + path?: string +} + +const fileExists = async (path: string): Promise => { + try { + await access(path, constants.F_OK) + return true + } catch { + return false + } +} + +const trimCommandOutput = (value: string): string => value.trim() + +const uniqueStrings = (values: readonly string[]): string[] => [...new Set(values)] + +const normalizePath = (path: string): string => resolve(path.trim()) + +export function deriveWorktreeContainerPath(repoRoot: string): string { + const normalizedRepoRoot = normalizePath(repoRoot) + return join( + dirname(normalizedRepoRoot), + WORKTREE_CONTAINER_DIRNAME, + sanitizeWorkspaceBranchSegment(basename(normalizedRepoRoot)) + ) +} + +export function isManagedWorktreePath(repoRoot: string, worktreePath: string): boolean { + const containerPath = deriveWorktreeContainerPath(repoRoot) + const candidatePath = normalizePath(worktreePath) + const relativePath = relative(containerPath, candidatePath) + + return ( + relativePath === '' || + (!isAbsolute(relativePath) && + !relativePath.startsWith('..') && + !relativePath.startsWith(`..${sep}`)) + ) +} + +async function copyNodeModulesSkeleton(sourcePath: string, targetPath: string): Promise { + if (!(await fileExists(sourcePath)) || (await fileExists(targetPath))) { + return + } + + await cp(sourcePath, targetPath, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + filter: (entry) => { + const pnpmStorePath = join(sourcePath, '.pnpm') + return entry !== pnpmStorePath && !entry.startsWith(`${pnpmStorePath}${sep}`) + } + }) + + const sourcePnpmStore = join(sourcePath, '.pnpm') + const targetPnpmStore = join(targetPath, '.pnpm') + if (await fileExists(sourcePnpmStore)) { + await symlink(sourcePnpmStore, targetPnpmStore, 'dir') + } +} + +async function bootstrapWorktreeNodeModules( + sourceRepoRoot: string, + worktreePath: string +): Promise { + const markerPath = join(worktreePath, WORKTREE_BOOTSTRAP_MARKER) + if (await fileExists(markerPath)) { + return + } + + await copyNodeModulesSkeleton( + join(sourceRepoRoot, 'node_modules'), + join(worktreePath, 'node_modules') + ) + await copyNodeModulesSkeleton( + join(sourceRepoRoot, 'apps', 'web', 'node_modules'), + join(worktreePath, 'apps', 'web', 'node_modules') + ) + await copyNodeModulesSkeleton( + join(sourceRepoRoot, 'apps', 'electron', 'node_modules'), + join(worktreePath, 'apps', 'electron', 'node_modules') + ) + + await mkdir(markerPath, { recursive: true }) +} + +export function parseWorktreeListOutput(output: string): WorktreeInfo[] { + const lines = output.split('\n') + const entries: ParsedWorktreeEntry[] = [] + let current: ParsedWorktreeEntry = {} + + const pushCurrent = (): void => { + if (!current.path) { + return + } + + entries.push({ + path: current.path, + head: current.head ?? null, + branch: current.branch ?? null, + bare: Boolean(current.bare), + detached: Boolean(current.detached), + locked: Boolean(current.locked), + prunable: Boolean(current.prunable) + }) + current = {} + } + + for (const line of lines) { + if (!line.trim()) { + pushCurrent() + continue + } + + if (line.startsWith('worktree ')) { + pushCurrent() + current = { path: line.slice('worktree '.length).trim() } + continue + } + + if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length).trim() + continue + } + + if (line.startsWith('branch refs/heads/')) { + current.branch = line.slice('branch refs/heads/'.length).trim() + continue + } + + if (line === 'bare') { + current.bare = true + continue + } + + if (line === 'detached') { + current.detached = true + continue + } + + if (line === 'locked') { + current.locked = true + continue + } + + if (line === 'prunable') { + current.prunable = true + } + } + + pushCurrent() + return entries as WorktreeInfo[] +} + +export function parseGitStatusSummary(output: string): GitStatusSummary { + const files = uniqueStrings(parseGitFileChanges(output).map((entry) => entry.path)) + + return { + changedFilesCount: files.length, + isDirty: files.length > 0, + files + } +} + +export function parseGitFileChanges(output: string): GitFileChange[] { + return output + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => ({ + status: line.slice(0, 2).trim() || '??', + path: line.slice(3).trim() + })) +} + +export function deriveWorktreeName(branch: string, sessionId: string): string { + const branchSuffix = branch.startsWith(WORKSPACE_SESSION_BRANCH_PREFIX) + ? branch.slice(WORKSPACE_SESSION_BRANCH_PREFIX.length) + : branch + + const compactSessionId = sanitizeWorkspaceBranchSegment(sessionId).slice(0, 8) + return `${sanitizeWorkspaceBranchSegment(branchSuffix)}-${compactSessionId}` +} + +export class GitService { + private repoContextPromise: Promise | null = null + + constructor(private readonly options: GitServiceOptions = {}) {} + + async resolveRepoContext(startPath?: string): Promise { + if (!startPath && this.repoContextPromise) { + return this.repoContextPromise + } + + const task = this.resolveRepoContextInternal(startPath) + if (!startPath) { + this.repoContextPromise = task + } + + return task + } + + async createWorktree(input: GitSessionInput): Promise { + const repoContext = await this.resolveRepoContext() + const branch = await this.resolveBranchName( + repoContext.repoRoot, + input.branchSlug ?? input.title, + input.sessionId + ) + const worktreeName = deriveWorktreeName(branch, input.sessionId) + const worktreeContainer = deriveWorktreeContainerPath(repoContext.repoRoot) + const worktreePath = join(worktreeContainer, worktreeName) + + await mkdir(worktreeContainer, { recursive: true }) + await this.runGit( + ['worktree', 'add', '-b', branch, worktreePath, input.baseRef?.trim() || repoContext.baseRef], + repoContext.repoRoot + ) + await bootstrapWorktreeNodeModules(repoContext.repoRoot, worktreePath) + + return { + repoRoot: repoContext.repoRoot, + baseRef: input.baseRef?.trim() || repoContext.baseRef, + branch, + worktreeName, + worktreePath + } + } + + async listWorktrees(repoRoot?: string): Promise { + const context = repoRoot ? { repoRoot } : await this.resolveRepoContext() + const result = await this.runGit(['worktree', 'list', '--porcelain'], context.repoRoot) + return parseWorktreeListOutput(result.stdout) + } + + async removeWorktree(worktreePath: string): Promise { + const repoContext = await this.resolveRepoContext() + await this.runGit(['worktree', 'remove', worktreePath], repoContext.repoRoot) + } + + async getStatus(cwd: string): Promise { + const result = await this.runGit(['status', '--porcelain=v1', '--untracked-files=all'], cwd) + return parseGitStatusSummary(result.stdout) + } + + async getDiffStat(cwd: string): Promise { + const result = await this.runGit(['diff', '--stat', '--no-ext-diff'], cwd) + return trimCommandOutput(result.stdout) + } + + async getDiffPatch(cwd: string): Promise { + const result = await this.runGit(['diff', '--no-ext-diff'], cwd) + return result.stdout + } + + async listChangedFiles(cwd: string): Promise { + const result = await this.runGit(['status', '--porcelain=v1', '--untracked-files=all'], cwd) + return parseGitFileChanges(result.stdout) + } + + async readRelativeFile(cwd: string, relativePath: string): Promise { + const absolutePath = join(cwd, relativePath) + return readFile(absolutePath, 'utf8') + } + + async createCommit(cwd: string, message: string): Promise { + await this.runGit(['commit', '-m', message], cwd) + } + + async createPullRequest(cwd: string, args: readonly string[] = []): Promise { + const result = await this.runCommand('gh', ['pr', 'create', ...args], cwd) + return trimCommandOutput(result.stdout) + } + + private async resolveRepoContextInternal(startPath?: string): Promise { + const candidatePath = + startPath?.trim() || this.options.repoRootOverride?.trim() || process.cwd() + const repoRootResult = await this.runGit(['rev-parse', '--show-toplevel'], candidatePath) + const repoRoot = trimCommandOutput(repoRootResult.stdout) + const currentBranchResult = await this.runGitAllowFailure( + ['branch', '--show-current'], + repoRoot + ) + const currentBranch = trimCommandOutput(currentBranchResult.stdout) || null + const baseRef = + currentBranch || + (await this.resolveDefaultBranch(repoRoot)) || + trimCommandOutput((await this.runGit(['rev-parse', 'HEAD'], repoRoot)).stdout) + + return { + repoRoot, + baseRef, + currentBranch + } + } + + private async resolveDefaultBranch(repoRoot: string): Promise { + const originHead = await this.runGitAllowFailure( + ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], + repoRoot + ) + const remoteBranch = trimCommandOutput(originHead.stdout).replace(/^origin\//, '') + if (remoteBranch) { + return remoteBranch + } + + for (const branch of ['main', 'master']) { + const exists = await this.runGitAllowFailure( + ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], + repoRoot + ) + if (exists.exitCode === 0) { + return branch + } + } + + return null + } + + private async resolveBranchName( + repoRoot: string, + branchSlug: string, + sessionId: string + ): Promise { + const normalized = normalizeWorkspaceBranchSlug(branchSlug) + const candidates = [ + normalized, + `${normalized}-${sanitizeWorkspaceBranchSegment(sessionId).slice(0, 8)}` + ] + + for (const candidate of candidates) { + if (!(await this.branchExists(repoRoot, candidate))) { + return candidate + } + } + + let attempt = 1 + let candidate = `${candidates.at(-1)}-${String(attempt)}` + while (await this.branchExists(repoRoot, candidate)) { + attempt += 1 + candidate = `${candidates.at(-1)}-${String(attempt)}` + } + + return candidate + } + + private async branchExists(repoRoot: string, branch: string): Promise { + const result = await this.runGitAllowFailure( + ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], + repoRoot + ) + if (result.exitCode === 0) { + return true + } + + const remoteResult = await this.runGitAllowFailure( + ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], + repoRoot + ) + return remoteResult.exitCode === 0 + } + + private async runGit(args: readonly string[], cwd: string): Promise { + return this.runCommand('git', args, cwd) + } + + private async runCommand( + command: string, + args: readonly string[], + cwd: string + ): Promise { + try { + const result = await execFileAsync(command, [...args], { + cwd, + env: process.env, + maxBuffer: 10 * 1024 * 1024 + }) + + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '' + } + } catch (error) { + throw new Error(formatCommandFailure(command, args, cwd, error)) + } + } + + private async runGitAllowFailure( + args: readonly string[], + cwd: string + ): Promise { + try { + const result = await this.runGit(args, cwd) + return { + ...result, + exitCode: 0 + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + stdout: '', + stderr: message, + exitCode: 1 + } + } + } +} + +export function createGitService(options: GitServiceOptions = {}): GitService { + return new GitService(options) +} diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 555a5dfb..b8a96de1 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -14,8 +14,10 @@ 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' +import { setupWorkspaceSessionIPC, stopWorkspaceSessions } from './workspace-session-ipc' // Enable remote debugging in development for Playwright/CDP testing // CDP port is configurable via ELECTRON_CDP_PORT env var (default: 9223) @@ -193,6 +195,8 @@ app.whenReady().then(async () => { // Setup service IPC for plugin background processes setupServiceIPC() + setupOpenCodeIPC() + setupWorkspaceSessionIPC() // Setup Local API IPC handlers setupLocalAPIIPC() @@ -236,6 +240,8 @@ app.on('before-quit', async () => { await stopLocalAPI() // Stop all plugin services + await stopWorkspaceSessions() + 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 00000000..ab0f79ae --- /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' && current.state !== 'stopped') { + 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 00000000..6196d93e --- /dev/null +++ b/apps/electron/src/main/opencode-service.ts @@ -0,0 +1,267 @@ +/** + * 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 OpenCodeAppendPromptInput, + 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' +const OPENCODE_APPEND_PROMPT_PATH = '/tui/append-prompt' + +let ipcRegistered = false +let serviceEventsRegistered = false + +function getOpenCodeAuthHeaders(config: OpenCodeHostConfig): HeadersInit | undefined { + if (!config.password) { + return undefined + } + + return { + authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}` + } +} + +async function waitForOpenCodeHealth(config: OpenCodeHostConfig): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const health = await probeOpenCodeHealth(config) + if (health) { + return + } + + await new Promise((resolve) => { + setTimeout(resolve, 250) + }) + } + + throw new Error('OpenCode host is not ready to accept prompt updates') +} + +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 response = await fetch(`${config.baseUrl}${OPENCODE_HEALTH_PATH}`, { + headers: getOpenCodeAuthHeaders(config), + 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()) + ipcMain.handle( + OPENCODE_HOST_IPC_CHANNELS.APPEND_PROMPT, + async (_event, input: OpenCodeAppendPromptInput) => { + const ensured = await controller.ensure() + if (ensured.state !== 'ready' && ensured.state !== 'starting') { + throw new Error('OpenCode host is not available for prompt prefill') + } + + const config = createOpenCodeHostConfig(process.env) + await waitForOpenCodeHealth(config) + + const response = await fetch(`${config.baseUrl}${OPENCODE_APPEND_PROMPT_PATH}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(getOpenCodeAuthHeaders(config) ?? {}) + }, + body: JSON.stringify({ + prompt: input.prompt, + text: input.prompt + }) + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`OpenCode prompt append failed: ${response.status} ${text}`) + } + + return { ok: true as const } + } + ) + + ipcRegistered = true +} + +export async function stopOpenCodeHost(): Promise { + await controller.stop() +} + +export { OPENCODE_INSTALL_URL } diff --git a/apps/electron/src/main/preview-manager.test.ts b/apps/electron/src/main/preview-manager.test.ts new file mode 100644 index 00000000..0a0b08da --- /dev/null +++ b/apps/electron/src/main/preview-manager.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + buildKeepWarmSessionIds, + createPreviewManager, + previewRuntimeToWorkspaceState +} from './preview-manager' + +describe('preview-manager', () => { + describe('buildKeepWarmSessionIds', () => { + it('keeps the active and most recent sessions warm by default', () => { + const keepWarm = buildKeepWarmSessionIds([ + { + sessionId: 'session-1', + title: 'Session 1', + branch: 'codex/session-1', + worktreeName: 'session-1', + worktreePath: '/tmp/session-1' + }, + { + sessionId: 'session-2', + title: 'Session 2', + branch: 'codex/session-2', + worktreeName: 'session-2', + worktreePath: '/tmp/session-2' + }, + { + sessionId: 'session-3', + title: 'Session 3', + branch: 'codex/session-3', + worktreeName: 'session-3', + worktreePath: '/tmp/session-3' + } + ]) + + expect([...keepWarm]).toEqual(['session-1', 'session-2']) + }) + }) + + describe('previewRuntimeToWorkspaceState', () => { + it('maps runtime states onto renderer session states', () => { + expect( + previewRuntimeToWorkspaceState({ + sessionId: 'session-1', + state: 'starting' + }) + ).toBe('running') + expect( + previewRuntimeToWorkspaceState({ + sessionId: 'session-1', + state: 'ready', + url: 'http://127.0.0.1:4310' + }) + ).toBe('previewing') + expect( + previewRuntimeToWorkspaceState({ + sessionId: 'session-1', + state: 'error', + lastError: 'boom' + }) + ).toBe('error') + expect( + previewRuntimeToWorkspaceState({ + sessionId: 'session-1', + state: 'stopped' + }) + ).toBe('idle') + }) + }) + + describe('port reservations', () => { + it('reserves unique ports before runtimes are registered', async () => { + const manager = createPreviewManager({ + basePort: 4630 + }) as unknown as { + allocatePort(sessionId: string): Promise + } + + const first = await manager.allocatePort('session-1') + const second = await manager.allocatePort('session-2') + + expect(second).toBe(first + 1) + }) + }) +}) diff --git a/apps/electron/src/main/preview-manager.ts b/apps/electron/src/main/preview-manager.ts new file mode 100644 index 00000000..293600d6 --- /dev/null +++ b/apps/electron/src/main/preview-manager.ts @@ -0,0 +1,491 @@ +/** + * Preview runtime manager for coding-workspace sessions. + */ + +import type { WorkspaceSessionDescriptor, WorkspaceSessionState } from '../shared/workspace-session' +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { access, constants } from 'node:fs/promises' +import { createServer } from 'node:net' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatCommandFailure } from './command-errors' + +const DEFAULT_PREVIEW_HOST = '127.0.0.1' +const DEFAULT_PREVIEW_BASE_PORT = 4310 +const DEFAULT_KEEP_WARM_COUNT = 2 +const DEFAULT_READY_TIMEOUT_MS = 30_000 +const DEFAULT_HEALTH_INTERVAL_MS = 5_000 +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const PREVIEW_SOURCE_REPO_ROOT = resolve(__dirname, '../../../..') + +export type PreviewRuntimeState = 'stopped' | 'starting' | 'ready' | 'error' + +export type PreviewRuntimeStatus = { + sessionId: string + state: PreviewRuntimeState + port?: number + url?: string + startedAt?: number + lastError?: string + lastOutput?: string +} + +type PreviewRuntime = { + descriptor: WorkspaceSessionDescriptor + process: ChildProcessWithoutNullStreams | null + port: number + state: PreviewRuntimeState + url: string + startedAt: number | null + lastError: string | null + lastOutput: string | null + stopRequested: boolean + healthTimer: ReturnType | null + readinessTask: Promise | null +} + +type PreviewManagerOptions = { + host?: string + basePort?: number + keepWarmCount?: number + readyTimeoutMs?: number + healthIntervalMs?: number +} + +const fileExists = async (path: string): Promise => { + try { + await access(path, constants.F_OK) + return true + } catch { + return false + } +} + +const withTimeout = async (task: Promise, timeoutMs: number): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => { + reject(new Error('Timed out waiting for preview runtime')) + }) + }) + ]) + } finally { + clearTimeout(timer) + } +} + +async function isPortAvailable(host: string, port: number): Promise { + return new Promise((resolve) => { + const server = createServer() + + server.once('error', () => { + resolve(false) + }) + + server.once('listening', () => { + server.close(() => resolve(true)) + }) + + server.listen(port, host) + }) +} + +async function findAvailableManagedPort( + host: string, + startPort: number, + claimedPorts: ReadonlySet +): Promise { + let port = startPort + + while (claimedPorts.has(port) || !(await isPortAvailable(host, port))) { + port += 1 + } + + return port +} + +async function probePreviewUrl(url: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 1000) + + try { + const response = await fetch(url, { signal: controller.signal }) + return response.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +function normalizeOutput(data: string): string | null { + const lines = data + .trim() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + return lines.at(-1) ?? null +} + +export function buildKeepWarmSessionIds( + sessions: readonly WorkspaceSessionDescriptor[], + keepWarmCount: number = DEFAULT_KEEP_WARM_COUNT +): Set { + return new Set(sessions.slice(0, Math.max(0, keepWarmCount)).map((session) => session.sessionId)) +} + +export function previewRuntimeToWorkspaceState( + status: PreviewRuntimeStatus +): WorkspaceSessionState { + switch (status.state) { + case 'ready': + return 'previewing' + case 'starting': + return 'running' + case 'error': + return 'error' + default: + return 'idle' + } +} + +export class PreviewManager { + private readonly events = new EventEmitter() + private readonly runtimes = new Map() + private readonly reservedPorts = new Map() + private readonly host: string + private readonly basePort: number + private readonly keepWarmCount: number + private readonly readyTimeoutMs: number + private readonly healthIntervalMs: number + + constructor(options: PreviewManagerOptions = {}) { + this.host = options.host ?? DEFAULT_PREVIEW_HOST + this.basePort = options.basePort ?? DEFAULT_PREVIEW_BASE_PORT + this.keepWarmCount = options.keepWarmCount ?? DEFAULT_KEEP_WARM_COUNT + this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS + this.healthIntervalMs = options.healthIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS + } + + onStatus(listener: (status: PreviewRuntimeStatus) => void): () => void { + this.events.on('status', listener) + return () => { + this.events.off('status', listener) + } + } + + getStatus(sessionId: string): PreviewRuntimeStatus { + const runtime = this.runtimes.get(sessionId) + if (!runtime) { + return { + sessionId, + state: 'stopped' + } + } + + return { + sessionId, + state: runtime.state, + port: runtime.port, + url: runtime.url, + ...(runtime.startedAt ? { startedAt: runtime.startedAt } : {}), + ...(runtime.lastError ? { lastError: runtime.lastError } : {}), + ...(runtime.lastOutput ? { lastOutput: runtime.lastOutput } : {}) + } + } + + async syncSessions(sessions: readonly WorkspaceSessionDescriptor[]): Promise { + const keepWarmSessionIds = buildKeepWarmSessionIds(sessions, this.keepWarmCount) + + await Promise.all( + sessions.map(async (session) => { + if (keepWarmSessionIds.has(session.sessionId)) { + await this.ensureSession(session) + return + } + + await this.stopSession(session.sessionId) + }) + ) + + const knownSessionIds = new Set(sessions.map((session) => session.sessionId)) + const staleSessionIds = [...this.runtimes.keys()].filter( + (sessionId) => !knownSessionIds.has(sessionId) + ) + await Promise.all(staleSessionIds.map((sessionId) => this.stopSession(sessionId))) + } + + async ensureSession(session: WorkspaceSessionDescriptor): Promise { + const existingRuntime = this.runtimes.get(session.sessionId) + if (existingRuntime) { + existingRuntime.descriptor = session + if (existingRuntime.state === 'ready' || existingRuntime.state === 'starting') { + return this.getStatus(session.sessionId) + } + + await this.stopSession(session.sessionId) + } + + return this.startSession(session) + } + + async refreshSession(session: WorkspaceSessionDescriptor): Promise { + const runtime = this.runtimes.get(session.sessionId) + if (!runtime) { + return this.ensureSession(session) + } + + runtime.descriptor = session + const healthy = await probePreviewUrl(runtime.url) + if (healthy) { + if (runtime.state !== 'ready') { + runtime.state = 'ready' + runtime.lastError = null + if (!runtime.startedAt) { + runtime.startedAt = Date.now() + } + this.emitStatus(session.sessionId) + } + return this.getStatus(session.sessionId) + } + + return this.restartSession(session) + } + + async restartSession(session: WorkspaceSessionDescriptor): Promise { + await this.stopSession(session.sessionId) + return this.startSession(session) + } + + async stopSession(sessionId: string): Promise { + const runtime = this.runtimes.get(sessionId) + if (!runtime) { + return + } + + runtime.stopRequested = true + if (runtime.healthTimer) { + clearInterval(runtime.healthTimer) + runtime.healthTimer = null + } + + const child = runtime.process + if (!child) { + this.runtimes.delete(sessionId) + this.emitStatus(sessionId) + return + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL') + }, 4000) + + child.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + + child.kill('SIGTERM') + }) + } + + async stopAll(): Promise { + await Promise.all([...this.runtimes.keys()].map((sessionId) => this.stopSession(sessionId))) + } + + private async startSession(session: WorkspaceSessionDescriptor): Promise { + const port = await this.allocatePort(session.sessionId) + const previewAppPath = join(session.worktreePath, 'apps', 'web') + const previewAppExists = await fileExists(previewAppPath) + if (!previewAppExists) { + const runtime = this.createRuntime(session, port) + this.releaseReservedPort(session.sessionId) + runtime.state = 'error' + runtime.lastError = `Preview app not found at ${previewAppPath}` + this.runtimes.set(session.sessionId, runtime) + this.emitStatus(session.sessionId) + return this.getStatus(session.sessionId) + } + + const runtime = this.createRuntime(session, port) + this.releaseReservedPort(session.sessionId) + this.runtimes.set(session.sessionId, runtime) + this.emitStatus(session.sessionId) + + try { + const child = spawn( + 'pnpm', + ['exec', 'vite', '--host', this.host, '--port', String(port), '--strictPort'], + { + cwd: previewAppPath, + env: { + ...process.env, + BROWSER: 'none', + CI: '1', + FORCE_COLOR: '0', + XNET_PREVIEW_SOURCE_REPO_ROOT: + process.env.XNET_PREVIEW_SOURCE_REPO_ROOT ?? PREVIEW_SOURCE_REPO_ROOT + } + } + ) + + runtime.process = child + + child.stdout.on('data', (chunk: Buffer) => { + const nextOutput = normalizeOutput(chunk.toString()) + if (nextOutput) { + runtime.lastOutput = nextOutput + } + }) + + child.stderr.on('data', (chunk: Buffer) => { + const nextOutput = normalizeOutput(chunk.toString()) + if (nextOutput) { + runtime.lastOutput = nextOutput + runtime.lastError = nextOutput + } + }) + + child.once('error', (error) => { + runtime.state = 'error' + runtime.lastError = formatCommandFailure( + 'pnpm', + ['exec', 'vite', '--host', this.host, '--port', String(port), '--strictPort'], + previewAppPath, + error + ) + this.emitStatus(session.sessionId) + }) + + child.once('exit', (_code, signal) => { + if (runtime.healthTimer) { + clearInterval(runtime.healthTimer) + runtime.healthTimer = null + } + + if (runtime.stopRequested) { + this.runtimes.delete(session.sessionId) + this.emitStatus(session.sessionId) + return + } + + runtime.state = 'error' + runtime.lastError = signal + ? `Preview runtime exited with signal ${signal}` + : 'Preview runtime exited unexpectedly' + runtime.process = null + this.emitStatus(session.sessionId) + }) + + runtime.readinessTask = withTimeout(this.waitForReady(runtime), this.readyTimeoutMs) + await runtime.readinessTask + + if (runtime.state !== 'error') { + runtime.state = 'ready' + runtime.startedAt = Date.now() + runtime.lastError = null + this.startHealthChecks(runtime) + this.emitStatus(session.sessionId) + } + } catch (error) { + if (runtime.process) { + runtime.process.kill('SIGKILL') + } + runtime.state = 'error' + runtime.lastError = formatCommandFailure( + 'pnpm', + ['exec', 'vite', '--host', this.host, '--port', String(port), '--strictPort'], + previewAppPath, + error + ) + this.emitStatus(session.sessionId) + } + + return this.getStatus(session.sessionId) + } + + private createRuntime(session: WorkspaceSessionDescriptor, port: number): PreviewRuntime { + return { + descriptor: session, + process: null, + port, + state: 'starting', + url: `http://${this.host}:${String(port)}`, + startedAt: null, + lastError: null, + lastOutput: null, + stopRequested: false, + healthTimer: null, + readinessTask: null + } + } + + private async allocatePort(sessionId: string): Promise { + const existingRuntime = this.runtimes.get(sessionId) + if (existingRuntime) { + return existingRuntime.port + } + + const existingReservation = this.reservedPorts.get(sessionId) + if (existingReservation) { + return existingReservation + } + + const claimedPorts = new Set([ + ...this.reservedPorts.values(), + ...[...this.runtimes.values()].map((runtime) => runtime.port) + ]) + const nextPort = await findAvailableManagedPort(this.host, this.basePort, claimedPorts) + this.reservedPorts.set(sessionId, nextPort) + return nextPort + } + + private releaseReservedPort(sessionId: string): void { + this.reservedPorts.delete(sessionId) + } + + private async waitForReady(runtime: PreviewRuntime): Promise { + while (runtime.state === 'starting' && !runtime.stopRequested) { + if (await probePreviewUrl(runtime.url)) { + return + } + + await new Promise((resolve) => { + setTimeout(resolve, 300) + }) + } + } + + private startHealthChecks(runtime: PreviewRuntime): void { + if (runtime.healthTimer) { + clearInterval(runtime.healthTimer) + } + + runtime.healthTimer = setInterval(() => { + void probePreviewUrl(runtime.url).then((healthy) => { + if (!healthy && runtime.state === 'ready') { + runtime.state = 'error' + runtime.lastError = runtime.lastError ?? 'Preview runtime stopped responding' + this.emitStatus(runtime.descriptor.sessionId) + } + }) + }, this.healthIntervalMs) + } + + private emitStatus(sessionId: string): void { + this.events.emit('status', this.getStatus(sessionId)) + } +} + +export function createPreviewManager(options: PreviewManagerOptions = {}): PreviewManager { + return new PreviewManager(options) +} diff --git a/apps/electron/src/main/service-ipc.ts b/apps/electron/src/main/service-ipc.ts index 20859a49..0e433f4b 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/main/workspace-session-ipc.ts b/apps/electron/src/main/workspace-session-ipc.ts new file mode 100644 index 00000000..a4b06178 --- /dev/null +++ b/apps/electron/src/main/workspace-session-ipc.ts @@ -0,0 +1,493 @@ +/** + * IPC handlers for coding-workspace sessions. + */ + +import type { + CaptureWorkspaceSessionScreenshotInput, + CaptureWorkspaceSessionScreenshotResult, + CreateWorkspaceSessionInput, + CreateWorkspaceSessionPullRequestInput, + CreateWorkspaceSessionPullRequestResult, + RefreshWorkspaceSessionInput, + RemoveWorkspaceSessionInput, + RemoveWorkspaceSessionResult, + StoreSelectedContextInput, + StoreSelectedContextResult, + SyncWorkspaceSessionsInput, + WorkspaceSessionDescriptor, + WorkspaceSessionReview, + WorkspaceSessionSnapshot, + WorkspaceSessionStatusEvent +} from '../shared/workspace-session' +import { access, constants, mkdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { BrowserWindow, ipcMain } from 'electron' +import { createOpenCodeHostConfig } from '../shared/opencode-host' +import { + areWorkspaceSessionSnapshotsEqual, + WORKSPACE_SESSION_IPC_CHANNELS +} from '../shared/workspace-session' +import { createGitService, isManagedWorktreePath, type GitFileChange } from './git-service' +import { + createPreviewManager, + previewRuntimeToWorkspaceState, + type PreviewRuntimeStatus +} from './preview-manager' + +const gitService = createGitService({ + repoRootOverride: process.env.XNET_WORKSPACE_REPO_ROOT ?? null +}) +const previewManager = createPreviewManager() +const sessionRegistry = new Map() +const lastPublishedSnapshots = new Map() + +let ipcRegistered = false +let previewEventsRegistered = false + +const fileExists = async (path: string): Promise => { + try { + await access(path, constants.F_OK) + return true + } catch { + return false + } +} + +const getOpenCodeUrl = (): string => createOpenCodeHostConfig(process.env).baseUrl +const getScreenshotPath = (sessionId: string, worktreePath: string): string => + join(worktreePath, 'tmp', 'playwright', `${sessionId}.png`) +const getSelectedContextPath = (worktreePath: string): string => + join(worktreePath, '.xnet', 'selected-context.json') +const getPullRequestBodyPath = (sessionId: string, worktreePath: string): string => + join(worktreePath, '.xnet', 'pr', `${sessionId}.md`) + +async function assertManagedWorktreePath(worktreePath: string): Promise { + const candidatePath = resolve(worktreePath.trim()) + const repoContext = await gitService.resolveRepoContext() + + if (!isManagedWorktreePath(repoContext.repoRoot, candidatePath)) { + throw new Error(`Refusing to access unmanaged worktree path: ${candidatePath}`) + } + + return candidatePath +} + +async function normalizeSessionDescriptor( + session: WorkspaceSessionDescriptor +): Promise { + return { + ...session, + worktreePath: await assertManagedWorktreePath(session.worktreePath) + } +} + +const publishWorkspaceSession = (session: WorkspaceSessionSnapshot): void => { + const previousSnapshot = lastPublishedSnapshots.get(session.sessionId) + if (previousSnapshot && areWorkspaceSessionSnapshotsEqual(previousSnapshot, session)) { + return + } + + lastPublishedSnapshots.set(session.sessionId, session) + const event: WorkspaceSessionStatusEvent = { session } + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send(WORKSPACE_SESSION_IPC_CHANNELS.STATUS_CHANGE, event) + }) +} + +async function buildSessionSnapshot( + session: WorkspaceSessionDescriptor, + previewStatus: PreviewRuntimeStatus +): Promise { + const worktreePath = await assertManagedWorktreePath(session.worktreePath) + const screenshotPath = getScreenshotPath(session.sessionId, worktreePath) + const hasScreenshot = await fileExists(screenshotPath) + + try { + const gitStatus = await gitService.getStatus(worktreePath) + + return { + sessionId: session.sessionId, + title: session.title, + branch: session.branch, + worktreeName: session.worktreeName, + worktreePath, + openCodeUrl: getOpenCodeUrl(), + ...(previewStatus.url && previewStatus.state === 'ready' + ? { previewUrl: previewStatus.url } + : {}), + ...(hasScreenshot ? { lastScreenshotPath: screenshotPath } : {}), + changedFilesCount: gitStatus.changedFilesCount, + state: previewRuntimeToWorkspaceState(previewStatus), + isDirty: gitStatus.isDirty, + ...(previewStatus.lastError ? { lastError: previewStatus.lastError } : {}) + } + } catch (error) { + return { + sessionId: session.sessionId, + title: session.title, + branch: session.branch, + worktreeName: session.worktreeName, + worktreePath, + openCodeUrl: getOpenCodeUrl(), + ...(hasScreenshot ? { lastScreenshotPath: screenshotPath } : {}), + changedFilesCount: 0, + state: 'error', + isDirty: false, + lastError: error instanceof Error ? error.message : String(error) + } + } +} + +function buildPullRequestDraft( + session: WorkspaceSessionDescriptor, + changedFiles: readonly GitFileChange[], + diffStat: string, + screenshotPath: string | null +): { title: string; body: string } { + const bulletFiles = changedFiles.slice(0, 10).map((file) => `- ${file.path}`) + + return { + title: `feat(workspace): update ${session.title.toLowerCase()}`, + body: [ + '## Summary', + '', + `- update ${session.title}`, + `- review worktree-backed shell changes on ${session.branch}`, + '', + '## Changed Files', + '', + ...(bulletFiles.length > 0 ? bulletFiles : ['- No changed files detected']), + '', + '## Diff Summary', + '', + diffStat || '_No diff summary available._', + '', + '## Screenshot', + '', + screenshotPath ? `- ${screenshotPath}` : '- No screenshot captured yet' + ].join('\n') + } +} + +async function buildWorkspaceReview( + session: WorkspaceSessionDescriptor +): Promise { + const worktreePath = await assertManagedWorktreePath(session.worktreePath) + const changedFiles = await gitService.listChangedFiles(worktreePath) + const diffStat = await gitService.getDiffStat(worktreePath) + const diffPatch = await gitService.getDiffPatch(worktreePath) + const screenshotPath = getScreenshotPath(session.sessionId, worktreePath) + const hasScreenshot = await fileExists(screenshotPath) + const markdownFile = changedFiles.find( + (entry) => entry.path.endsWith('.md') || entry.path.endsWith('.mdx') + ) + const markdownPreview = markdownFile + ? await gitService + .readRelativeFile(worktreePath, markdownFile.path) + .then((content) => ({ + path: markdownFile.path, + content + })) + .catch(() => null) + : null + const prDraft = buildPullRequestDraft( + session, + changedFiles, + diffStat, + hasScreenshot ? screenshotPath : null + ) + + return { + sessionId: session.sessionId, + changedFiles, + diffStat, + diffPatch, + markdownPreview, + prDraft: { + ...prDraft, + screenshotPath: hasScreenshot ? screenshotPath : null + } + } +} + +async function refreshSessionSnapshot( + session: WorkspaceSessionDescriptor +): Promise { + const normalizedSession = await normalizeSessionDescriptor(session) + const previewStatus = await previewManager.refreshSession(normalizedSession) + const snapshot = await buildSessionSnapshot(normalizedSession, previewStatus) + publishWorkspaceSession(snapshot) + return snapshot +} + +async function storeSelectedContext( + input: StoreSelectedContextInput +): Promise { + const worktreePath = await assertManagedWorktreePath(input.worktreePath) + const outputPath = getSelectedContextPath(worktreePath) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, JSON.stringify(input.context, null, 2), 'utf8') + return { path: outputPath } +} + +async function captureWorkspaceSessionScreenshot( + browserWindow: BrowserWindow | null, + input: CaptureWorkspaceSessionScreenshotInput +): Promise { + if (!browserWindow) { + throw new Error('Unable to capture screenshot without an active window') + } + + const image = await browserWindow.webContents.capturePage() + const worktreePath = await assertManagedWorktreePath(input.worktreePath) + const outputPath = getScreenshotPath(input.sessionId, worktreePath) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, image.toPNG()) + return { path: outputPath } +} + +async function createWorkspaceSessionPullRequest( + input: CreateWorkspaceSessionPullRequestInput +): Promise { + const worktreePath = await assertManagedWorktreePath(input.worktreePath) + const bodyFilePath = getPullRequestBodyPath(input.sessionId, worktreePath) + await mkdir(dirname(bodyFilePath), { recursive: true }) + await writeFile(bodyFilePath, input.body, 'utf8') + + try { + const output = await gitService.createPullRequest(worktreePath, [ + '--title', + input.title, + '--body-file', + bodyFilePath + ]) + const urlMatch = output.match(/https:\/\/\S+/) + + return { + created: true, + url: urlMatch ? urlMatch[0] : null, + bodyFilePath, + error: null + } + } catch (error) { + return { + created: false, + url: null, + bodyFilePath, + error: error instanceof Error ? error.message : String(error) + } + } +} + +const registerPreviewEventForwarding = (): void => { + if (previewEventsRegistered) { + return + } + + previewManager.onStatus((status) => { + const descriptor = sessionRegistry.get(status.sessionId) + if (!descriptor) { + return + } + + void buildSessionSnapshot(descriptor, status).then((snapshot) => { + publishWorkspaceSession(snapshot) + }) + }) + + previewEventsRegistered = true +} + +export function setupWorkspaceSessionIPC(): void { + if (ipcRegistered) { + return + } + + registerPreviewEventForwarding() + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.CREATE, + async (_event, input: CreateWorkspaceSessionInput): Promise => { + const created = await gitService.createWorktree({ + sessionId: input.sessionId, + title: input.title, + branchSlug: input.branchSlug ?? undefined, + baseRef: input.baseRef ?? undefined + }) + + const session: WorkspaceSessionDescriptor = { + sessionId: input.sessionId, + title: input.title, + branch: created.branch, + worktreeName: created.worktreeName, + worktreePath: created.worktreePath + } + + sessionRegistry.set(session.sessionId, session) + const previewStatus = await previewManager.ensureSession(session) + const snapshot = await buildSessionSnapshot(session, previewStatus) + publishWorkspaceSession(snapshot) + return snapshot + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.SYNC, + async (_event, input: SyncWorkspaceSessionsInput): Promise => { + const sessions = await Promise.all( + input.sessions.map((session) => normalizeSessionDescriptor(session)) + ) + + sessions.forEach((session) => { + sessionRegistry.set(session.sessionId, session) + }) + + const knownIds = new Set(sessions.map((session) => session.sessionId)) + ;[...sessionRegistry.keys()] + .filter((sessionId) => !knownIds.has(sessionId)) + .forEach((sessionId) => { + sessionRegistry.delete(sessionId) + }) + + await previewManager.syncSessions(sessions) + + return Promise.all( + sessions.map(async (session) => { + const previewStatus = previewManager.getStatus(session.sessionId) + return buildSessionSnapshot(session, previewStatus) + }) + ) + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.REFRESH, + async (_event, input: RefreshWorkspaceSessionInput): Promise => { + const session = await normalizeSessionDescriptor({ + sessionId: input.sessionId, + title: input.title, + branch: input.branch, + worktreeName: input.worktreeName, + worktreePath: input.worktreePath + }) + + sessionRegistry.set(session.sessionId, session) + return refreshSessionSnapshot(session) + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.RESTART_PREVIEW, + async (_event, input: RefreshWorkspaceSessionInput): Promise => { + const session = await normalizeSessionDescriptor({ + sessionId: input.sessionId, + title: input.title, + branch: input.branch, + worktreeName: input.worktreeName, + worktreePath: input.worktreePath + }) + + sessionRegistry.set(session.sessionId, session) + const previewStatus = await previewManager.restartSession(session) + const snapshot = await buildSessionSnapshot(session, previewStatus) + publishWorkspaceSession(snapshot) + return snapshot + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.REVIEW, + async (_event, input: RefreshWorkspaceSessionInput): Promise => { + const session = await normalizeSessionDescriptor({ + sessionId: input.sessionId, + title: input.title, + branch: input.branch, + worktreeName: input.worktreeName, + worktreePath: input.worktreePath + }) + + sessionRegistry.set(session.sessionId, session) + return buildWorkspaceReview(session) + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.STORE_SELECTED_CONTEXT, + async (_event, input: StoreSelectedContextInput): Promise => { + return storeSelectedContext(input) + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.CAPTURE_SCREENSHOT, + async ( + event, + input: CaptureWorkspaceSessionScreenshotInput + ): Promise => { + const browserWindow = BrowserWindow.fromWebContents(event.sender) + const result = await captureWorkspaceSessionScreenshot(browserWindow, input) + const descriptor = sessionRegistry.get(input.sessionId) + if (descriptor) { + const snapshot = await buildSessionSnapshot( + descriptor, + previewManager.getStatus(input.sessionId) + ) + publishWorkspaceSession(snapshot) + } + return result + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.CREATE_PULL_REQUEST, + async ( + _event, + input: CreateWorkspaceSessionPullRequestInput + ): Promise => { + return createWorkspaceSessionPullRequest(input) + } + ) + + ipcMain.handle( + WORKSPACE_SESSION_IPC_CHANNELS.REMOVE, + async (_event, input: RemoveWorkspaceSessionInput): Promise => { + try { + const worktreePath = await assertManagedWorktreePath(input.worktreePath) + const gitStatus = await gitService.getStatus(worktreePath) + if (gitStatus.isDirty) { + return { + removed: false, + dirty: true, + message: + 'Worktree has uncommitted changes. Review the diff, commit, or revert before removing it.' + } + } + + sessionRegistry.delete(input.sessionId) + lastPublishedSnapshots.delete(input.sessionId) + await previewManager.stopSession(input.sessionId) + await gitService.removeWorktree(worktreePath) + + return { + removed: true, + dirty: false, + message: 'Removed worktree and stopped the preview runtime.' + } + } catch (error) { + return { + removed: false, + dirty: false, + message: error instanceof Error ? error.message : String(error) + } + } + } + ) + + ipcRegistered = true +} + +export async function stopWorkspaceSessions(): Promise { + await previewManager.stopAll() + sessionRegistry.clear() + lastPublishedSnapshots.clear() +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 7b445bde..b6ed1884 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -1,8 +1,33 @@ /** * 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 OpenCodeAppendPromptInput, + type OpenCodeHostOutputEvent, + type OpenCodeHostStatus +} from '../shared/opencode-host' +import { isAllowedServiceChannel } from '../shared/service-ipc' +import { + WORKSPACE_SESSION_IPC_CHANNELS, + type CaptureWorkspaceSessionScreenshotInput, + type CaptureWorkspaceSessionScreenshotResult, + type CreateWorkspaceSessionPullRequestInput, + type CreateWorkspaceSessionPullRequestResult, + type CreateWorkspaceSessionInput, + type RefreshWorkspaceSessionInput, + type RemoveWorkspaceSessionInput, + type RemoveWorkspaceSessionResult, + type StoreSelectedContextInput, + type StoreSelectedContextResult, + type SyncWorkspaceSessionsInput, + type WorkspaceSessionReview, + type WorkspaceSessionSnapshot, + type WorkspaceSessionStatusEvent +} from '../shared/workspace-session' // Expose xNet API to renderer contextBridge.exposeInMainWorld('xnet', { @@ -237,32 +262,129 @@ 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), + appendPrompt: (input: OpenCodeAppendPromptInput) => + ipcRenderer.invoke(OPENCODE_HOST_IPC_CHANNELS.APPEND_PROMPT, input), + 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 + ) + } +}) + +contextBridge.exposeInMainWorld('xnetWorkspaceSessions', { + create: (input: CreateWorkspaceSessionInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.CREATE, input), + sync: (input: SyncWorkspaceSessionsInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.SYNC, input), + refresh: (input: RefreshWorkspaceSessionInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.REFRESH, input), + restartPreview: (input: RefreshWorkspaceSessionInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.RESTART_PREVIEW, input), + review: (input: RefreshWorkspaceSessionInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.REVIEW, input), + storeSelectedContext: (input: StoreSelectedContextInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.STORE_SELECTED_CONTEXT, input), + captureScreenshot: ( + input: CaptureWorkspaceSessionScreenshotInput + ): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.CAPTURE_SCREENSHOT, input), + createPullRequest: ( + input: CreateWorkspaceSessionPullRequestInput + ): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.CREATE_PULL_REQUEST, input), + remove: (input: RemoveWorkspaceSessionInput): Promise => + ipcRenderer.invoke(WORKSPACE_SESSION_IPC_CHANNELS.REMOVE, input), + onStatusChange: (callback: (event: WorkspaceSessionStatusEvent) => void) => { + const handler = (_: unknown, event: WorkspaceSessionStatusEvent) => callback(event) + ipcRenderer.on( + WORKSPACE_SESSION_IPC_CHANNELS.STATUS_CHANGE, + handler as (...args: unknown[]) => void + ) + return () => + ipcRenderer.removeListener( + WORKSPACE_SESSION_IPC_CHANNELS.STATUS_CHANGE, + handler as (...args: unknown[]) => void + ) } }) @@ -450,6 +572,34 @@ export interface XNetServicesAPI { off(channel: string, handler: (...args: unknown[]) => void): void } +export type XNetServiceChannel = ServiceIpcChannel + +export interface XNetOpenCodeAPI { + ensure(): Promise + status(): Promise + stop(): Promise + appendPrompt(input: OpenCodeAppendPromptInput): Promise<{ ok: true }> + onStatusChange(callback: (status: OpenCodeHostStatus) => void): () => void + onOutput(callback: (event: OpenCodeHostOutputEvent) => void): () => void +} + +export interface XNetWorkspaceSessionsAPI { + create(input: CreateWorkspaceSessionInput): Promise + sync(input: SyncWorkspaceSessionsInput): Promise + refresh(input: RefreshWorkspaceSessionInput): Promise + restartPreview(input: RefreshWorkspaceSessionInput): Promise + review(input: RefreshWorkspaceSessionInput): Promise + storeSelectedContext(input: StoreSelectedContextInput): Promise + captureScreenshot( + input: CaptureWorkspaceSessionScreenshotInput + ): Promise + createPullRequest( + input: CreateWorkspaceSessionPullRequestInput + ): Promise + remove(input: RemoveWorkspaceSessionInput): Promise + onStatusChange(callback: (event: WorkspaceSessionStatusEvent) => void): () => void +} + export interface XNetLocalAPIStatus { running: boolean port: number @@ -526,6 +676,8 @@ declare global { xnet: XNetAPI xnetBSM: XNetBSMAPI xnetServices: XNetServicesAPI + xnetOpenCode: XNetOpenCodeAPI + xnetWorkspaceSessions: XNetWorkspaceSessionsAPI xnetLocalAPI: XNetLocalAPIAPI xnetTunnel: XNetTunnelAPI xnetNodes: XNetNodesAPI diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index 584917d3..75e54164 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 cc1dfc42..d85888e8 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/index.html b/apps/electron/src/renderer/index.html index 14b6cf3f..ead97c1d 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/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 1c2fc975..ac7044de 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/DevWorkspaceShell.tsx b/apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx new file mode 100644 index 00000000..06d1e043 --- /dev/null +++ b/apps/electron/src/renderer/workspace/DevWorkspaceShell.tsx @@ -0,0 +1,277 @@ +/** + * Three-panel coding workspace shell for the Electron app. + */ + +import { useTelemetry } from '@xnetjs/telemetry' +import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@xnetjs/ui' +import { ArrowLeft, Code2, RefreshCcw, Settings, Trash2 } from 'lucide-react' +import React, { useCallback, useEffect, useState } from 'react' +import { useActiveSession } from './hooks/useActiveSession' +import { usePreviewSelectedContext } from './hooks/usePreviewSelectedContext' +import { useSessionCommands } from './hooks/useSessionCommands' +import { useWorkspaceSessionSync } from './hooks/useWorkspaceSessionSync' +import { OpenCodePanel } from './OpenCodePanel' +import { consumeWorkspaceSessionSelectionDuration } from './performance' +import { PreviewWorkspace } from './PreviewWorkspace' +import { SessionRail } from './SessionRail' + +type DevWorkspaceShellProps = { + onReturnToCanvas: () => void + onOpenSettings: () => void +} + +type ShellNotice = { + tone: 'warning' | 'error' + message: string +} + +function createWorkspaceSessionTitle(index: number): string { + return `Workspace Session ${String(index).padStart(2, '0')}` +} + +export function DevWorkspaceShell({ + onReturnToCanvas, + onOpenSettings +}: DevWorkspaceShellProps): React.ReactElement { + const telemetry = useTelemetry({ component: 'electron.workspace.shell' }) + const { activeSession, activeSessionId, summaries, summariesLoading, summariesError, reload } = + useActiveSession() + const { + createWorkspaceSession, + refreshWorkspaceSession, + removeWorkspaceSession, + restartWorkspacePreview, + selectSession + } = useSessionCommands() + const [shellNotice, setShellNotice] = useState(null) + + useWorkspaceSessionSync({ + summaries, + activeSessionId + }) + usePreviewSelectedContext(activeSession) + + useEffect(() => { + if (!activeSessionId) { + return + } + + const duration = consumeWorkspaceSessionSelectionDuration(activeSessionId) + if (duration === null) { + return + } + + telemetry.reportPerformance('workspace.session.switch.visible', duration, 'electron.workspace') + if (duration > 50) { + telemetry.reportUsage('workspace.session.switch.slow', 1) + } + }, [activeSessionId, telemetry]) + + const captureShellError = useCallback((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + setShellNotice({ + tone: 'error', + message + }) + }, []) + + const handleCreateSession = useCallback(async () => { + const nextIndex = summaries.length + 1 + try { + await createWorkspaceSession({ + title: createWorkspaceSessionTitle(nextIndex) + }) + setShellNotice(null) + } catch (error) { + captureShellError(error) + } + }, [captureShellError, createWorkspaceSession, summaries.length]) + + const handleRefresh = useCallback(async () => { + try { + if (activeSession) { + await refreshWorkspaceSession(activeSession) + } else { + await reload() + } + setShellNotice(null) + } catch (error) { + captureShellError(error) + } + }, [activeSession, captureShellError, refreshWorkspaceSession, reload]) + + const handleRemoveActiveSession = useCallback(async () => { + if (!activeSession) { + return + } + + const confirmed = window.confirm( + `Remove ${activeSession.title ?? 'this session'} and its worktree? Clean worktrees only.` + ) + if (!confirmed) { + return + } + + try { + const result = await removeWorkspaceSession(activeSession) + if (!result.removed) { + setShellNotice({ + tone: result.dirty ? 'warning' : 'error', + message: result.message + }) + return + } + + setShellNotice(null) + } catch (error) { + captureShellError(error) + } + }, [activeSession, captureShellError, removeWorkspaceSession]) + + const handleRestartPreview = useCallback(async () => { + if (!activeSession) { + return + } + + try { + await restartWorkspacePreview(activeSession) + setShellNotice(null) + } catch (error) { + captureShellError(error) + } + }, [activeSession, captureShellError, restartWorkspacePreview]) + + const handleSelectSession = useCallback( + async (sessionId: string): Promise => { + try { + await selectSession(sessionId) + setShellNotice(null) + } catch (error) { + captureShellError(error) + } + }, + [captureShellError, selectSession] + ) + + return ( +
+
+ +
+
+
+
+
+ + Coding Workspace +
+ {activeSession ? ( +
+ {activeSession.title ?? 'Untitled session'} + / + {activeSession.branch ?? 'no-branch'} +
+ ) : null} +
+ +
+ {String(summaries.length)} sessions + + {activeSession ? ( + + ) : null} + + +
+
+
+ +
+ {shellNotice ? ( +
+ {shellNotice.message} +
+ ) : null} + +
+ + + { + void handleCreateSession() + }} + onRemoveSession={() => { + void handleRemoveActiveSession() + }} + onSelectSession={(sessionId) => { + void handleSelectSession(sessionId) + }} + /> + + + + + + + + + + + + { + void handleRefresh() + }} + onRestartPreview={() => { + void handleRestartPreview() + }} + /> + + +
+
+
+ ) +} diff --git a/apps/electron/src/renderer/workspace/OpenCodePanel.tsx b/apps/electron/src/renderer/workspace/OpenCodePanel.tsx new file mode 100644 index 00000000..37590577 --- /dev/null +++ b/apps/electron/src/renderer/workspace/OpenCodePanel.tsx @@ -0,0 +1,294 @@ +/** + * 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 { useTelemetry } from '@xnetjs/telemetry' +import { Badge, Button } from '@xnetjs/ui' +import { Code2, LoaderCircle, RefreshCcw, SquareTerminal } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useRef, 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 telemetry = useTelemetry({ component: 'electron.workspace.opencode' }) + const [status, setStatus] = useState(null) + const [lastOutput, setLastOutput] = useState('') + const [busy, setBusy] = useState(false) + const ensureStartRef = useRef(null) + const reportedStateRef = useRef(null) + + const ensureHost = useCallback(async (): Promise => { + setBusy(true) + ensureStartRef.current = performance.now() + + try { + const nextStatus = await window.xnetOpenCode.ensure() + setStatus(nextStatus) + return nextStatus + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)) + setLastOutput(normalized.message) + telemetry.reportCrash(normalized, { + codeNamespace: 'electron.workspace', + codeFunction: 'workspace.opencode.ensure' + }) + return null + } finally { + setBusy(false) + } + }, [telemetry]) + + 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]) + + useEffect(() => { + if (!status) { + return + } + + if (status.state === reportedStateRef.current) { + return + } + + reportedStateRef.current = status.state + + if (status.state === 'ready' && ensureStartRef.current !== null) { + const duration = performance.now() - ensureStartRef.current + telemetry.reportPerformance('workspace.opencode.ready', duration, 'electron.workspace') + if (duration > 50) { + telemetry.reportUsage('workspace.opencode.ready.slow', 1) + } + ensureStartRef.current = null + return + } + + if (status.state === 'missing-binary') { + telemetry.reportUsage('workspace.opencode.missing_binary', 1) + return + } + + if (status.state === 'error') { + telemetry.reportCrash(new Error(status.error), { + codeNamespace: 'electron.workspace', + codeFunction: 'workspace.opencode.ready' + }) + } + }, [status, telemetry]) + + 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} +
+