diff --git a/.gitignore b/.gitignore index 91ebcd26..34db38f4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ artifacts/coverage/ desktop/dist/ desktop/build-artifacts/ desktop/binaries/ +desktop/.bun-cache/ desktop/.electron-cache/ desktop/electron-dist/ desktop/electron-release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffc9df3..11f88ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to DreamCoder will be documented in this file. ### Fixed +- Building the desktop sidecar while Vite is running no longer crashes the Windows file watcher on Bun compiler temporary files, and concurrent dev launches no longer reuse an unrelated Vite instance. +- Project boards now keep distinct storage for project paths that sanitize to the same name, recover new events after a partial JSONL write, resolve cancellation dependencies atomically with optional dependent and subtask cascading, and keep delivered file links readable after the author session is deleted. - Permission prompts that ask every time, such as starting a session or editing files under `.git/`, no longer offer "Allow for this session", which could not take effect (#242). - Tools sent to providers through the OpenAI Responses API are no longer in strict mode, so their optional parameters stay optional (#242). - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. diff --git a/desktop/scripts/electron-dev-server.test.ts b/desktop/scripts/electron-dev-server.test.ts new file mode 100644 index 00000000..611819d0 --- /dev/null +++ b/desktop/scripts/electron-dev-server.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { assertDevServerAvailable, waitForDevServer } from './electron-dev-server' + +describe('electron dev server startup', () => { + it('refuses a port that already has an HTTP server', async () => { + await expect(assertDevServerAvailable('http://localhost:1420', vi.fn().mockResolvedValue({ ok: false }))).rejects.toThrow('already in use') + }) + + it('fails when the Vite child exits before it becomes ready', async () => { + await expect(waitForDevServer('http://localhost:1420', { exitCode: 1 }, { fetcher: vi.fn() })).rejects.toThrow('exited before startup with code 1') + }) + + it('accepts a stable response from the Vite child', async () => { + const sleep = vi.fn().mockResolvedValue(undefined) + await waitForDevServer('http://localhost:1420', { exitCode: null }, { fetcher: vi.fn().mockResolvedValue({ ok: true }), sleep }) + expect(sleep).toHaveBeenCalledWith(500) + }) +}) diff --git a/desktop/scripts/electron-dev-server.ts b/desktop/scripts/electron-dev-server.ts new file mode 100644 index 00000000..7a0d334f --- /dev/null +++ b/desktop/scripts/electron-dev-server.ts @@ -0,0 +1,47 @@ +type DevServerProcess = { readonly exitCode: number | null } + +type FetchDevServer = (url: string) => Promise<{ ok: boolean }> + +type WaitOptions = { + timeoutMs?: number + stabilizationMs?: number + fetcher?: FetchDevServer + sleep?: (milliseconds: number) => Promise +} + +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +export async function assertDevServerAvailable(url: string, fetcher: FetchDevServer = fetch): Promise { + let occupied = false + try { + await fetcher(url) + occupied = true + } catch { + occupied = false + } + if (occupied) throw new Error(`[electron-dev] ${url} is already in use. Stop the other dev server and try again.`) +} + +export async function waitForDevServer( + url: string, + process: DevServerProcess, + { timeoutMs = 60_000, stabilizationMs = 500, fetcher = fetch, sleep = delay }: WaitOptions = {}, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (process.exitCode !== null) throw new Error(`[electron-dev] Vite exited before startup with code ${process.exitCode}.`) + let ready = false + try { + ready = (await fetcher(url)).ok + } catch { + ready = false + } + if (ready) { + await sleep(stabilizationMs) + if (process.exitCode !== null) throw new Error(`[electron-dev] Vite exited during startup with code ${process.exitCode}.`) + return + } + await sleep(250) + } + throw new Error(`[electron-dev] Vite did not answer on ${url} within ${timeoutMs / 1000} seconds`) +} diff --git a/desktop/scripts/electron-dev.ts b/desktop/scripts/electron-dev.ts index 18e4d364..7b09a460 100644 --- a/desktop/scripts/electron-dev.ts +++ b/desktop/scripts/electron-dev.ts @@ -1,10 +1,10 @@ import path from 'node:path' import electronPath from 'electron' import type { Subprocess } from 'bun' +import { assertDevServerAvailable, waitForDevServer } from './electron-dev-server' const desktopRoot = path.resolve(import.meta.dir, '..') const devUrl = 'http://localhost:1420' -const startupTimeoutMs = 60_000 const children: Subprocess[] = [] function spawn(cmd: string[], env?: Record) { @@ -19,24 +19,13 @@ function stopAll() { } } -async function waitForDevServer() { - const deadline = Date.now() + startupTimeoutMs - while (Date.now() < deadline) { - try { - if ((await fetch(devUrl)).ok) return - } catch { - await Bun.sleep(250) - } - } - throw new Error(`[electron-dev] Vite did not answer on ${devUrl} within ${startupTimeoutMs / 1000} seconds`) -} - process.on('SIGINT', stopAll) process.on('SIGTERM', stopAll) try { - spawn(['bun', 'run', 'dev']) - await waitForDevServer() + await assertDevServerAvailable(devUrl) + const devServer = spawn(['bun', 'run', 'dev']) + await waitForDevServer(devUrl, devServer) if (await spawn(['bun', 'run', './scripts/build-electron.ts']).exited !== 0) { throw new Error('[electron-dev] building the main process failed') } diff --git a/desktop/src/api/kanban.ts b/desktop/src/api/kanban.ts index a3dc8e94..dd410c6f 100644 --- a/desktop/src/api/kanban.ts +++ b/desktop/src/api/kanban.ts @@ -1,5 +1,6 @@ import type { BoardListing, BoardView, KanbanCommand, KanbanEvent } from '@protocol' import { api } from './client' +import type { WorkspaceReadFileResult } from './sessions' export type BoardPoll = { changed: true; view: BoardView } | { changed: false; etag: string } @@ -29,4 +30,8 @@ export const kanbanApi = { history(key: string, card: number) { return api.get<{ events: KanbanEvent[] }>(`${boardPath(key)}/cards/${card}/history`) }, + + file(key: string, path: string) { + return api.get(`${boardPath(key)}/files?path=${encodeURIComponent(path)}`) + }, } diff --git a/desktop/src/components/kanban/CardLinks.tsx b/desktop/src/components/kanban/CardLinks.tsx index 0f365614..9edcab33 100644 --- a/desktop/src/components/kanban/CardLinks.tsx +++ b/desktop/src/components/kanban/CardLinks.tsx @@ -2,16 +2,16 @@ import type { KanbanActor } from '@protocol' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { linkTarget, type LinkTarget } from './board' -function openLink(panelId: string, target: LinkTarget, projectRoot: string) { +function openLink(panelId: string, board: string, target: LinkTarget, projectRoot: string) { const panel = useWorkspacePanelStore.getState() if (target.kind === 'file') { - panel.openTab(panelId, { kind: 'file', path: target.path, sessionId: target.sessionId }) + panel.openTab(panelId, { kind: 'file', path: target.path, board }) return } void panel.openUrl(panelId, target.url, projectRoot) } -export function CardLinks({ panelId, projectRoot, links, author }: { panelId: string; projectRoot: string; links: readonly string[]; author: KanbanActor }) { +export function CardLinks({ panelId, board, projectRoot, links, author }: { panelId: string; board: string; projectRoot: string; links: readonly string[]; author: KanbanActor }) { if (!links.length) return null return ( @@ -21,7 +21,7 @@ export function CardLinks({ panelId, projectRoot, links, author }: { panelId: st