diff --git a/docs/superpowers/plans/2026-04-02-agent-chat-upgrade.md b/docs/superpowers/plans/2026-04-02-agent-chat-upgrade.md new file mode 100644 index 00000000..1180b680 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-agent-chat-upgrade.md @@ -0,0 +1,1987 @@ +# Agent Chat Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform the Agent Chat from a single-session-per-email timeline into a multi-session chat with persistent history, live thread context, and a polished chat UI. + +**Architecture:** Add an `agent_sessions` DB table to persist sessions independently of emails. Replace the flat event timeline with a chat-bubble UI composed of small focused components under `AgentPanel/`. The renderer watches `selectedEmailId` changes and lazily injects thread context into agent messages. + +**Tech Stack:** Electron, React, TypeScript, Zustand, Tailwind CSS, better-sqlite3, Playwright (tests) + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `src/shared/agent-types.ts` | Add `AgentSession`, `AgentSessionSummary`, `ThreadContextMessage` types | +| `src/main/db/schema.ts` | Add `agent_sessions` table DDL | +| `src/main/db/index.ts` | Migration v2 + session CRUD functions | +| `src/main/ipc/agent.ipc.ts` | New session IPC handlers, modify `agent:run` | +| `src/main/agents/agent-coordinator.ts` | Session creation/update on task lifecycle | +| `src/preload/index.ts` | Expose new session IPC methods + `getThreadEmails` | +| `src/renderer/store/index.ts` | Replace task state with session state, add context tracking | +| `src/renderer/components/AgentPanel.tsx` | Slim down to barrel export + `AgentTabContent` shell | +| `src/renderer/components/AgentPanel/SessionDropdown.tsx` | Session list dropdown | +| `src/renderer/components/AgentPanel/ChatMessage.tsx` | User/agent message bubbles | +| `src/renderer/components/AgentPanel/CollapsedToolCalls.tsx` | Foldable tool call groups | +| `src/renderer/components/AgentPanel/ChatInput.tsx` | Multi-line input with send button | +| `src/renderer/components/AgentPanel/ThreadBar.tsx` | Current thread indicator | +| `src/renderer/components/AgentPanel/TypingIndicator.tsx` | Animated dots while agent runs | +| `src/renderer/components/AgentPanel/index.ts` | Barrel exports | +| `src/renderer/components/AgentCommandPalette.tsx` | Use session-based flow | +| `src/renderer/components/EmailPreviewSidebar.tsx` | Update to use session state | +| `src/renderer/components/AgentsSidebar.tsx` | Pull history from sessions | +| `tests/unit/agent-sessions-db.spec.ts` | DB CRUD + migration tests | +| `tests/unit/agent-chat-ui.spec.ts` | Component rendering tests | + +--- + +### Task 1: Add Types to `agent-types.ts` + +**Files:** +- Modify: `src/shared/agent-types.ts` + +- [ ] **Step 1: Add `AgentSession` type** + +At the end of `src/shared/agent-types.ts`, add: + +```typescript +/** A persistent agent chat session, decoupled from email lifecycle */ +export interface AgentSession { + id: string; + title: string; + emailId: string | null; + threadId: string | null; + accountId: string; + providerIds: string[]; + createdAt: number; + updatedAt: number; + status: "active" | "completed" | "failed" | "cancelled"; + runs: Record; +} + +export interface AgentSessionSummary { + id: string; + title: string; + status: AgentSession["status"]; + updatedAt: number; + emailId: string | null; +} + +/** Plain-text representation of one message in a thread, for agent context injection */ +export interface ThreadContextMessage { + from: string; + date: string; + body: string; + isFromUser: boolean; +} +``` + +- [ ] **Step 2: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS (new types are additive, no consumers yet) + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/agent-types.ts +git commit -m "feat(agent): add AgentSession, AgentSessionSummary, ThreadContextMessage types" +``` + +--- + +### Task 2: Add `agent_sessions` DB Table & Migration + +**Files:** +- Modify: `src/main/db/schema.ts` +- Modify: `src/main/db/index.ts` +- Create: `tests/unit/agent-sessions-db.spec.ts` + +- [ ] **Step 1: Write the failing test for session CRUD** + +Create `tests/unit/agent-sessions-db.spec.ts`: + +```typescript +import { test, expect } from "@playwright/test"; +import Database from "better-sqlite3"; + +/** + * These tests create an in-memory SQLite DB, run the schema + migrations, + * then exercise the session CRUD functions. We import the functions directly + * from the db module after initializing with the test DB. + */ + +// We'll test the raw SQL since the db module requires electron app paths. +// This validates the schema and migration SQL are correct. + +function createTestDb(): Database.Database { + const db = new Database(":memory:"); + db.pragma("journal_mode = WAL"); + return db; +} + +const AGENT_SESSIONS_DDL = ` + CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + email_id TEXT, + thread_id TEXT, + account_id TEXT NOT NULL, + provider_ids TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_account ON agent_sessions(account_id); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_email ON agent_sessions(email_id); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated ON agent_sessions(updated_at DESC); +`; + +test.describe("agent_sessions table", () => { + test("creates table and inserts/reads sessions", () => { + const db = createTestDb(); + db.exec(AGENT_SESSIONS_DDL); + + const now = Date.now(); + db.prepare(` + INSERT INTO agent_sessions (id, title, email_id, thread_id, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run("sess-1", "Draft reply", "email-1", "thread-1", "acct-1", '["claude"]', now, now, "active"); + + const row = db.prepare("SELECT * FROM agent_sessions WHERE id = ?").get("sess-1") as Record; + expect(row).toBeTruthy(); + expect(row.title).toBe("Draft reply"); + expect(row.email_id).toBe("email-1"); + expect(row.status).toBe("active"); + expect(JSON.parse(row.provider_ids as string)).toEqual(["claude"]); + }); + + test("lists sessions ordered by updated_at desc", () => { + const db = createTestDb(); + db.exec(AGENT_SESSIONS_DDL); + + const now = Date.now(); + db.prepare(` + INSERT INTO agent_sessions (id, title, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run("sess-old", "Old", "acct-1", '["claude"]', now - 2000, now - 2000, "completed"); + db.prepare(` + INSERT INTO agent_sessions (id, title, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run("sess-new", "New", "acct-1", '["claude"]', now, now, "active"); + + const rows = db.prepare( + "SELECT id FROM agent_sessions WHERE account_id = ? ORDER BY updated_at DESC" + ).all("acct-1") as Array<{ id: string }>; + expect(rows.map((r) => r.id)).toEqual(["sess-new", "sess-old"]); + }); + + test("updates session status and updated_at", () => { + const db = createTestDb(); + db.exec(AGENT_SESSIONS_DDL); + + const now = Date.now(); + db.prepare(` + INSERT INTO agent_sessions (id, title, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run("sess-1", "Test", "acct-1", '["claude"]', now, now, "active"); + + const later = now + 5000; + db.prepare("UPDATE agent_sessions SET status = ?, updated_at = ? WHERE id = ?") + .run("completed", later, "sess-1"); + + const row = db.prepare("SELECT status, updated_at FROM agent_sessions WHERE id = ?") + .get("sess-1") as { status: string; updated_at: number }; + expect(row.status).toBe("completed"); + expect(row.updated_at).toBe(later); + }); + + test("deletes session", () => { + const db = createTestDb(); + db.exec(AGENT_SESSIONS_DDL); + + const now = Date.now(); + db.prepare(` + INSERT INTO agent_sessions (id, title, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run("sess-1", "Delete me", "acct-1", '["claude"]', now, now, "active"); + + db.prepare("DELETE FROM agent_sessions WHERE id = ?").run("sess-1"); + const row = db.prepare("SELECT * FROM agent_sessions WHERE id = ?").get("sess-1"); + expect(row).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (schema DDL is self-contained in test)** + +Run: `npm run test:unit -- --grep "agent_sessions table"` +Expected: PASS (4 tests) + +- [ ] **Step 3: Add table DDL to `schema.ts`** + +In `src/main/db/schema.ts`, add after the `memories` table definition: + +```typescript +// Agent chat sessions — persistent multi-session support +CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + email_id TEXT, + thread_id TEXT, + account_id TEXT NOT NULL, + provider_ids TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' +); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_account ON agent_sessions(account_id); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_email ON agent_sessions(email_id); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated ON agent_sessions(updated_at DESC); +``` + +- [ ] **Step 4: Add migration v2 to `index.ts`** + +In `src/main/db/index.ts`, add to `NUMBERED_MIGRATIONS` array after version 1: + +```typescript +{ + version: 2, + name: "add_agent_sessions_table", + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + email_id TEXT, + thread_id TEXT, + account_id TEXT NOT NULL, + provider_ids TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_account ON agent_sessions(account_id); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_email ON agent_sessions(email_id); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated ON agent_sessions(updated_at DESC); + `); + + // Migrate existing traces to sessions + const traces = db.prepare("SELECT * FROM agent_conversation_mirror").all() as Array<{ + local_task_id: string; + provider_id: string; + messages_json: string; + created_at: string; + updated_at: string; + }>; + + const insertSession = db.prepare(` + INSERT OR IGNORE INTO agent_sessions (id, title, email_id, thread_id, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const trace of traces) { + if (!trace.local_task_id) continue; + // Extract title from first user_message event + let title = "Untitled session"; + try { + const events = JSON.parse(trace.messages_json) as Array<{ type: string; text?: string }>; + const firstUserMsg = events.find((e) => e.type === "user_message"); + if (firstUserMsg?.text) { + title = firstUserMsg.text.length > 40 + ? firstUserMsg.text.slice(0, 40).replace(/\s+\S*$/, "") + "..." + : firstUserMsg.text; + } + } catch { /* ignore parse errors */ } + + const createdAt = new Date(trace.created_at).getTime() || Date.now(); + const updatedAt = new Date(trace.updated_at).getTime() || Date.now(); + + insertSession.run( + trace.local_task_id, + title, + null, // email_id not stored in mirror table + null, // thread_id not stored in mirror table + "", // account_id not stored in mirror table + JSON.stringify([trace.provider_id]), + createdAt, + updatedAt, + "completed" + ); + } + }, +}, +``` + +- [ ] **Step 5: Add session CRUD functions to `index.ts`** + +Add these functions to `src/main/db/index.ts` near the other agent-related functions: + +```typescript +// --------------------------------------------------------------------------- +// Agent Sessions +// --------------------------------------------------------------------------- + +export interface AgentSessionRow { + id: string; + title: string; + email_id: string | null; + thread_id: string | null; + account_id: string; + provider_ids: string; // JSON array + created_at: number; + updated_at: number; + status: string; +} + +export function saveAgentSession(session: AgentSessionRow): void { + getDb() + .prepare( + `INSERT OR REPLACE INTO agent_sessions + (id, title, email_id, thread_id, account_id, provider_ids, created_at, updated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + session.id, + session.title, + session.email_id, + session.thread_id, + session.account_id, + session.provider_ids, + session.created_at, + session.updated_at, + session.status + ); +} + +export function getAgentSession(sessionId: string): AgentSessionRow | null { + return ( + getDb() + .prepare("SELECT * FROM agent_sessions WHERE id = ?") + .get(sessionId) as AgentSessionRow | undefined + ) ?? null; +} + +export function listAgentSessions(accountId: string, limit = 50): AgentSessionRow[] { + return getDb() + .prepare( + "SELECT * FROM agent_sessions WHERE account_id = ? ORDER BY updated_at DESC LIMIT ?" + ) + .all(accountId, limit) as AgentSessionRow[]; +} + +export function listAgentSessionsForEmail(emailId: string): AgentSessionRow[] { + return getDb() + .prepare( + "SELECT * FROM agent_sessions WHERE email_id = ? ORDER BY updated_at DESC" + ) + .all(emailId) as AgentSessionRow[]; +} + +export function updateAgentSessionStatus( + sessionId: string, + status: string, + updatedAt = Date.now() +): void { + getDb() + .prepare("UPDATE agent_sessions SET status = ?, updated_at = ? WHERE id = ?") + .run(status, updatedAt, sessionId); +} + +export function updateAgentSessionTitle(sessionId: string, title: string): void { + getDb() + .prepare("UPDATE agent_sessions SET title = ?, updated_at = ? WHERE id = ?") + .run(title, Date.now(), sessionId); +} + +export function deleteAgentSession(sessionId: string): void { + getDb().prepare("DELETE FROM agent_sessions WHERE id = ?").run(sessionId); + // Also clean up associated traces + getDb() + .prepare("DELETE FROM agent_conversation_mirror WHERE local_task_id = ?") + .run(sessionId); +} +``` + +- [ ] **Step 6: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/main/db/schema.ts src/main/db/index.ts tests/unit/agent-sessions-db.spec.ts +git commit -m "feat(agent): add agent_sessions table, migration v2, CRUD functions" +``` + +--- + +### Task 3: Add Session IPC Handlers + +**Files:** +- Modify: `src/main/ipc/agent.ipc.ts` +- Modify: `src/preload/index.ts` + +- [ ] **Step 1: Add session IPC handlers to `agent.ipc.ts`** + +Import the new DB functions at the top of `src/main/ipc/agent.ipc.ts`: + +```typescript +import { + // ... existing imports ... + saveAgentSession, + getAgentSession, + listAgentSessions, + listAgentSessionsForEmail, + updateAgentSessionStatus, + updateAgentSessionTitle, + deleteAgentSession, + type AgentSessionRow, +} from "../db"; +``` + +Add these handlers inside `registerAgentIpc()`, after the existing handlers: + +```typescript +ipcMain.handle( + "agent:list-sessions", + async (_, { accountId, emailId }: { accountId: string; emailId?: string }) => { + try { + const rows = emailId + ? listAgentSessionsForEmail(emailId) + : listAgentSessions(accountId); + const summaries = rows.map((r) => ({ + id: r.id, + title: r.title, + status: r.status, + updatedAt: r.updated_at, + emailId: r.email_id, + })); + return { success: true, data: summaries }; + } catch (err) { + log.error({ err }, "Failed to list agent sessions"); + return { success: false, error: String(err) }; + } + } +); + +ipcMain.handle( + "agent:get-session", + async (_, { sessionId }: { sessionId: string }) => { + try { + const row = getAgentSession(sessionId); + if (!row) return { success: false, error: "Session not found" }; + return { + success: true, + data: { + id: row.id, + title: row.title, + emailId: row.email_id, + threadId: row.thread_id, + accountId: row.account_id, + providerIds: JSON.parse(row.provider_ids), + createdAt: row.created_at, + updatedAt: row.updated_at, + status: row.status, + }, + }; + } catch (err) { + log.error({ err }, "Failed to get agent session"); + return { success: false, error: String(err) }; + } + } +); + +ipcMain.handle( + "agent:rename-session", + async (_, { sessionId, title }: { sessionId: string; title: string }) => { + try { + updateAgentSessionTitle(sessionId, title); + return { success: true, data: null }; + } catch (err) { + log.error({ err }, "Failed to rename agent session"); + return { success: false, error: String(err) }; + } + } +); + +ipcMain.handle( + "agent:delete-session", + async (_, { sessionId }: { sessionId: string }) => { + try { + deleteAgentSession(sessionId); + return { success: true, data: null }; + } catch (err) { + log.error({ err }, "Failed to delete agent session"); + return { success: false, error: String(err) }; + } + } +); +``` + +- [ ] **Step 2: Expose in preload bridge** + +In `src/preload/index.ts`, add to the `agent` object (inside the `contextBridge.exposeInMainWorld` call, after the existing agent methods): + +```typescript +listSessions(accountId: string, emailId?: string): Promise<{ success: boolean; data?: AgentSessionSummary[]; error?: string }> { + return ipcRenderer.invoke("agent:list-sessions", { accountId, emailId }); +}, +getSession(sessionId: string): Promise<{ success: boolean; data?: unknown; error?: string }> { + return ipcRenderer.invoke("agent:get-session", { sessionId }); +}, +renameSession(sessionId: string, title: string): Promise<{ success: boolean; error?: string }> { + return ipcRenderer.invoke("agent:rename-session", { sessionId, title }); +}, +deleteSession(sessionId: string): Promise<{ success: boolean; error?: string }> { + return ipcRenderer.invoke("agent:delete-session", { sessionId }); +}, +``` + +- [ ] **Step 3: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/main/ipc/agent.ipc.ts src/preload/index.ts +git commit -m "feat(agent): add session IPC handlers and preload bridge" +``` + +--- + +### Task 4: Update Agent Coordinator for Session Lifecycle + +**Files:** +- Modify: `src/main/agents/agent-coordinator.ts` + +- [ ] **Step 1: Import session DB functions** + +Add imports at top of `src/main/agents/agent-coordinator.ts`: + +```typescript +import { + saveAgentSession, + updateAgentSessionStatus, + type AgentSessionRow, +} from "../db"; +``` + +- [ ] **Step 2: Create session on `runAgent()` call** + +In the `runAgent()` method, after the existing setup code (where `taskEvents[taskId]` is initialized), add session creation: + +```typescript +// Create or update session in DB +const now = Date.now(); +const sessionRow: AgentSessionRow = { + id: taskId, + title: prompt.length > 40 + ? prompt.slice(0, 40).replace(/\s+\S*$/, "") + "..." + : prompt, + email_id: context.currentEmailId || null, + thread_id: context.currentThreadId || null, + account_id: context.accountId, + provider_ids: JSON.stringify(providerIds), + created_at: now, + updated_at: now, + status: "active", +}; +saveAgentSession(sessionRow); +``` + +- [ ] **Step 3: Update session status on terminal state** + +In the terminal state detection block (where `persistTaskEvents()` is called), add: + +```typescript +const terminalStatus = state === "completed" ? "completed" + : state === "cancelled" ? "cancelled" + : "failed"; +updateAgentSessionStatus(taskId, terminalStatus); +``` + +- [ ] **Step 4: Update session status on cancel** + +In the `cancel()` method, add after persisting: + +```typescript +updateAgentSessionStatus(taskId, "cancelled"); +``` + +- [ ] **Step 5: Add session DB methods to worker proxy** + +Add to the `dbMethods` map so the worker can access sessions if needed: + +```typescript +saveAgentSession: (session: AgentSessionRow) => saveAgentSession(session), +updateAgentSessionStatus: (sessionId: string, status: string) => + updateAgentSessionStatus(sessionId, status), +``` + +- [ ] **Step 6: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/main/agents/agent-coordinator.ts +git commit -m "feat(agent): coordinator creates/updates sessions on task lifecycle" +``` + +--- + +### Task 5: Update Zustand Store — Session State + +**Files:** +- Modify: `src/renderer/store/index.ts` + +- [ ] **Step 1: Add session state fields** + +Add alongside the existing agent state fields (do NOT remove old fields yet — we'll keep both during transition): + +```typescript +// Session-based agent state (new) +agentSessions: Record; +activeSessionId: string | null; +sessionList: AgentSessionSummary[]; +viewedThreadId: string | null; // tracks which thread the user is currently viewing +``` + +Initialize in the store creator: + +```typescript +agentSessions: {}, +activeSessionId: null, +sessionList: [], +viewedThreadId: null, +``` + +- [ ] **Step 2: Add session actions** + +```typescript +// Session management +setActiveSessionId: (sessionId: string | null) => void; +loadSessionList: (accountId: string) => void; +createSession: (session: AgentSession) => void; +updateSessionInStore: (sessionId: string, updates: Partial) => void; +removeSession: (sessionId: string) => void; +setViewedThreadId: (threadId: string | null) => void; +``` + +Implement them: + +```typescript +setActiveSessionId: (sessionId) => set({ activeSessionId: sessionId }), + +loadSessionList: async (accountId) => { + const result = await window.api.agent.listSessions(accountId); + if (result.success && result.data) { + set({ sessionList: result.data }); + } +}, + +createSession: (session) => + set((state) => ({ + agentSessions: { ...state.agentSessions, [session.id]: session }, + activeSessionId: session.id, + sessionList: [ + { id: session.id, title: session.title, status: session.status, updatedAt: session.updatedAt, emailId: session.emailId }, + ...state.sessionList, + ], + })), + +updateSessionInStore: (sessionId, updates) => + set((state) => { + const existing = state.agentSessions[sessionId]; + if (!existing) return state; + const updated = { ...existing, ...updates, updatedAt: Date.now() }; + return { + agentSessions: { ...state.agentSessions, [sessionId]: updated }, + sessionList: state.sessionList.map((s) => + s.id === sessionId + ? { ...s, title: updated.title, status: updated.status, updatedAt: updated.updatedAt } + : s + ), + }; + }), + +removeSession: (sessionId) => + set((state) => { + const { [sessionId]: _, ...rest } = state.agentSessions; + return { + agentSessions: rest, + sessionList: state.sessionList.filter((s) => s.id !== sessionId), + activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, + }; + }), + +setViewedThreadId: (threadId) => set({ viewedThreadId: threadId }), +``` + +- [ ] **Step 3: Wire `appendAgentEvent` to also update session runs** + +In the existing `appendAgentEvent` implementation, after updating `agentTasks`, add: + +```typescript +// Also update session if it exists +const session = state.agentSessions[taskId]; +if (session) { + const providerId = event.providerId || session.providerIds[0] || "unknown"; + const run = session.runs[providerId] || { status: "running" as const, events: [] }; + const updatedRun = { ...run, events: [...run.events, event] }; + + if (event.type === "state") { + updatedRun.status = event.state; + } + if (event.type === "confirmation_required") { + updatedRun.pendingConfirmation = { + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + description: event.description, + }; + } + if (event.providerConversationId) { + updatedRun.providerConversationId = event.providerConversationId; + } + + return { + ...state, + agentSessions: { + ...state.agentSessions, + [taskId]: { + ...session, + runs: { ...session.runs, [providerId]: updatedRun }, + updatedAt: Date.now(), + }, + }, + }; +} +``` + +- [ ] **Step 4: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/store/index.ts +git commit -m "feat(agent): add session state and actions to Zustand store" +``` + +--- + +### Task 6: Build Chat UI Components + +**Files:** +- Create: `src/renderer/components/AgentPanel/index.ts` +- Create: `src/renderer/components/AgentPanel/ChatMessage.tsx` +- Create: `src/renderer/components/AgentPanel/CollapsedToolCalls.tsx` +- Create: `src/renderer/components/AgentPanel/TypingIndicator.tsx` +- Create: `src/renderer/components/AgentPanel/ThreadBar.tsx` +- Create: `src/renderer/components/AgentPanel/ChatInput.tsx` +- Create: `src/renderer/components/AgentPanel/SessionDropdown.tsx` + +- [ ] **Step 1: Create barrel export** + +Create `src/renderer/components/AgentPanel/index.ts`: + +```typescript +export { ChatMessage } from "./ChatMessage"; +export { CollapsedToolCalls } from "./CollapsedToolCalls"; +export { TypingIndicator } from "./TypingIndicator"; +export { ThreadBar } from "./ThreadBar"; +export { ChatInput } from "./ChatInput"; +export { SessionDropdown } from "./SessionDropdown"; +``` + +- [ ] **Step 2: Create `ChatMessage.tsx`** + +Create `src/renderer/components/AgentPanel/ChatMessage.tsx`: + +```tsx +import { memo } from "react"; +import ReactMarkdown from "react-markdown"; + +interface ChatMessageProps { + role: "user" | "agent"; + content: string; + timestamp?: number; +} + +export const ChatMessage = memo(function ChatMessage({ + role, + content, + timestamp, +}: ChatMessageProps) { + const isUser = role === "user"; + + return ( +
+
+ {isUser ? ( +

{content}

+ ) : ( +
+ {content} +
+ )} + {timestamp && ( +
+ {new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ )} +
+
+ ); +}); +``` + +- [ ] **Step 3: Create `CollapsedToolCalls.tsx`** + +Create `src/renderer/components/AgentPanel/CollapsedToolCalls.tsx`: + +```tsx +import { useState, memo } from "react"; +import type { ScopedAgentEvent } from "../../../shared/agent-types"; + +interface CollapsedToolCallsProps { + events: ScopedAgentEvent[]; +} + +export const CollapsedToolCalls = memo(function CollapsedToolCalls({ + events, +}: CollapsedToolCallsProps) { + const [expanded, setExpanded] = useState(false); + + const toolNames = events + .filter((e) => e.type === "tool_call_start") + .map((e) => (e as { toolName: string }).toolName); + + const uniqueTools = [...new Set(toolNames)]; + const summary = + uniqueTools.length <= 2 + ? uniqueTools.join(", ") + : `${uniqueTools.slice(0, 2).join(", ")} +${uniqueTools.length - 2}`; + + return ( +
+ + {expanded && ( +
+ {events.map((event, i) => { + if (event.type === "tool_call_start") { + const e = event as { toolName: string; input: unknown }; + return ( +
+ {e.toolName} +
+                    {typeof e.input === "string"
+                      ? e.input.slice(0, 200)
+                      : JSON.stringify(e.input, null, 2).slice(0, 200)}
+                  
+
+ ); + } + if (event.type === "tool_call_end") { + const e = event as { result: unknown }; + return ( +
+ → {typeof e.result === "string" + ? e.result.slice(0, 150) + : JSON.stringify(e.result).slice(0, 150)} +
+ ); + } + return null; + })} +
+ )} +
+ ); +}); +``` + +- [ ] **Step 4: Create `TypingIndicator.tsx`** + +Create `src/renderer/components/AgentPanel/TypingIndicator.tsx`: + +```tsx +export function TypingIndicator() { + return ( +
+
+
+ + + +
+
+
+ ); +} +``` + +- [ ] **Step 5: Create `ThreadBar.tsx`** + +Create `src/renderer/components/AgentPanel/ThreadBar.tsx`: + +```tsx +import { useAppStore } from "../../store"; + +export function ThreadBar() { + const selectedEmailId = useAppStore((s) => s.selectedEmailId); + const emails = useAppStore((s) => s.emails); + + const selectedEmail = selectedEmailId + ? emails.find((e) => e.id === selectedEmailId) + : null; + + if (!selectedEmail) { + return ( +
+ No thread selected +
+ ); + } + + return ( +
+ 📎 + {selectedEmail.subject || "(no subject)"} +
+ ); +} +``` + +- [ ] **Step 6: Create `ChatInput.tsx`** + +Create `src/renderer/components/AgentPanel/ChatInput.tsx`: + +```tsx +import { useState, useRef, useCallback, type KeyboardEvent } from "react"; + +interface ChatInputProps { + onSend: (message: string) => void; + disabled?: boolean; + placeholder?: string; +} + +export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + const handleSend = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed || disabled) return; + onSend(trimmed); + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, [value, disabled, onSend]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && e.metaKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + const handleInput = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 96) + "px"; // max ~4 lines + }, []); + + return ( +
+