diff --git a/docs/learnings/2026-04-27-fleet-bridge-handler-type.md b/docs/learnings/2026-04-27-fleet-bridge-handler-type.md new file mode 100644 index 0000000..b24dc43 --- /dev/null +++ b/docs/learnings/2026-04-27-fleet-bridge-handler-type.md @@ -0,0 +1,16 @@ +# Fleet bridge handler return type and lint + +## Symptom + +While adding the Pi plan-open bridge command, changing `FleetBridgeServer`'s `RequestHandler` from `Promise` to `unknown | Promise` made lint report: + +- `@typescript-eslint/no-redundant-type-constituents` because `unknown` absorbs the union +- `@typescript-eslint/await-thenable` at the call site that awaited the handler result + +## Fix + +Keep `RequestHandler` as `Promise` and keep bridge request handlers `async`. If a handler is currently synchronous, use a real async boundary rather than changing the framework type. + +## Lesson + +Do not use `unknown | Promise` for async callback return types in this codebase. It looks flexible, but lint treats `unknown` as overriding the promise branch and then flags awaited values as non-thenable. diff --git a/docs/learnings/2026-04-28-pi-plan-modal-disconnect.md b/docs/learnings/2026-04-28-pi-plan-modal-disconnect.md new file mode 100644 index 0000000..62b944a --- /dev/null +++ b/docs/learnings/2026-04-28-pi-plan-modal-disconnect.md @@ -0,0 +1,21 @@ +# Pi plan modal bridge disconnect handling + +## Symptom + +The Pi plan modal response flow initially assumed that once `pi.plan_open` returned, the Fleet bridge would stay connected until the user clicked Approve/Reject/Continue. If the WebSocket disconnected after opening the modal but before the response, `exit_plan_mode` could wait forever. After adding delivery failure checks, the modal could also become effectively unclosable because every dismissal path tried to send `continue` through the disconnected bridge. + +## Fix + +- Add a disconnect callback to the Fleet Pi bridge extension client. +- Have pending `exit_plan_mode` response waiters resolve as `continue` on bridge disconnect, matching abort behavior. +- Make renderer-side response delivery failures visible but non-blocking by showing an error with a direct Dismiss escape hatch. +- When invoking disconnect handlers, iterate over a copy so handlers can unsubscribe themselves without skipping later handlers. + +## Lesson + +For two-way UI flows over an agent bridge, handle all lifecycle edges explicitly: + +1. Request opened but response never arrives. +2. Response send fails after user action. +3. User needs a local dismiss path even if the agent can no longer be notified. +4. Event handler arrays must tolerate handlers unregistering during dispatch. diff --git a/resources/pi-extensions/fleet-bridge.ts b/resources/pi-extensions/fleet-bridge.ts index e98b692..94a7127 100644 --- a/resources/pi-extensions/fleet-bridge.ts +++ b/resources/pi-extensions/fleet-bridge.ts @@ -16,6 +16,7 @@ const MAX_RECONNECT_ATTEMPTS = 10; export type BridgeClient = { send: (type: string, payload: Record) => Promise; onEvent: (handler: (type: string, payload: Record) => void) => void; + onDisconnect: (handler: () => void) => () => void; isConnected: () => boolean; }; @@ -37,6 +38,7 @@ export default function (_pi: ExtensionAPI): void { let requestId = 0; const pending = new Map void; reject: (e: Error) => void }>(); const eventHandlers: Array<(type: string, payload: Record) => void> = []; + const disconnectHandlers: Array<() => void> = []; function connect(): void { try { @@ -73,6 +75,9 @@ export default function (_pi: ExtensionAPI): void { ws.onclose = () => { ws = null; + for (const handler of [...disconnectHandlers]) { + handler(); + } if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; setTimeout(connect, RECONNECT_DELAY_MS); @@ -109,6 +114,13 @@ export default function (_pi: ExtensionAPI): void { onEvent(handler) { eventHandlers.push(handler); }, + onDisconnect(handler) { + disconnectHandlers.push(handler); + return () => { + const index = disconnectHandlers.indexOf(handler); + if (index >= 0) disconnectHandlers.splice(index, 1); + }; + }, isConnected() { return ws !== null && ws.readyState === WebSocket.OPEN; } diff --git a/resources/pi-extensions/fleet-plan-mode-policy.ts b/resources/pi-extensions/fleet-plan-mode-policy.ts index d554835..5f43b1a 100644 --- a/resources/pi-extensions/fleet-plan-mode-policy.ts +++ b/resources/pi-extensions/fleet-plan-mode-policy.ts @@ -1,5 +1,3 @@ -const PLAN_PREVIEW_LINES = 60; - const BLOCKED_IN_PLAN = new Set(['write', 'edit', 'bash', 'fleet_run']); const REQUIRED_PLAN_MODE_TOOLS = ['read', 'grep', 'find', 'ls', 'fleet_open', 'exit_plan_mode']; @@ -15,13 +13,6 @@ export function getPlanModeActiveTools(currentActiveTools: string[]): string[] { return next; } -function previewPlan(plan: string): string { - const lines = plan.split('\n'); - if (lines.length <= PLAN_PREVIEW_LINES) return plan; - const remaining = lines.length - PLAN_PREVIEW_LINES; - return `${lines.slice(0, PLAN_PREVIEW_LINES).join('\n')}\n\n(${remaining} more lines)`; -} - -export function buildPlanApprovalMessage(planPath: string, plan: string): string { - return `Path: ${planPath}\n\n---\n\n${previewPlan(plan)}`; +export function buildPlanApprovalMessage(planPath: string): string { + return `Plan written to:\n${planPath}\n\nReview it in the Fleet plan modal, then approve when ready.`; } diff --git a/resources/pi-extensions/fleet-plan-mode.ts b/resources/pi-extensions/fleet-plan-mode.ts index d9fed79..793c6ef 100644 --- a/resources/pi-extensions/fleet-plan-mode.ts +++ b/resources/pi-extensions/fleet-plan-mode.ts @@ -6,7 +6,7 @@ * swapped to hide write/exec tools via pi.setActiveTools, with a * tool_call blocker as a final policy gate. The LLM * produces a markdown plan via the exit_plan_mode tool; the plan is - * written to docs/plans/YYYY-MM-DD-.md after the user approves. + * written to docs/plans/YYYY-MM-DD-.md and opened in Fleet's plan modal. * * Also registers pi's built-in grep/find/ls tools, which are not in * the default toolset. @@ -20,6 +20,7 @@ import { type ExtensionContext } from '@mariozechner/pi-coding-agent'; import { Type } from '@sinclair/typebox'; +import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -28,6 +29,17 @@ import { shouldBlockPlanModeTool } from './fleet-plan-mode-policy.ts'; +type FleetBridgeClient = { + send: (type: string, payload: Record) => Promise; + onEvent: (handler: (type: string, payload: Record) => void) => void; + onDisconnect: (handler: () => void) => () => void; + isConnected: () => boolean; +}; + +declare global { + var __fleetBridge: FleetBridgeClient | null; // eslint-disable-line no-var +} + const PLAN_MODE_STATUS_KEY = 'plan-mode'; const PLAN_MODE_STATUS_LABEL = 'πŸ“‹ Plan Mode'; @@ -49,13 +61,65 @@ You are in plan mode. Only read-only tools are available (write, edit, bash, fle 7. YAGNI. Plan only what's asked. No speculative features, flags, or abstractions. -When you have enough that another engineer could execute without asking questions, call exit_plan_mode.`; +When you have enough that another engineer could execute without asking questions, call exit_plan_mode. The plan will be written to a markdown file and opened in Fleet for review; do not include the full plan in a normal assistant message.`; const PLAN_MODE_BLOCK_REASON = 'Plan mode is active β€” this tool is disabled. Use read-only tools to investigate, then call exit_plan_mode with your plan.'; const TOPIC_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +type PlanReviewAction = 'approve' | 'reject' | 'continue'; + +type PlanReviewResponse = { + action: PlanReviewAction; + feedback?: string; +}; + +type PendingPlanResponse = { + resolve: (response: PlanReviewResponse) => void; +}; + +const pendingPlanResponses = new Map(); + +function isPlanReviewAction(value: unknown): value is PlanReviewAction { + return value === 'approve' || value === 'reject' || value === 'continue'; +} + +function createPlanResponseWaiter( + requestId: string, + signal: AbortSignal | undefined, + bridge: FleetBridgeClient +) { + let abortHandler: (() => void) | undefined; + let unsubscribeDisconnect: (() => void) | undefined; + let cleanup = () => { + pendingPlanResponses.delete(requestId); + }; + + const promise = new Promise((resolve) => { + cleanup = () => { + pendingPlanResponses.delete(requestId); + if (abortHandler) signal?.removeEventListener('abort', abortHandler); + unsubscribeDisconnect?.(); + }; + const finish = (response: PlanReviewResponse) => { + cleanup(); + resolve(response); + }; + abortHandler = () => finish({ action: 'continue' }); + unsubscribeDisconnect = bridge.onDisconnect(() => finish({ action: 'continue' })); + + pendingPlanResponses.set(requestId, { resolve: finish }); + if (signal?.aborted || !bridge.isConnected()) { + abortHandler(); + return; + } + signal?.addEventListener('abort', abortHandler, { once: true }); + }); + + return { promise, cancel: cleanup }; +} + const ExitPlanModeParams = Type.Object({ plan: Type.String({ description: @@ -95,6 +159,18 @@ export default function (pi: ExtensionAPI): void { pi.registerTool(createFindToolDefinition(cwd)); pi.registerTool(createLsToolDefinition(cwd)); + globalThis.__fleetBridge?.onEvent((type, payload) => { + if (type !== 'pi.plan_response') return; + const requestId = typeof payload.requestId === 'string' ? payload.requestId : ''; + const action = payload.action; + if (!requestId || !isPlanReviewAction(action)) return; + + pendingPlanResponses.get(requestId)?.resolve({ + action, + feedback: typeof payload.feedback === 'string' ? payload.feedback : undefined + }); + }); + function enterPlanMode(ctx: ExtensionContext): void { const activeTools = pi.getActiveTools(); savedActiveTools = activeTools; @@ -151,6 +227,10 @@ export default function (pi: ExtensionAPI): void { }); pi.on('session_shutdown', async (_event, ctx) => { + for (const pending of pendingPlanResponses.values()) { + pending.resolve({ action: 'continue' }); + } + pendingPlanResponses.clear(); if (planMode || savedActiveTools) leavePlanMode(ctx); }); @@ -171,10 +251,10 @@ export default function (pi: ExtensionAPI): void { name: 'exit_plan_mode', label: 'Exit Plan Mode', description: - 'Call this when you have a complete plan ready for the user. Writes the plan to docs/plans/YYYY-MM-DD-.md after the user approves it, then exits plan mode so you can begin executing. Pass the plan as markdown in `plan` and a short kebab-case topic in `topic`.', + 'Call this when you have a complete plan ready for the user. Writes the plan to docs/plans/YYYY-MM-DD-.md, opens it in Fleet for review, then exits plan mode after approval so you can begin executing. Pass the plan as markdown in `plan` and a short kebab-case topic in `topic`.', parameters: ExitPlanModeParams, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (!planMode) { return { content: [ @@ -200,27 +280,49 @@ export default function (pi: ExtensionAPI): void { } const planPath = resolvePlanPath(ctx.cwd, params.topic); - const approved = await ctx.ui.confirm( - 'Approve plan?', - buildPlanApprovalMessage(planPath, params.plan) - ); + const dir = join(ctx.cwd, 'docs', 'plans'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(planPath, params.plan, 'utf-8'); + + let review: PlanReviewResponse | null = null; + const bridge = globalThis.__fleetBridge; + if (bridge?.isConnected()) { + const requestId = randomUUID(); + const responseWaiter = createPlanResponseWaiter(requestId, signal, bridge); + try { + await bridge.send('pi.plan_open', { path: planPath, requestId }); + review = await responseWaiter.promise; + } catch (err) { + responseWaiter.cancel(); + ctx.ui.notify( + `Plan written, but Fleet could not open it: ${err instanceof Error ? err.message : String(err)}`, + 'warning' + ); + } + } else { + ctx.ui.notify('Plan written, but Fleet bridge is not connected.', 'warning'); + } + + if (!review) { + const approved = await ctx.ui.confirm('Approve plan?', buildPlanApprovalMessage(planPath)); + review = { action: approved ? 'approve' : 'reject' }; + } - if (!approved) { + if (review.action !== 'approve') { + const feedback = review.feedback?.trim(); + const actionText = + review.action === 'reject' ? 'rejected the plan' : 'requested more planning'; return { content: [ { type: 'text' as const, - text: 'User rejected the plan. Revise based on their feedback and call exit_plan_mode again when ready.' + text: `User ${actionText} for ${planPath}.${feedback ? ` Feedback: ${feedback}` : ''} Revise based on their feedback and call exit_plan_mode again when ready.` } ], details: undefined }; } - const dir = join(ctx.cwd, 'docs', 'plans'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(planPath, params.plan, 'utf-8'); - leavePlanMode(ctx); return { diff --git a/src/main/__tests__/fleet-cli.test.ts b/src/main/__tests__/fleet-cli.test.ts index 86c218a..41a509b 100644 --- a/src/main/__tests__/fleet-cli.test.ts +++ b/src/main/__tests__/fleet-cli.test.ts @@ -204,6 +204,23 @@ describe('--help via runCLI', () => { }); }); +// ── fleet pi plan_open tests ───────────────────────────────────────────────── + +describe('fleet pi plan_open', () => { + it('requires a path before opening the socket', async () => { + const out = await runCLI(['pi', 'plan_open'], '/tmp/no-socket.sock'); + expect(out).toBe('Usage: fleet pi plan_open '); + }); + + it('validates the plan file exists before opening the socket', async () => { + const out = await runCLI( + ['pi', 'plan_open', '/tmp/fleet-missing-plan.md'], + '/tmp/no-socket.sock' + ); + expect(out).toContain('file not found'); + }); +}); + // ── FleetCLI.send basic tests ───────────────────────────────────────────────── describe('FleetCLI.send', () => { diff --git a/src/main/__tests__/fleet-plan-mode-extension.test.ts b/src/main/__tests__/fleet-plan-mode-extension.test.ts index dea6902..9009eb9 100644 --- a/src/main/__tests__/fleet-plan-mode-extension.test.ts +++ b/src/main/__tests__/fleet-plan-mode-extension.test.ts @@ -27,14 +27,11 @@ describe('fleet plan mode extension helpers', () => { expect(shouldBlockPlanModeTool('read')).toBe(false); }); - it('builds approval text with target path and a 60-line plan preview', () => { - const plan = Array.from({ length: 62 }, (_, index) => `Line ${index + 1}`).join('\n'); - const message = buildPlanApprovalMessage('/repo/docs/plans/2026-04-25-demo.md', plan); + it('builds approval text with target path but no plan body', () => { + const message = buildPlanApprovalMessage('/repo/docs/plans/2026-04-25-demo.md'); - expect(message).toContain('Path: /repo/docs/plans/2026-04-25-demo.md'); - expect(message).toContain('Line 1'); - expect(message).toContain('Line 60'); - expect(message).not.toContain('Line 61'); - expect(message).toContain('(2 more lines)'); + expect(message).toContain('/repo/docs/plans/2026-04-25-demo.md'); + expect(message).toContain('Review it in the Fleet plan modal'); + expect(message).not.toContain('Line 1'); }); }); diff --git a/src/main/fleet-bridge.ts b/src/main/fleet-bridge.ts index ab180ef..d38ad9e 100644 --- a/src/main/fleet-bridge.ts +++ b/src/main/fleet-bridge.ts @@ -125,11 +125,11 @@ export class FleetBridgeServer { } } - sendEvent(paneId: string, event: BridgeEvent): void { + sendEvent(paneId: string, event: BridgeEvent): boolean { const ws = this.connections.get(paneId); - if (ws && ws.readyState === ws.OPEN) { - ws.send(JSON.stringify(event)); - } + if (!ws || ws.readyState !== ws.OPEN) return false; + ws.send(JSON.stringify(event)); + return true; } broadcast(event: BridgeEvent): void { diff --git a/src/main/fleet-cli.ts b/src/main/fleet-cli.ts index 4e5e520..8371a4f 100644 --- a/src/main/fleet-cli.ts +++ b/src/main/fleet-cli.ts @@ -356,6 +356,7 @@ Manage images and open files from the terminal. | images | Generate, edit, and transform AI images. | | open | Open files or images in Fleet tabs. | | annotate | Visually annotate web page elements for AI agents. | +| pi | Open Pi agent tabs and Pi plan documents. | ## Examples @@ -391,6 +392,27 @@ Supports code files and common image formats (png, jpg, gif, webp, svg). fleet open src/main.ts fleet open screenshot.png diagram.svg fleet open ./README.md ../other-repo/notes.txt +\`\`\``, + + pi: `# fleet pi + +Open Pi agent tabs and Pi plan documents. + +## Usage + + fleet pi + fleet pi plan_open + +## Commands + + fleet pi Open a Pi agent tab for the current directory. + fleet pi plan_open Open a markdown plan in Fleet's plan modal. + +## Examples + +\`\`\`bash +fleet pi +fleet pi plan_open docs/plans/2026-04-27-my-plan.md \`\`\``, annotate: `# fleet annotate @@ -583,9 +605,29 @@ export async function runCLI( // ── Top-level "pi" command ─────────────────────────────────────────────── if (group === 'pi') { - const cwd = process.cwd(); - const command = 'pi.open'; - const args: Record = { cwd }; + const command = action === 'plan_open' ? 'pi.plan_open' : 'pi.open'; + const args: Record = {}; + let successMessage = 'Opening Pi agent in Fleet'; + + if (command === 'pi.plan_open') { + const rawPath = rest[0]; + if (!rawPath) return 'Usage: fleet pi plan_open '; + + const resolved = resolve(rawPath); + if (!existsSync(resolved)) return `Error: file not found: ${rawPath}`; + if (statSync(resolved).isDirectory()) { + return `Error: directories not supported, use a file path: ${rawPath}`; + } + if (isBinaryBlockedFilePath(resolved)) { + return `Error: unsupported binary file: ${rawPath}`; + } + + args.path = resolved; + successMessage = `Opened plan in Fleet: ${resolved}`; + } else { + if (action) return `Unknown pi command: ${action}\n\nUsage: fleet pi [plan_open ]`; + args.cwd = process.cwd(); + } const cli = new FleetCLI(sockPath); try { @@ -595,7 +637,7 @@ export async function runCLI( if (!response.ok) { return `Error: ${response.error ?? 'Unknown error'}`; } - return 'Opening Pi agent in Fleet'; + return successMessage; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('ECONNREFUSED') || msg.includes('ENOENT')) { diff --git a/src/main/index.ts b/src/main/index.ts index 69df542..3ebdbad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -349,6 +349,11 @@ void app.whenReady().then(async () => { mainWindow.webContents.send(IPC_CHANNELS.PI_OPEN, payload); } }); + socketSupervisor.on('pi-plan-open', (payload: unknown) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, payload); + } + }); socketSupervisor.start().catch((err: unknown) => { log.error('socket-supervisor failed to start', { error: err instanceof Error ? err.message : String(err) @@ -356,7 +361,8 @@ void app.whenReady().then(async () => { }); // Start Fleet bridge for Pi agent extensions - fleetBridge.onRequest(async (type, payload, _paneId) => { + fleetBridge.onRequest(async (type, payload, paneId) => { + await Promise.resolve(); switch (type) { case 'file.open': { const rawPath = typeof payload.path === 'string' ? payload.path : ''; @@ -379,6 +385,29 @@ void app.whenReady().then(async () => { } return { ok: true, paneType }; } + case 'pi.plan_open': { + const rawPath = typeof payload.path === 'string' ? payload.path : ''; + const requestId = typeof payload.requestId === 'string' ? payload.requestId : undefined; + if (!rawPath) throw new Error('pi.plan_open requires a path'); + + const planPath = resolve(rawPath); + if (!existsSync(planPath)) throw new Error(`file not found: ${planPath}`); + if (statSync(planPath).isDirectory()) { + throw new Error(`directories not supported, use a file path: ${planPath}`); + } + if (isBinaryBlockedFilePath(planPath)) { + throw new Error(`unsupported binary file: ${planPath}`); + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, { + path: planPath, + paneId, + requestId + }); + } + return { ok: true }; + } default: throw new Error(`Unknown bridge command: ${type}`); } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 668596c..a58c175 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -26,7 +26,8 @@ import type { FileGrepRequest, LogEntry, WorktreeCreateRequest, - WorktreeRemoveRequest + WorktreeRemoveRequest, + PiPlanResponseRequest } from '../shared/ipc-api'; import type { Workspace } from '../shared/types'; import type { PtyManager } from './pty-manager'; @@ -548,6 +549,20 @@ export function registerIpcHandlers( installed: piAgentManager.isInstalled() })); + ipcMain.handle(IPC_CHANNELS.PI_PLAN_RESPOND, (_event, req: PiPlanResponseRequest) => { + const delivered = fleetBridge.sendEvent(req.paneId, { + type: 'pi.plan_response', + payload: { + requestId: req.requestId, + action: req.action, + feedback: req.feedback ?? '' + } + }); + if (!delivered) { + throw new Error('Pi plan response could not be delivered. The Pi bridge is disconnected.'); + } + }); + ipcMain.handle(IPC_CHANNELS.PI_CHECK_UPDATES, async () => { return piAgentManager.checkForUpdates(); }); diff --git a/src/main/socket-server.ts b/src/main/socket-server.ts index 3ed0874..e1988e6 100644 --- a/src/main/socket-server.ts +++ b/src/main/socket-server.ts @@ -379,6 +379,13 @@ export class SocketServer extends EventEmitter { return { ok: true }; } + case 'pi.plan_open': { + const planPath = typeof args.path === 'string' ? args.path : undefined; + if (!planPath) throw new CodedError('pi.plan_open requires a path', 'BAD_REQUEST'); + this.emit('pi-plan-open', { path: planPath }); + return { ok: true }; + } + default: { throw new CodedError(`Unknown command: ${command}`, 'NOT_FOUND'); } diff --git a/src/main/socket-supervisor.ts b/src/main/socket-supervisor.ts index d3fd879..6f06fa5 100644 --- a/src/main/socket-supervisor.ts +++ b/src/main/socket-supervisor.ts @@ -101,6 +101,10 @@ export class SocketSupervisor extends EventEmitter { this.emit('pi-open', ...args); }); + server.on('pi-plan-open', (...args: unknown[]) => { + this.emit('pi-plan-open', ...args); + }); + server.on('server-error', (err: Error) => { log.error('Server error detected', { error: err.message }); this.restart().catch((e) => diff --git a/src/preload/index.ts b/src/preload/index.ts index 0178eac..8ffd7f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,8 @@ import type { WorktreeCreateResponse, WorktreeRemoveRequest, PiOpenPayload, + PiPlanOpenPayload, + PiPlanResponseRequest, PiLaunchConfig } from '../shared/ipc-api'; import type { @@ -332,6 +334,10 @@ const fleetApi = { pi: { onOpen: (callback: (payload: PiOpenPayload) => void): Unsubscribe => onChannel(IPC_CHANNELS.PI_OPEN, callback), + onPlanOpen: (callback: (payload: PiPlanOpenPayload) => void): Unsubscribe => + onChannel(IPC_CHANNELS.PI_PLAN_OPEN, callback), + respondToPlan: async (req: PiPlanResponseRequest): Promise => + typedInvoke(IPC_CHANNELS.PI_PLAN_RESPOND, req), getLaunchConfig: async (paneId: string): Promise => typedInvoke(IPC_CHANNELS.PI_LAUNCH_CONFIG, { paneId }), getVersion: async (): Promise<{ version: string | null; installed: boolean }> => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ac52a59..8f28f39 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,8 +28,12 @@ import { TelescopeModal } from './components/Telescope/TelescopeModal'; import { ImageGallery } from './components/ImageGallery/ImageGallery'; import { AnnotateTab } from './components/AnnotateTab'; import { PiTab } from './components/PiTab'; +import { PiPlanModal } from './components/PiPlanModal'; import { AnnotateModal } from './components/AnnotateModal'; import { ToastContainer } from './components/ToastContainer'; +import type { PiPlanOpenPayload } from '../../shared/ipc-api'; + +type PiPlanModalEntry = PiPlanOpenPayload & { modalId: string }; function MiniSidebarTooltip({ label, @@ -130,6 +134,7 @@ export function App(): React.JSX.Element { const [fileSearchOpen, setFileSearchOpen] = useState(false); const [clipboardHistoryOpen, setClipboardHistoryOpen] = useState(false); const [telescopeOpen, setTelescopeOpen] = useState(false); + const [planModalQueue, setPlanModalQueue] = useState([]); const [updateReady, setUpdateReady] = useState(false); // Load settings on startup @@ -266,6 +271,21 @@ export function App(): React.JSX.Element { }; }, []); + // Open Pi plan document in modal via IPC (fleet pi plan_open / Pi extension bridge) + useEffect(() => { + const cleanup = window.fleet.pi.onPlanOpen((payload) => { + setPlanModalQueue((queue) => [...queue, { ...payload, modalId: crypto.randomUUID() }]); + }); + return () => { + cleanup(); + }; + }, []); + + const activePlanModal = planModalQueue[0] ?? null; + const closeActivePlanModal = useCallback(() => { + setPlanModalQueue((queue) => queue.slice(1)); + }, []); + // Auto-updater useEffect(() => { const cleanup = window.fleet.updates.onUpdateStatus((status) => { @@ -859,6 +879,11 @@ export function App(): React.JSX.Element { cwd={focusedPaneCwd ?? window.fleet.homeDir} /> {}} /> + ); diff --git a/src/renderer/src/components/PiPlanModal.tsx b/src/renderer/src/components/PiPlanModal.tsx new file mode 100644 index 0000000..be1d273 --- /dev/null +++ b/src/renderer/src/components/PiPlanModal.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ExternalLink, X } from 'lucide-react'; +import { MarkdownPane } from './MarkdownPane'; +import { useWorkspaceStore } from '../store/workspace-store'; +import { getPaneTypeForFilePath } from '../../../shared/file-open'; +import type { PiPlanAction, PiPlanOpenPayload } from '../../../shared/ipc-api'; + +type PiPlanModalProps = { + plan: PiPlanOpenPayload | null; + contentKey?: string; + onClose: () => void; +}; + +export function PiPlanModal({ + plan, + contentKey, + onClose +}: PiPlanModalProps): React.JSX.Element | null { + const modalRef = useRef(null); + const paneIdRef = useRef(`pi-plan-modal-${crypto.randomUUID()}`); + const [feedback, setFeedback] = useState(''); + const [submitError, setSubmitError] = useState(null); + const [submittingAction, setSubmittingAction] = useState(null); + const openFileInTab = useWorkspaceStore((s) => s.openFileInTab); + + const filePath = plan?.path ?? null; + const canRespond = Boolean(plan?.paneId && plan.requestId); + + useEffect(() => { + if (!filePath) return; + setFeedback(''); + setSubmitError(null); + setSubmittingAction(null); + modalRef.current?.focus(); + }, [filePath, plan?.requestId]); + + const respond = useCallback( + async (action: PiPlanAction) => { + if (!plan?.paneId || !plan.requestId) { + onClose(); + return; + } + + setSubmittingAction(action); + setSubmitError(null); + try { + await window.fleet.pi.respondToPlan({ + paneId: plan.paneId, + requestId: plan.requestId, + action, + feedback: feedback.trim() || undefined + }); + onClose(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmittingAction(null); + } + }, + [feedback, onClose, plan?.paneId, plan?.requestId] + ); + + const closeOrContinue = useCallback(() => { + if (canRespond && !submitError) { + void respond('continue'); + return; + } + onClose(); + }, [canRespond, onClose, respond, submitError]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + closeOrContinue(); + }, + [closeOrContinue] + ); + + const handleOpenInTab = useCallback(() => { + if (!filePath) return; + openFileInTab([ + { + path: filePath, + paneType: getPaneTypeForFilePath(filePath), + label: filePath.split('/').pop() ?? filePath + } + ]); + }, [filePath, openFileInTab]); + + const footerHint = useMemo(() => { + if (!canRespond) return 'Opened from Fleet CLI. This modal is read-only.'; + return 'Approve exits plan mode. Reject or Continue keeps Pi in plan mode with your feedback.'; + }, [canRespond]); + + if (!filePath) return null; + + return ( +
+
event.stopPropagation()} + className="bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl flex flex-col outline-none overflow-hidden" + style={{ width: 'calc(100vw - 64px)', height: 'calc(100vh - 48px)' }} + > +
+
+
Pi Plan
+
+ {filePath} +
+
+
+ + +
+
+
+ +
+
+
{footerHint}
+ {canRespond && ( + <> + {submitError && ( +
+ {submitError} + +
+ )} +