feat(floating-ai): step 5 — add ai:action IPC side-channel

Add a new ai:action IPC channel so the renderer can react to AI tool
side-effects (task creation, checkpoint/task suggestions). Also mark
AI-created tasks with isAiSuggested: 1 in both project and global
add_task tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-28 09:23:04 +01:00
parent 310370ef66
commit 6c498c5f40
4 changed files with 28 additions and 1 deletions

View File

@@ -642,7 +642,7 @@ Use these existing patterns from the codebase:
## Step 5: Add `ai:action` IPC Side-Channel ## Step 5: Add `ai:action` IPC Side-Channel
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Step 4 completed **Prerequisites**: Step 4 completed
**Modifies**: **Modifies**:
- `src/preload/trpc.ts` - `src/preload/trpc.ts`

View File

@@ -24,6 +24,10 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']); const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
const AI_STREAM_CHANNEL = 'ai:stream'; const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
let currentSender: Electron.WebContents | undefined;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId, projectId,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId: input.projectId ?? null, projectId: input.projectId ?? null,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -849,12 +859,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
sender.send(AI_STREAM_CHANNEL, { token, done }); sender.send(AI_STREAM_CHANNEL, { token, done });
} }
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
if (!sender || sender.isDestroyed()) return;
sender.send(AI_ACTION_CHANNEL, action);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Orchestrate (public entry point) // Orchestrate (public entry point)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> { export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input; const { message, context, sender } = input;
currentSender = sender;
// Quick check: is an LLM available? // Quick check: is an LLM available?
const llm = await getLLM(); const llm = await getLLM();

View File

@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
}); });
const AI_STREAM_CHANNEL = 'ai:stream'; const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
contextBridge.exposeInMainWorld('electronAI', { contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */ /** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler); ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
}; };
}, },
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
};
},
}); });

View File

@@ -15,6 +15,7 @@ interface ElectronTRPC {
interface ElectronAI { interface ElectronAI {
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void; onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
} }
declare global { declare global {