Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions desktop/scripts/electron-dev-server.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
47 changes: 47 additions & 0 deletions desktop/scripts/electron-dev-server.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

const delay = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds))

export async function assertDevServerAvailable(url: string, fetcher: FetchDevServer = fetch): Promise<void> {
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<void> {
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`)
}
19 changes: 4 additions & 15 deletions desktop/scripts/electron-dev.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) {
Expand All @@ -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')
}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/api/kanban.ts
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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<WorkspaceReadFileResult>(`${boardPath(key)}/files?path=${encodeURIComponent(path)}`)
},
}
8 changes: 4 additions & 4 deletions desktop/src/components/kanban/CardLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="flex flex-col gap-0.5">
Expand All @@ -21,7 +21,7 @@ export function CardLinks({ panelId, projectRoot, links, author }: { panelId: st
<button
key={link}
type="button"
onClick={() => openLink(panelId, target, projectRoot)}
onClick={() => openLink(panelId, board, target, projectRoot)}
className="truncate text-left font-mono text-[12px] text-[var(--color-brand)] hover:underline"
>
{link}
Expand Down
14 changes: 8 additions & 6 deletions desktop/src/components/kanban/KanbanViews.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,21 @@ function MoveButton({ card, to }: { card: number; to: CardStatus }) {
}

describe('cancelling a task', () => {
it('lists the open tasks that wait on it and its unfinished subtasks before the person confirms', async () => {
it('lets the person choose whether to cancel dependents and unfinished subtasks', async () => {
kanbanApiMock.command.mockResolvedValue({ view: hierarchy(), warnings: [] })
render(<MoveButton card={3} to="cancelled" />)
fireEvent.click(screen.getByRole('button', { name: 'Move it' }))
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getAllByRole('note').map((note) => note.textContent)).toEqual(['kanban.prompt.cancelDependents #2'])
const dependents = within(dialog).getByRole('checkbox', { name: 'kanban.prompt.cancelDependents #2' })
expect(dependents).not.toBeChecked()
fireEvent.click(dependents)
fireEvent.click(within(dialog).getByRole('button', { name: 'kanban.action.cancelTask' }))
await waitFor(() => expect(kanbanApiMock.command).toHaveBeenCalledWith(FIXTURE_BOARD, { type: 'cancel', card: 3 }))
await waitFor(() => expect(kanbanApiMock.command).toHaveBeenCalledWith(FIXTURE_BOARD, { type: 'cancel', card: 3, cancelDependents: true, cancelChildren: false }))

cleanup()
render(<MoveButton card={1} to="cancelled" />)
fireEvent.click(screen.getByRole('button', { name: 'Move it' }))
expect(within(await screen.findByRole('alertdialog')).getAllByRole('note').map((note) => note.textContent)).toEqual(['kanban.prompt.cancelChildren #2'])
expect(within(await screen.findByRole('alertdialog')).getByRole('checkbox', { name: 'kanban.prompt.cancelChildren #2' })).not.toBeChecked()
})
})

Expand Down Expand Up @@ -424,7 +426,7 @@ describe('TaskDetail', () => {
expect(within(delivery).getByText('Token store').tagName).toBe('STRONG')
})

it('opens a delivered report file in the panel through the session that wrote it', () => {
it('opens a delivered report file in the panel through the board', () => {
const view = hierarchy()
const reviewed = { ...view.cards[2]!, deliveries: [{ id: 'd', summary: 'See the report', links: ['/repo/review.md', 'feat/tokens'], actor: { kind: 'session' as const, sessionId: 'worker' }, at: '' }] }
useKanbanStore.setState({ views: { [FIXTURE_BOARD]: { ...view, cards: [view.cards[0]!, view.cards[1]!, reviewed] } } })
Expand All @@ -433,7 +435,7 @@ describe('TaskDetail', () => {
expect(screen.queryByRole('button', { name: 'feat/tokens' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '/repo/review.md' }))

expect(useWorkspacePanelStore.getState().getActiveTab('board-tab')).toMatchObject({ kind: 'file', path: '/repo/review.md', sessionId: 'worker' })
expect(useWorkspacePanelStore.getState().getActiveTab('board-tab')).toMatchObject({ kind: 'file', path: '/repo/review.md', board: FIXTURE_BOARD })
})

it('opens a related task as another tab in the same panel', () => {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/components/kanban/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ function TaskDetailBody({ panelId, board, view, card }: { panelId: string; board
{card.deliveries.map((delivery) => (
<div key={delivery.id} className="flex flex-col gap-1 text-[13px]">
<ClampedMarkdown content={delivery.summary} />
<CardLinks panelId={panelId} projectRoot={view.project} links={delivery.links} author={delivery.actor} />
<CardLinks panelId={panelId} board={board} projectRoot={view.project} links={delivery.links} author={delivery.actor} />
</div>
))}
{card.status === 'review' && (
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/components/kanban/TaskTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function TaskTimeline({ panelId, board, view, card }: { panelId: string;
<MarkdownRenderer content={line.detail} variant="compact" className="break-words" />
</div>
)}
{line.links && <CardLinks panelId={panelId} projectRoot={view.project} links={line.links} author={event.actor} />}
{line.links && <CardLinks panelId={panelId} board={board} projectRoot={view.project} links={line.links} author={event.actor} />}
{line.resend !== undefined && (
<Button variant="outline" size="sm" className="self-start" onClick={() => void run({ type: 'resend_notification', notification: line.resend! })}>
{t('kanban.action.resend')}
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/components/kanban/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ describe('actorName', () => {
describe('linkTarget', () => {
const author = { kind: 'session', sessionId: 'reviewer' } as const

it('opens web addresses in the browser and absolute files through the session that wrote them', () => {
it('opens web addresses in the browser and absolute files through the board', () => {
expect(linkTarget('https://example.test/pr/1', author)).toEqual({ kind: 'url', url: 'https://example.test/pr/1' })
expect(linkTarget('/repo/docs/review.md', author)).toEqual({ kind: 'file', path: '/repo/docs/review.md', sessionId: 'reviewer' })
expect(linkTarget('/repo/docs/review.md', author)).toEqual({ kind: 'file', path: '/repo/docs/review.md' })
})

it('leaves branch names, relative paths and files from a person as plain text', () => {
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/components/kanban/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ export function cardTreeRows(cards: readonly CardView[], collapsed: ReadonlySet<
return rows
}

export type LinkTarget = { kind: 'url'; url: string } | { kind: 'file'; path: string; sessionId: string }
export type LinkTarget = { kind: 'url'; url: string } | { kind: 'file'; path: string }

export function linkTarget(link: string, author: KanbanActor): LinkTarget | null {
if (/^https?:\/\//i.test(link)) return { kind: 'url', url: link }
const absolute = link.startsWith('/') || /^[A-Za-z]:[\\/]/.test(link)
return absolute && author.kind === 'session' ? { kind: 'file', path: link, sessionId: author.sessionId } : null
return absolute && author.kind === 'session' ? { kind: 'file', path: link } : null
}
40 changes: 27 additions & 13 deletions desktop/src/components/kanban/useReasonPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toast } from 'sonner'
import type { CardStatus, CardView, KanbanCommand } from '@protocol'
import { useTranslation, type TranslationKey } from '../../i18n'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { Checkbox } from '../shared/ui/checkbox'
import { Textarea } from '../shared/ui/textarea'
import { STATUS_LABEL, cancelImpact, moveIntent } from './board'
import { useKanbanStore } from '../../stores/kanbanStore'
Expand All @@ -14,27 +15,33 @@ type Prompt = {
confirmLabel: string
required: boolean
destructive?: boolean
notes?: string[]
command: (text: string) => KanbanCommand
cancel?: { dependents: number[]; children: number[] }
command: (text: string, choices: CancelChoices) => KanbanCommand
}

type CancelChoices = { dependents: boolean; children: boolean }

function useReasonPrompt(board: string): { ask: (prompt: Prompt) => void; dialog: ReactNode } {
const t = useTranslation()
const run = useKanbanCommand(board)
const fieldId = useId()
const dependentsId = useId()
const childrenId = useId()
const [prompt, setPrompt] = useState<Prompt | null>(null)
const [text, setText] = useState('')
const [choices, setChoices] = useState<CancelChoices>({ dependents: false, children: false })
const [busy, setBusy] = useState(false)

const ask = useCallback((next: Prompt) => {
setText('')
setChoices({ dependents: false, children: false })
setPrompt(next)
}, [])

const confirm = async () => {
if (!prompt) return
setBusy(true)
const ok = await run(prompt.command(text.trim()))
const ok = await run(prompt.command(text.trim(), choices))
setBusy(false)
if (ok) setPrompt(null)
}
Expand All @@ -47,9 +54,18 @@ function useReasonPrompt(board: string): { ask: (prompt: Prompt) => void; dialog
title={prompt?.title ?? ''}
body={(
<div className="flex flex-col gap-2 pt-1">
{prompt?.notes?.map((note) => (
<p key={note} role="note" className="rounded-lg bg-[var(--color-warning-container)] px-3 py-2 text-[12px] text-[var(--color-text-primary)]">{note}</p>
))}
{prompt?.cancel?.dependents.length ? (
<div className="flex items-start gap-2 rounded-lg bg-[var(--color-warning-container)] px-3 py-2 text-[12px] text-[var(--color-text-primary)]">
<Checkbox id={dependentsId} checked={choices.dependents} onCheckedChange={(checked) => setChoices((current) => ({ ...current, dependents: checked === true }))} />
<label htmlFor={dependentsId}>{t('kanban.prompt.cancelDependents', { cards: prompt.cancel.dependents.map((id) => `#${id}`).join(' ') })}</label>
</div>
) : null}
{prompt?.cancel?.children.length ? (
<div className="flex items-start gap-2 rounded-lg bg-[var(--color-warning-container)] px-3 py-2 text-[12px] text-[var(--color-text-primary)]">
<Checkbox id={childrenId} checked={choices.children} onCheckedChange={(checked) => setChoices((current) => ({ ...current, children: checked === true }))} />
<label htmlFor={childrenId}>{t('kanban.prompt.cancelChildren', { cards: prompt.cancel.children.map((id) => `#${id}`).join(' ') })}</label>
</div>
) : null}
<label htmlFor={fieldId} className="text-[12px] text-[var(--color-text-secondary)]">{prompt?.label}</label>
<Textarea id={fieldId} value={text} onChange={(event) => setText(event.target.value)} rows={3} />
</div>
Expand Down Expand Up @@ -105,19 +121,17 @@ export function useMoveCard(board: string) {
const copy = REASON_PROMPTS[intent.type]
const view = useKanbanStore.getState().views[board]
const impact = intent.type === 'cancel' && view ? cancelImpact(view, card) : null
const cards = (ids: number[]) => ids.map((id) => `#${id}`).join(' ')
ask({
notes: impact ? [
...(impact.dependents.length ? [t('kanban.prompt.cancelDependents', { cards: cards(impact.dependents) })] : []),
...(impact.children.length ? [t('kanban.prompt.cancelChildren', { cards: cards(impact.children) })] : []),
] : undefined,
cancel: impact ?? undefined,
title: t(copy.title, { card: `#${card.id}` }),
label: t(copy.label),
confirmLabel: t(copy.confirm),
required: intent.required,
destructive: intent.type === 'cancel',
command: (reason) => intent.type === 'cancel' || intent.type === 'reopen'
? { type: intent.type, card: card.id, ...(reason ? { reason } : {}) }
command: (reason, choices) => intent.type === 'cancel'
? { type: 'cancel', card: card.id, cancelDependents: choices.dependents, cancelChildren: choices.children, ...(reason ? { reason } : {}) }
: intent.type === 'reopen'
? { type: intent.type, card: card.id, ...(reason ? { reason } : {}) }
: { type: intent.type, card: card.id, reason },
})
}, [ask, board, run, t])
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/components/workspace/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,11 @@ function FilesView({ sessionId }: { sessionId: string }) {
)
}

function FileTabView({ sessionId, path }: { sessionId: string; path: string }) {
function FileTabView({ sessionId, path, board }: { sessionId: string; path: string; board?: string }) {
const ensurePreview = useWorkspacePanelStore((state) => state.ensurePreview)
useEffect(() => {
void ensurePreview(sessionId, path, 'file')
}, [ensurePreview, path, sessionId])
void ensurePreview(sessionId, path, 'file', board)
}, [board, ensurePreview, path, sessionId])
return <PreviewBody sessionId={sessionId} path={path} kind="file" fill />
}

Expand Down Expand Up @@ -1129,7 +1129,7 @@ function OpenWorkspacePanel({ panelId: sessionId, sessionTitle, titleRowStyle, t
case 'artifacts':
return <ArtifactsView sessionId={sessionId} />
case 'file':
return <FileTabView sessionId={tab.sessionId ?? sessionId} path={tab.path} />
return <FileTabView sessionId={tab.sessionId ?? sessionId} path={tab.path} board={tab.board} />
case 'browser':
return <BrowserView sessionId={sessionId} tab={tab} projectRoot={projectRoot} />
case 'uiPage':
Expand Down
Loading
Loading