From e64880f245679013043f66bd750169f9c0cbf4c0 Mon Sep 17 00:00:00 2001 From: Oliviero Pinotti Date: Thu, 2 Apr 2026 12:30:16 +0200 Subject: [PATCH 01/22] docs: add agent chat upgrade design spec Multi-session support, live thread context awareness, and chat UI redesign for the Agent tab in the email preview sidebar. Co-Authored-By: Claude Opus 4.6 --- .../2026-04-02-agent-chat-upgrade-design.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-agent-chat-upgrade-design.md diff --git a/docs/superpowers/specs/2026-04-02-agent-chat-upgrade-design.md b/docs/superpowers/specs/2026-04-02-agent-chat-upgrade-design.md new file mode 100644 index 00000000..ea6348d1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-agent-chat-upgrade-design.md @@ -0,0 +1,317 @@ +# Agent Chat Upgrade: Multi-Session, Live Thread Context, UI Redesign + +## Summary + +Transform the Agent Chat from a single-session-per-email timeline into a multi-session chat interface with persistent session history, live email thread context awareness, and a polished chat UI within the existing 320px sidebar. + +## Goals + +1. **Multi-session**: Users can have multiple independent chat sessions per email, plus global sessions. All sessions are browsable and resumable from a session dropdown within the Agent tab. +2. **Live thread context**: The agent automatically knows which email thread the user is viewing. Thread content is injected into the conversation when the user sends a message, so the agent always has current context without manual passing. +3. **UI redesign**: Replace the flat event timeline with a proper chat interface — message bubbles, collapsed tool calls, typing indicators, and a better input area. + +## Non-Goals + +- Pop-out / resizable chat panel (future enhancement) +- Changes to the Agents Sidebar (left panel) — it keeps its current role +- Changes to agent providers, orchestration, or tool system +- Virtual scrolling (overkill for 320px sidebar) + +--- + +## Design + +### 1. Data Model + +#### New type: `AgentSession` + +```typescript +interface AgentSession { + id: string; // UUID + title: string; // Auto-generated from first prompt, editable + emailId: string | null; // null for global sessions + threadId: string | null; // thread the session was viewing at creation + accountId: string; + providerIds: string[]; + createdAt: number; // epoch ms + updatedAt: number; // epoch ms, updated on every new message + status: "active" | "completed" | "failed" | "cancelled"; + runs: Record; // reuses existing type, keyed by providerId +} + +interface AgentSessionSummary { + id: string; + title: string; + status: AgentSession["status"]; + updatedAt: number; + emailId: string | null; +} +``` + +#### DB table: `agent_sessions` + +```sql +CREATE TABLE 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, -- JSON array + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' +); +CREATE INDEX idx_agent_sessions_account ON agent_sessions(account_id); +CREATE INDEX idx_agent_sessions_email ON agent_sessions(email_id); +CREATE INDEX idx_agent_sessions_updated ON agent_sessions(updated_at DESC); +``` + +The existing `agent_conversation_mirror` table continues to store event traces. Its `local_task_id` column maps to `agent_sessions.id` going forward. + +#### Store changes + +```typescript +// Replace +agentTasks: Record; // emailId → task +agentTaskIdMap: Record; // taskId → emailId + +// With +agentSessions: Record; // sessionId → session +activeSessionId: string | null; // currently displayed session +sessionList: AgentSessionSummary[]; // for the dropdown, sorted by updatedAt desc + +// Keep (backward compat during migration) +agentTaskHistory: AgentTaskHistoryEntry[]; +``` + +#### Session title generation + +Auto-generated from the first user prompt: +- Truncate to ~40 chars at a word boundary +- Append "..." if truncated +- User can rename via the session dropdown (click on title → inline edit) + +### 2. Live Thread Context + +#### Mechanism + +The renderer watches `selectedEmailId` and `selectedThreadId` in the Zustand store. When the user sends a message in an agent session: + +1. Fetch all messages in the currently viewed thread via `sync:get-thread-emails` IPC (existing) +2. Build a structured context block: + ``` + [Currently viewing thread: "Re: Q2 Planning"] + From: alice@example.com, 3 messages in thread + --- + [1] From: alice@example.com | Apr 1, 2026 + Hey, can we meet Thursday to discuss Q2? + + [2] From: you | Apr 1, 2026 + Sure, what time works? + + [3] From: alice@example.com | Apr 2, 2026 + How about 2pm? + ``` +3. Prepend this context block to the user's message as a system context section +4. The context is **lazy** — only fetched when the user actually sends a message, not on every navigation +5. If the thread hasn't changed since the last message, skip re-injection to avoid redundancy + +#### Context change indicator + +When the viewed thread changes while a session is active, show a subtle indicator in the chat: +- A small divider line: `· Viewing: "New Subject…" ·` +- This is purely visual — the actual context injection happens on next message send + +#### AgentContext changes + +Add to the existing `AgentContext` type: + +```typescript +interface AgentContext { + // ... existing fields ... + currentThreadMessages?: ThreadContextMessage[]; // full thread for context injection +} + +interface ThreadContextMessage { + from: string; + date: string; + body: string; // plain text, stripped HTML + isFromUser: boolean; +} +``` + +### 3. Chat UI Redesign + +#### Layout + +``` +┌──────────────────────────┐ +│ ▾ "Draft reply to Ali…" │ Session dropdown +│ + New Chat │ +├──────────────────────────┤ +│ │ +│ ┌────────────────────┐ │ Agent bubble (left, gray bg) +│ │ I'll draft a reply │ │ +│ │ to Alice's email. │ │ +│ │ ▸ Used 2 tools │ │ Collapsed tool summary +│ └────────────────────┘ │ +│ │ +│ ┌────────────┐ │ User bubble (right, accent bg) +│ │ Make it │ │ +│ │ shorter │ │ +│ └────────────┘ │ +│ │ +│ ┌────────────────────┐ │ +│ │ Here's a shorter │ │ +│ │ version: … │ │ +│ └────────────────────┘ │ +│ │ +│ · Viewing: "Q2 Plan…" · │ Context change indicator +│ │ +├──────────────────────────┤ +│ 📎 Re: Q2 Planning │ Current thread bar +├──────────────────────────┤ +│ [Ask about this thread…] │ Input textarea +│ [↑] │ Send button +└──────────────────────────┘ +``` + +#### Component breakdown + +**SessionDropdown** — New component at top of Agent tab: +- Shows current session title (truncated with ellipsis) +- Click to expand dropdown listing all sessions for current account +- Sessions grouped: "Active" (running/active status) then "Recent" (completed/failed/cancelled) +- Each item shows: title, relative timestamp, status dot (green=active, gray=completed, red=failed) +- "New Chat" button at top of dropdown +- Click session to switch `activeSessionId` +- Click title text to rename inline + +**ChatMessage** — New component replacing inline event rendering: +- Two variants: `user` (right-aligned, accent background) and `agent` (left-aligned, gray background) +- Agent messages render markdown (reuse existing markdown rendering) +- Streaming text shows with a blinking cursor + +**CollapsedToolCalls** — New component for tool call groups: +- Between agent text segments, consecutive tool calls collapse into "▸ Used N tools" +- Click to expand and show tool name, input summary, result summary +- Errors and confirmation requests remain always-visible (not collapsed) +- Pending confirmations show inline approve/deny buttons as before + +**ContextIndicator** — Small divider component: +- Shows when the viewed thread changes: `· Viewing: "Subject…" ·` +- Muted text, centered, small font + +**ThreadBar** — Above the input area: +- Shows the currently viewed thread subject (or "No thread selected") +- Small paperclip icon prefix +- Truncated with ellipsis +- Clicking scrolls to / selects that email in the email list + +**ChatInput** — Improved input area: +- Multi-line textarea (min 1 row, max 4 rows, auto-grows) +- Placeholder: "Ask about this thread…" (when thread selected) or "Start a conversation…" (no thread) +- Cmd+Enter to send (primary), Enter for newline +- Send button (arrow up icon) appears when input is non-empty +- Disabled state with "Thinking…" while agent is responding + +**TypingIndicator** — Replaces status bar: +- Three-dot animation in an agent-style bubble at the bottom of the message list +- Shows while agent is processing (between first event and done/error) + +#### Styling + +- Message bubbles: rounded corners (12px), padding 8px 12px +- User bubbles: `bg-blue-500 text-white` (or app accent color) +- Agent bubbles: `bg-gray-100 text-gray-900` (light mode), `bg-gray-800 text-gray-100` (dark mode) +- Tool call collapsed line: `text-xs text-gray-500`, monospace icon +- Session dropdown: standard dropdown styling, max-height 300px with scroll +- All existing Tailwind CSS patterns in the codebase + +### 4. Session Lifecycle + +#### Creating a session + +1. User types in the chat input and presses send (no command palette needed for basic usage) +2. If no active session for this context → create new `AgentSession` with auto-generated title +3. If active session exists → append as follow-up message +4. Command palette still works for structured actions — creates a session the same way + +#### Switching sessions + +1. Click session in dropdown → set `activeSessionId` +2. Load events from `agent_conversation_mirror` if not already in memory +3. Display the session's chat history +4. Input placeholder updates based on session's email context vs current view + +#### Resuming sessions + +1. Sessions with status "active" or "completed" can receive follow-up messages +2. On follow-up, the session's `updatedAt` is bumped +3. If status was "completed", it transitions back to "active" +4. Thread context is re-injected based on the **currently viewed** thread (not the original) + +#### Session cleanup + +- No auto-deletion — sessions persist until manually cleared +- Future: add a "Clear all sessions" option in Agents Sidebar settings + +### 5. Migration + +On first launch after upgrade: + +1. Migration reads all rows from `agent_conversation_mirror` +2. For each unique `local_task_id`, creates an `agent_sessions` row: + - `id` = existing `local_task_id` + - `title` = first user message text (truncated) or "Untitled session" + - `email_id`, `thread_id`, `account_id` = extracted from stored events context + - `provider_ids` = from `provider_id` column + - `status` = "completed" (all migrated sessions are historical) + - Timestamps from existing data +3. No data loss — event traces remain in `agent_conversation_mirror` unchanged + +### 6. IPC Changes + +**New handlers:** +- `agent:list-sessions` — returns `AgentSessionSummary[]` for current account, sorted by `updatedAt` desc, limit 50 +- `agent:get-session` — returns full `AgentSession` by ID +- `agent:rename-session` — updates session title +- `agent:delete-session` — deletes session and its traces + +**Modified handlers:** +- `agent:run` — accepts `sessionId` parameter. If provided, appends to existing session. If not, creates new session and returns its ID. +- `agent:get-trace` — works with session IDs (backward compatible since old task IDs become session IDs) + +**New events:** +- `agent:session-updated` — broadcast when session status/title changes + +### 7. Testing Strategy + +- **Unit tests**: Session CRUD in DB, title generation, context block building, migration logic +- **E2E tests**: Create session → send message → switch thread → send follow-up (verify context injection) → switch sessions → verify history loads +- **Integration tests**: IPC round-trip for session lifecycle + +--- + +## File Impact + +| File | Change | +|------|--------| +| `src/shared/agent-types.ts` | Add `AgentSession`, `AgentSessionSummary`, `ThreadContextMessage` types | +| `src/main/db/schema.ts` | Add `agent_sessions` table | +| `src/main/db/index.ts` | Add migration, session CRUD functions | +| `src/main/ipc/agent.ipc.ts` | Add 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 | +| `src/renderer/store/index.ts` | Replace task state with session state | +| `src/renderer/components/AgentPanel.tsx` | Full rewrite → chat UI with message bubbles | +| `src/renderer/components/AgentPanel/SessionDropdown.tsx` | New component | +| `src/renderer/components/AgentPanel/ChatMessage.tsx` | New component | +| `src/renderer/components/AgentPanel/CollapsedToolCalls.tsx` | New component | +| `src/renderer/components/AgentPanel/ChatInput.tsx` | New component | +| `src/renderer/components/AgentPanel/ContextIndicator.tsx` | New component | +| `src/renderer/components/AgentPanel/ThreadBar.tsx` | New component | +| `src/renderer/components/AgentCommandPalette.tsx` | Use session-based flow | +| `src/renderer/components/EmailPreviewSidebar.tsx` | Update to use session state | +| `src/renderer/components/AgentsSidebar.tsx` | Minor: pull history from sessions | From b774cf23bdbdafe55c301ded5460ffa766d439ea Mon Sep 17 00:00:00 2001 From: Oliviero Pinotti Date: Thu, 2 Apr 2026 12:36:46 +0200 Subject: [PATCH 02/22] docs: add agent chat upgrade implementation plan 13-task plan covering: types, DB migration, IPC handlers, coordinator session lifecycle, Zustand store, chat UI components, AgentPanel rewrite, sidebar/palette integration, live thread context, and testing. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-02-agent-chat-upgrade.md | 1987 +++++++++++++++++ 1 file changed, 1987 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-agent-chat-upgrade.md 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 ( +
+