Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
### Fixed

- 新增 `.gitattributes` 统一文本文件为 `LF`,修复 Windows CI 中 `Prettier --check` 因 `CRLF` 检出导致的格式校验失败。
- 修复终态任务仍可再次触发 `Approve and run` 的问题,主进程会将其视为 no-op,渲染层也会在任务进入终态后禁用执行按钮。
- 修复 `Claude Code CLI` 适配器在同一 session 重复执行时复用事件 ID 的问题,避免时间线渲染出现重复 key。
- 为桌面主窗口补充受信导航策略,仅允许本地 `file://` 或开发服务器地址留在应用内,其它链接统一改走系统浏览器。

## [0.1.0] - 2026-03-25

Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/app/bootstrapDesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerDesktopIpcHandlers } from '../ipc/registerDesktopIpcHandlers'
import { FileDesktopStore } from '../persistence/fileDesktopStore'
import { SQLiteDesktopStore } from '../persistence/sqliteDesktopStore'
import { DesktopController } from '../services/desktopController'
import { isTrustedDesktopUrl } from './navigationPolicy'

declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined
declare const MAIN_WINDOW_VITE_NAME: string
Expand All @@ -29,6 +30,31 @@ const createMainWindow = () => {
}
})

const handleExternalNavigation = (url: string) => {
if (isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL)) {
return
}

void shell.openExternal(url)
}

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
handleExternalNavigation(url)

return {
action: isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL) ? 'allow' : 'deny'
}
})

mainWindow.webContents.on('will-navigate', (event, url) => {
if (isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL)) {
return
}

event.preventDefault()
handleExternalNavigation(url)
})

if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL)
mainWindow.webContents.openDevTools({ mode: 'detach' })
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/app/navigationPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @vitest-environment node

import { describe, expect, it } from 'vitest'

import { isTrustedDesktopUrl } from './navigationPolicy'

describe('isTrustedDesktopUrl', () => {
it('allows only the configured dev server during local development', () => {
expect(isTrustedDesktopUrl('http://127.0.0.1:5173/', 'http://127.0.0.1:5173')).toBe(true)
expect(isTrustedDesktopUrl('https://github.com/FruitsAI/Mango', 'http://127.0.0.1:5173')).toBe(
false
)
})

it('allows packaged file urls in production builds', () => {
expect(isTrustedDesktopUrl('file:///C:/Program%20Files/Mango/index.html')).toBe(true)
expect(isTrustedDesktopUrl('https://example.com')).toBe(false)
})
})
7 changes: 7 additions & 0 deletions apps/desktop/src/main/app/navigationPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isTrustedDesktopUrl = (url: string, devServerUrl?: string): boolean => {
if (devServerUrl) {
return url.startsWith(devServerUrl)
}

return url.startsWith('file://')
}
26 changes: 26 additions & 0 deletions apps/desktop/src/main/services/desktopController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,30 @@ describe('DesktopController', () => {
level: 'error'
})
})

it('treats approve and run as a no-op once the active task has already finished', async () => {
const store = new MemoryDesktopStore(createPersistedState())
const controller = new DesktopController(store, [
new FakeAdapter('claude-code', 'Claude Code CLI'),
new FakeAdapter('mock-claude', 'Mock Claude CLI')
])

await controller.generatePlan({
prompt: 'Implement adapter registry',
workspaceId: workspace.id,
adapterId: 'claude-code'
})

const completedState = await controller.approveAndRun()
const repeatedState = await controller.approveAndRun()

expect(completedState.activeTask?.status).toBe('succeeded')
expect(repeatedState.activeTask?.status).toBe('succeeded')
expect(repeatedState.activeTask?.events).toHaveLength(
completedState.activeTask?.events.length ?? 0
)
expect(repeatedState.activeTask?.executionSummary).toBe(
completedState.activeTask?.executionSummary
)
})
})
5 changes: 3 additions & 2 deletions apps/desktop/src/main/services/desktopController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,13 @@ export class DesktopController {

public async approveAndRun(): Promise<DesktopState> {
const persisted = await this.ensureState()
const activeTask = persisted.activeTask

if (!persisted.activeTask?.plan) {
if (!activeTask?.plan || !['planned', 'approved'].includes(activeTask.status)) {
return this.buildDesktopState(persisted)
}

let session = persisted.activeTask
let session = activeTask

if (session.status === 'planned') {
session = applyTaskTransition(session, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const TaskWorkbench = ({
selectedAdapterId ?? selectedWorkspace?.providerConfig?.primaryAdapterId
)
const activePlan = state.activeTask?.plan
const canApproveAndRun =
Boolean(activePlan) &&
(state.activeTask?.status === 'planned' || state.activeTask?.status === 'approved')

return (
<div className="shell">
Expand Down Expand Up @@ -178,7 +181,7 @@ export const TaskWorkbench = ({
</button>
<button
className="secondary-button"
disabled={busy || !state.activeTask?.plan}
disabled={busy || !canApproveAndRun}
onClick={onApproveAndRun}
type="button"
>
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/test/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,10 @@ describe('TaskWorkbench', () => {
screen.getByText('Claude Code CLI is available for local Mango execution.')
).toBeInTheDocument()
})

it('disables approve and run after the task has already reached a terminal state', () => {
render(<TaskWorkbench state={buildMockDesktopState()} />)

expect(screen.getByRole('button', { name: /approve and run/i })).toBeDisabled()
})
})
8 changes: 6 additions & 2 deletions packages/adapters/src/claudeCodeCliAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

Expand Down Expand Up @@ -94,6 +95,9 @@ const planSchema = {
const timestamp = (offsetMilliseconds = 0): string =>
new Date(Date.now() + offsetMilliseconds).toISOString()

const createExecutionEventId = (sessionId: string, suffix: string): string =>
`${sessionId}-${randomUUID()}-${suffix}`

const normalizeText = (value: string): string => value.trim()

const buildCommandErrorMessage = (
Expand Down Expand Up @@ -272,14 +276,14 @@ export class ClaudeCodeCliAdapter implements AgentAdapter {
return [
{
type: 'terminal.output',
id: `${input.sessionId}-claude-output`,
id: createExecutionEventId(input.sessionId, 'claude-output'),
level: 'info',
message: `${this.label} completed the approved run`,
createdAt: timestamp()
},
{
type: 'summary.ready',
id: `${input.sessionId}-claude-summary`,
id: createExecutionEventId(input.sessionId, 'claude-summary'),
level: 'info',
message: `${this.label} summary ready`,
summary,
Expand Down
24 changes: 24 additions & 0 deletions packages/adapters/tests/claudeCodeCliAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,28 @@ describe('ClaudeCodeCliAdapter', () => {
})
)
})

it('emits unique event ids across repeated approved runs for the same session', async () => {
const commandRunner = vi.fn(async () => ({
exitCode: 0,
stdout: 'Updated the adapter registry and verified the desktop workflow.',
stderr: ''
}))
const adapter = new ClaudeCodeCliAdapter({
commandRunner
})

const firstRunEvents = await adapter.runApprovedPlan({
sessionId: 'session-1',
prompt: 'Implement adapter registry',
workspace
})
const secondRunEvents = await adapter.runApprovedPlan({
sessionId: 'session-1',
prompt: 'Implement adapter registry',
workspace
})

expect(new Set([...firstRunEvents, ...secondRunEvents].map((event) => event.id)).size).toBe(4)
})
})
Loading