diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d39c787 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# MosaicIQ - Claude Code Instructions + +This is the primary instruction file for Claude Code working on the MosaicIQ project. + +## Quick Start + +**Required:** +```bash +export PI_API_KEY=your_pi_api_key_here +``` + +See **[agent.md](./agent.md)** for complete project documentation, including: +- Full LLM Client Reference with all Pi API functions +- Monorepo structure and key files +- Agent behavior guidelines +- Implementation priorities +- Coding style and architecture rules + +## Project Overview + +**MosaicIQ** is an AI-native equity research workspace built as a local-first desktop application. + +- **Stack**: TypeScript, React, Electron, SQLite +- **Architecture**: t3-code-style with typed RPC layer +- **LLM**: Pi API (Inflection AI) for all AI operations +- **Monorepo**: PNPM workspace with apps (desktop, web) and shared packages + +## Dev Server Rules + +**NEVER start, stop, restart, or manage the dev server.** The user runs the app independently. Your job is to write/edit code and run verification commands (typecheck, lint, tests, build). + +## File Structure + +``` +local_research_app/ +├── apps/ +│ ├── desktop/ # Electron main (RPC server, file I/O, DB, agents) +│ └── web/ # React UI (client) +├── packages/ +│ ├── contracts/ # Shared TypeScript types +│ └── shared/ # Shared utilities, LLM client, agents, DB, export +├── agent.md # Complete project documentation +└── CLAUDE.md # This file +``` + +## Key Links + +| Topic | File | +|-------|------| +| Full Documentation | [agent.md](./agent.md) | +| LLM Client Reference | [agent.md # LLM Client Reference](./agent.md#llm-client-reference) | +| Architecture | [docs/architecture.md](./docs/architecture.md) | +| RPC Contracts | [`packages/contracts/src/rpc.ts`](./packages/contracts/src/rpc.ts) | +| Pi Client | [`packages/shared/src/llm/client.ts`](./packages/shared/src/llm/client.ts) | + +## Coding Style + +- TypeScript throughout +- Small, readable components +- Explicit state and typed contracts +- No unnecessary abstractions +- Follow design tokens from design doc +- Intentional empty/loading/error states + +## When Unsure + +Default to **[agent.md](./agent.md)** or the MosaicIQ Design Document v3 (attached separately). diff --git a/agent.md b/agent.md index 56ee7ea..ddbb10f 100644 --- a/agent.md +++ b/agent.md @@ -1,9 +1,27 @@ -# Agent Instructions +# MosaicIQ - Agent Instructions & Project Documentation You are helping implement **MosaicIQ**, an AI-native equity research workspace. Use the attached **MosaicIQ Design Document v3** as the primary reference for product intent, UX direction, architecture, screen specs, agent behavior, and implementation details. This file is intentionally light; the design doc contains the fuller guidance. +## Required Environment Variables + +**`PI_API_KEY` is required.** This application uses the Pi API (Inflection AI) for all LLM operations. The application will not function without this key set. + +```bash +export PI_API_KEY=your_pi_api_key_here +``` + +### API Details + +- **Provider**: Pi (Inflection AI) +- **Base URL**: `https://api.pi.ai/v1/chat/completions` +- **Client**: Custom fetch-based client in `packages/shared/src/llm/client.ts` +- **Model**: `pi` (default) +- **Features**: Streaming and non-streaming completions, structured JSON responses + +All agent execution, memo generation, and AI-powered features depend on this API key. + ## Core Direction Build a clean, fast, local-first equity research application with a **t3-code-style architecture**: @@ -109,3 +127,157 @@ Refer back to the MosaicIQ Design Document v3. It contains additional helpful in - Accessibility and keyboard shortcuts Default to the design doc unless it conflicts with a newer explicit instruction. + +--- + +# LLM Client Reference + +The Pi API client is located at `packages/shared/src/llm/client.ts`. + +## Available Functions + +### `streamResponse(prompt, options)` +Stream a response from Pi with progress callbacks. + +```typescript +import { streamResponse } from "@mosaiciq/shared/llm"; + +const response = await streamResponse("Analyze this data", { + model: "pi", // Optional, defaults to "pi" + maxTokens: 4096, // Optional, defaults to 4096 + temperature: 0, // Optional, defaults to 0 + onProgress: (text) => console.log(text), // Optional progress callback + signal: abortSignal, // Optional AbortSignal for cancellation +}); +``` + +### `complete(prompt, options)` +Get a complete response without streaming. + +```typescript +import { complete } from "@mosaiciq/shared/llm"; + +const response = await complete("Summarize this report", { + model: "pi", + maxTokens: 2048, + temperature: 0.5, +}); +``` + +### `streamStructuredResponse(prompt, schema, options)` +Stream a response and parse as JSON using the provided schema. + +```typescript +import { streamStructuredResponse } from "@mosaiciq/shared/llm"; + +const result = await streamStructuredResponse( + "Extract financial metrics", + { + revenue: "number", + growth: "number", + margin: "number", + }, + { + onProgress: (text) => updateUI(text), + signal, + } +); +``` + +### `completeStructured(prompt, schema, options)` +Get a complete structured JSON response. + +```typescript +import { completeStructured } from "@mosaiciq/shared/llm"; + +const data = await completeStructured( + "Parse this earnings report", + { + eps: "number", + revenue: "string", + guidance: "string", + } +); +``` + +### `isConfigured()` +Check if the Pi API key is properly configured. + +```typescript +import { isConfigured } from "@mosaiciq/shared/llm"; + +if (!isConfigured()) { + console.error("PI_API_KEY is not set"); +} +``` + +## Usage in Agents + +When implementing agents that use the Pi API: + +```typescript +import { streamResponse, isConfigured } from "../llm/client.js"; + +export async function executeMyAgent(db: Db, companyId: string) { + if (!isConfigured()) { + throw new Error("PI_API_KEY is not configured"); + } + + const prompt = buildAgentPrompt(context); + + const result = await streamResponse(prompt, { + onProgress: (text) => updateProgress(text), + signal, + }); + + return result; +} +``` + +--- + +# Monorepo Structure + +``` +local_research_app/ +├── apps/ +│ ├── desktop/ # Electron main process (RPC server, file I/O) +│ └── web/ # React web UI (client) +├── packages/ +│ ├── contracts/ # Shared TypeScript types (RPC, domain models) +│ └── shared/ # Shared utilities, DB, LLM client, agents +├── docs/ +│ └── architecture.md # Architecture documentation +├── agent.md # This file - agent instructions +├── pnpm-workspace.yaml # PNPM workspace configuration +└── package.json # Root package.json +``` + +--- + +# Key Files by Concern + +## RPC Layer +- `packages/contracts/src/rpc.ts` - RPC method definitions and types +- `apps/desktop/src/rpc.ts` - RPC server implementation +- `apps/web/src/rpcClient.ts` - RPC client wrapper + +## Data Layer +- `packages/shared/src/db/database.ts` - SQLite database setup +- `packages/shared/src/db/schema.ts` - Database schema +- `packages/shared/src/db/queries.ts` - Database query functions + +## LLM Layer +- `packages/shared/src/llm/client.ts` - Pi API client +- `packages/shared/src/llm/prompts.ts` - Agent prompt templates +- `packages/shared/src/llm/context.ts` - Context building for agents + +## Agents +- `packages/shared/src/agents/runner.ts` - Individual agent execution +- `packages/shared/src/agents/executor.ts` - Agent pipeline orchestration +- `packages/shared/src/agents/index.ts` - Agent registry and exports + +## Export +- `packages/shared/src/export/excel.ts` - Excel export (ExcelJS) +- `packages/shared/src/export/pdf.ts` - PDF export +- `packages/shared/src/export/pptx.ts` - PowerPoint export (PptxGenJS) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 68856d4..102301a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,5 +1,19 @@ { "name": "@mosaiciq/desktop", "private": true, - "type": "module" + "type": "module", + "scripts": { + "dev:bundle": "tsdown --watch", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@mosaiciq/contracts": "workspace:*", + "@mosaiciq/shared": "workspace:*", + "better-sqlite3": "^12.10.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "tsdown": "^0.22.0" + } } diff --git a/apps/desktop/src/dataRefresh.ts b/apps/desktop/src/dataRefresh.ts new file mode 100644 index 0000000..c0a77df --- /dev/null +++ b/apps/desktop/src/dataRefresh.ts @@ -0,0 +1,192 @@ +/** + * Background data refresh service + * Updates prices, filings, and earnings data on a schedule + */ + +import type { BrowserWindow } from "electron"; +import type { Db } from "@mosaiciq/shared/db"; +import { fetchQuote, fetchFilings } from "@mosaiciq/shared/data"; +import { upsertCompany } from "@mosaiciq/shared/db"; +import { listFilings } from "@mosaiciq/shared/db"; + +export interface DataRefreshOptions { + priceInterval?: number; // minutes + filingInterval?: number; // minutes + earningsInterval?: number; // minutes +} + +export function startDataRefresh( + db: Db, + mainWindow: BrowserWindow, + options: DataRefreshOptions = {} +): () => void { + const { + priceInterval = 5, + filingInterval = 60, + earningsInterval = 1440, // daily + } = options; + + let priceTimeout: NodeJS.Timeout | null = null; + let filingTimeout: NodeJS.Timeout | null = null; + let earningsTimeout: NodeJS.Timeout | null = null; + + // Get all portfolio holdings + function getPortfolioTickers(): string[] { + const stmt = db.prepare(` + SELECT DISTINCT ticker FROM holdings + UNION + SELECT DISTINCT ticker FROM companies + `); + const rows = stmt.all() as Array<{ ticker: string }>; + return rows.map((r) => r.ticker); + } + + // Refresh prices for all tickers + async function refreshPrices() { + try { + const tickers = getPortfolioTickers(); + if (tickers.length === 0) return; + + console.log(`[DataRefresh] Refreshing prices for ${tickers.length} tickers`); + + for (const ticker of tickers) { + const quote = await fetchQuote(ticker); + if (quote) { + // Update company price in database + const company = db.prepare("SELECT * FROM companies WHERE ticker = ?").get(ticker) as any; + if (company) { + db.prepare(` + UPDATE companies + SET price = ?, change_pct = ?, updated_at = datetime('now') + WHERE ticker = ? + `).run(quote.price, quote.changePercent, ticker); + } + } + } + + // Notify UI of price updates + mainWindow?.webContents.send("data:prices-updated", { + timestamp: new Date().toISOString(), + }); + + console.log("[DataRefresh] Price refresh complete"); + } catch (error) { + console.error("[DataRefresh] Error refreshing prices:", error); + } + + // Schedule next refresh + priceTimeout = setTimeout(refreshPrices, priceInterval * 60 * 1000); + } + + // Check for new filings + async function refreshFilings() { + try { + const tickers = getPortfolioTickers(); + if (tickers.length === 0) return; + + console.log(`[DataRefresh] Checking for new filings for ${tickers.length} tickers`); + + for (const ticker of tickers) { + // Get most recent filing date from database + const existingFilings = listFilings(db, ticker); + const since = existingFilings.length > 0 + ? existingFilings[0].filedDate + : undefined; + + const newFilings = await fetchFilings(ticker, { limit: 10, since }); + + for (const filing of newFilings) { + // Check if filing already exists + const exists = db.prepare(` + SELECT id FROM filings WHERE company_id = ? AND filed_date = ? AND form_type = ? + `).get(ticker, filing.filedDate, filing.formType); + + if (!exists) { + db.prepare(` + INSERT INTO filings (id, company_id, form_type, filed_date, title) + VALUES (?, ?, ?, ?, ?) + `).run( + `${ticker}-${filing.formType}-${filing.filedDate}`, + ticker, + filing.formType, + filing.filedDate, + filing.title + ); + + // Notify UI of new filing + mainWindow?.webContents.send("alert:new-filing", { + ticker, + formType: filing.formType, + title: filing.title, + filedDate: filing.filedDate, + }); + } + } + } + + console.log("[DataRefresh] Filing refresh complete"); + } catch (error) { + console.error("[DataRefresh] Error refreshing filings:", error); + } + + // Schedule next refresh + filingTimeout = setTimeout(refreshFilings, filingInterval * 60 * 1000); + } + + // Refresh earnings dates + async function refreshEarnings() { + try { + const tickers = getPortfolioTickers(); + if (tickers.length === 0) return; + + console.log(`[DataRefresh] Updating earnings dates for ${tickers.length} tickers`); + + // Import dynamically to avoid circular dependency + const { getEarningsDate, getQuarterString } = await import("@mosaiciq/shared/data"); + + for (const ticker of tickers) { + const earningsDate = await getEarningsDate(ticker); + if (earningsDate) { + // Check if earnings schedule exists + const existing = db.prepare(` + SELECT id FROM earnings_schedules WHERE company_id = ? AND expected_date = ? + `).get(ticker, earningsDate.toISOString()); + + if (!existing) { + const quarter = getQuarterString(earningsDate); + db.prepare(` + INSERT INTO earnings_schedules (id, company_id, quarter, expected_date) + VALUES (?, ?, ?, ?) + `).run( + `earnings-${ticker}-${Date.now()}`, + ticker, + quarter, + earningsDate.toISOString() + ); + } + } + } + + console.log("[DataRefresh] Earnings refresh complete"); + } catch (error) { + console.error("[DataRefresh] Error refreshing earnings:", error); + } + + // Schedule next refresh + earningsTimeout = setTimeout(refreshEarnings, earningsInterval * 60 * 1000); + } + + // Start all refresh cycles + console.log("[DataRefresh] Starting data refresh service"); + priceTimeout = setTimeout(refreshPrices, 1000); // Start immediately + filingTimeout = setTimeout(refreshFilings, 5000); // Start after 5s + earningsTimeout = setTimeout(refreshEarnings, 10000); // Start after 10s + + // Return cleanup function + return () => { + if (priceTimeout) clearTimeout(priceTimeout); + if (filingTimeout) clearTimeout(filingTimeout); + if (earningsTimeout) clearTimeout(earningsTimeout); + console.log("[DataRefresh] Stopped data refresh service"); + }; +} diff --git a/apps/desktop/src/fileHandler.ts b/apps/desktop/src/fileHandler.ts new file mode 100644 index 0000000..70a1cba --- /dev/null +++ b/apps/desktop/src/fileHandler.ts @@ -0,0 +1,165 @@ +/** + * File download handler for Electron + * Manages save dialogs and file downloads + */ + +import { dialog, shell } from "electron"; +import type { BrowserWindow } from "electron"; +import path from "node:path"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { z } from "zod"; +import { registerIpcMethod } from "./ipc.js"; + +export interface FileDownloadOptions { + defaultName: string; + filters: Array<{ name: string; extensions: string[] }>; + data: Buffer; + mimeType?: string; +} + +/** + * Handle file download with save dialog + */ +export async function handleFileDownload( + mainWindow: BrowserWindow, + options: FileDownloadOptions +): Promise<{ saved: boolean; filePath?: string; error?: string }> { + const { defaultName, filters, data, mimeType } = options; + + try { + // Show save dialog + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: defaultName, + filters, + properties: ["createDirectory"], + }); + + if (result.canceled || !result.filePath) { + return { saved: false }; + } + + // Ensure directory exists + const dir = path.dirname(result.filePath); + try { + mkdirSync(dir, { recursive: true }); + } catch { + // Directory might already exist + } + + // Write file + writeFileSync(result.filePath, data); + + // Open file with default application + await shell.openPath(result.filePath); + + return { saved: true, filePath: result.filePath }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("[FileHandler] Error saving file:", errorMsg); + return { saved: false, error: errorMsg }; + } +} + +/** + * Save file to temp directory and return path + */ +export function saveToTemp( + filename: string, + data: Buffer +): { filePath: string; error?: string } { + try { + const tempDir = path.join(tmpdir(), "mosaiciq"); + mkdirSync(tempDir, { recursive: true }); + + const filePath = path.join(tempDir, filename); + writeFileSync(filePath, data); + + return { filePath }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("[FileHandler] Error saving to temp:", errorMsg); + return { filePath: "", error: errorMsg }; + } +} + +/** + * Open file with default application + */ +export async function openFile(filePath: string): Promise { + try { + await shell.openPath(filePath); + return true; + } catch (error) { + console.error("[FileHandler] Error opening file:", error); + return false; + } +} + +/** + * Show file in default file manager + */ +export async function showInFolder(filePath: string): Promise { + try { + await shell.showItemInFolder(filePath); + return true; + } catch (error) { + console.error("[FileHandler] Error showing in folder:", error); + return false; + } +} + +/** + * Get filters for file type + */ +export function getFileFilters(type: "pdf" | "excel" | "ppt"): Array<{ name: string; extensions: string[] }> { + const filters: Record> = { + pdf: [{ name: "PDF Files", extensions: ["pdf"] }], + excel: [{ name: "Excel Files", extensions: ["xlsx", "xls"] }], + ppt: [{ name: "PowerPoint Files", extensions: ["pptx", "ppt"] }], + }; + + return filters[type] || []; +} + +/** + * Register IPC handlers for file operations + */ +export function registerFileHandlers(mainWindow: BrowserWindow) { + const FileDownloadOptionsSchema: z.ZodType = z.object({ + defaultName: z.string().trim().min(1), + filters: z.array(z.object({ + name: z.string().trim().min(1), + extensions: z.array(z.string().trim().min(1)), + })), + data: z.custom((value) => Buffer.isBuffer(value), { message: "Expected Buffer" }), + mimeType: z.string().optional(), + }); + + const FileSaveResultSchema = z.object({ + saved: z.boolean(), + filePath: z.string().optional(), + error: z.string().optional(), + }); + + registerIpcMethod({ + channel: "file:save", + input: FileDownloadOptionsSchema, + output: FileSaveResultSchema, + handler: (options) => handleFileDownload(mainWindow, options), + }); + + registerIpcMethod({ + channel: "file:open", + input: z.string().trim().min(1), + output: z.boolean(), + handler: openFile, + }); + + registerIpcMethod({ + channel: "file:showInFolder", + input: z.string().trim().min(1), + output: z.boolean(), + handler: showInFolder, + }); +} diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts new file mode 100644 index 0000000..39d5e27 --- /dev/null +++ b/apps/desktop/src/ipc.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +const handlers = new Map Promise>(); + +vi.mock("electron", () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (event: unknown, input: unknown) => Promise) => { + handlers.set(channel, handler); + }), + }, +})); + +describe("registerIpcMethod", () => { + beforeEach(() => { + handlers.clear(); + }); + + it("calls handler for valid payloads", async () => { + const { registerIpcMethod } = await import("./ipc.js"); + const handler = vi.fn((input: { name: string }) => ({ greeting: `hello ${input.name}` })); + registerIpcMethod({ + channel: "test:valid", + input: z.object({ name: z.string().min(1) }), + output: z.object({ greeting: z.string() }), + handler, + }); + + await expect(handlers.get("test:valid")?.({}, { name: "Ada" })).resolves.toEqual({ + ok: true, + data: { greeting: "hello Ada" }, + }); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("does not call handler for invalid payloads", async () => { + const { registerIpcMethod } = await import("./ipc.js"); + const handler = vi.fn(); + registerIpcMethod({ + channel: "test:invalid-input", + input: z.object({ name: z.string().min(1) }), + output: z.object({ greeting: z.string() }), + handler, + }); + + const result = await handlers.get("test:invalid-input")?.({}, { name: "" }); + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("returns a controlled error for invalid handler output", async () => { + const { registerIpcMethod } = await import("./ipc.js"); + registerIpcMethod({ + channel: "test:invalid-output", + input: z.object({ name: z.string() }), + output: z.object({ greeting: z.string() }), + handler: () => ({ greeting: 123 }) as never, + }); + + const result = await handlers.get("test:invalid-output")?.({}, { name: "Ada" }); + expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL_ERROR" } }); + }); +}); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts new file mode 100644 index 0000000..fecc300 --- /dev/null +++ b/apps/desktop/src/ipc.ts @@ -0,0 +1,61 @@ +import { ipcMain } from "electron"; +import { z } from "zod"; + +export type IpcErrorCode = "VALIDATION_ERROR" | "INTERNAL_ERROR"; + +export type IpcResult = + | { ok: true; data: Output } + | { ok: false; error: { code: IpcErrorCode; message: string; detail?: unknown } }; + +export type IpcMethod = { + channel: string; + input: z.ZodType; + output: z.ZodType; + handler: (input: Input) => Promise | Output; +}; + +function validationDetail(error: z.ZodError): unknown { + return error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); +} + +export function registerIpcMethod(method: IpcMethod): void { + ipcMain.handle(method.channel, async (_event, rawInput): Promise> => { + const parsedInput = method.input.safeParse(rawInput); + if (!parsedInput.success) { + return { + ok: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid IPC payload.", + detail: validationDetail(parsedInput.error), + }, + }; + } + + try { + const output = await method.handler(parsedInput.data); + const parsedOutput = method.output.safeParse(output); + if (!parsedOutput.success) { + return { + ok: false, + error: { + code: "INTERNAL_ERROR", + message: "Invalid IPC response.", + }, + }; + } + return { ok: true, data: parsedOutput.data }; + } catch (error) { + return { + ok: false, + error: { + code: "INTERNAL_ERROR", + message: error instanceof Error ? error.message : "Unhandled IPC failure.", + }, + }; + } + }); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 46335c3..bfa60fb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,13 +1,21 @@ -import { app, BrowserWindow, ipcMain } from "electron"; +import { app, BrowserWindow, ipcMain, globalShortcut } from "electron"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { handleRpc } from "./rpc.js"; -import type { RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js"; +import { startDataRefresh } from "./dataRefresh.js"; +import { startAutoSnapshot } from "./snapshotService.js"; +import { registerFileHandlers } from "./fileHandler.js"; +import type { ClientSettings, RpcMethod, RpcResult } from "@mosaiciq/contracts/rpc"; +import { isRpcMethod, parseRpcRequest, parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas"; +import { getDatabase, seedDatabase, getDataDir, updateClientSettings, getClientSettings } from "@mosaiciq/shared/db"; +import { eventEmitter } from "@mosaiciq/shared/agents"; +import { z } from "zod"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const isDev = process.env.VITE_DEV_SERVER_URL || !app.isPackaged; -async function createWindow() { +async function createWindow(): Promise { const win = new BrowserWindow({ width: 1440, height: 960, @@ -16,7 +24,7 @@ async function createWindow() { title: "MosaicIQ", backgroundColor: "#f8f5ed", webPreferences: { - preload: path.join(__dirname, "preload.js"), + preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: false @@ -27,15 +35,177 @@ async function createWindow() { await win.loadURL(process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173"); win.webContents.openDevTools({ mode: "detach" }); } else { - await win.loadFile(path.join(__dirname, "../../../../apps/web/dist/index.html")); + await win.loadFile(path.join(__dirname, "../../web/dist/index.html")); } + return win; } -ipcMain.handle("rpc:call", (_event, method: RpcMethod, payload: RpcRequestMap[RpcMethod]) => { - return handleRpc(method, payload); +function rpcValidationDetail(error: z.ZodError): unknown { + return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })); +} + +ipcMain.handle("rpc:call", async (_event, method: unknown, payload: unknown): Promise> => { + if (!isRpcMethod(method)) { + return { ok: false, error: { code: "VALIDATION_ERROR", message: "Unknown RPC method." } }; + } + + let parsedPayload; + try { + parsedPayload = parseRpcRequest(method, payload); + } catch (error) { + return { + ok: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid RPC payload.", + detail: error instanceof z.ZodError ? rpcValidationDetail(error) : undefined, + }, + }; + } + + const result = await handleRpc(method, parsedPayload); + if (!result.ok) return result; + + try { + parseRpcResponse(method, result.data); + return result; + } catch { + return { ok: false, error: { code: "INTERNAL_ERROR", message: "Invalid RPC response." } }; + } }); -app.whenReady().then(createWindow); +app.whenReady().then(async () => { + // Initialize database + const db = getDatabase(); + + // Load client settings from JSON file if exists + const settingsPath = path.join(getDataDir(), "settings.json"); + if (existsSync(settingsPath)) { + try { + const saved = JSON.parse(readFileSync(settingsPath, "utf-8")) as Partial; + // Merge saved settings into database + updateClientSettings(db, saved); + console.log("[Settings] Loaded settings from", settingsPath); + } catch (error) { + console.warn("[Settings] Failed to load settings file:", error); + } + } + + // Seed demo data if empty (check if any companies exist) + const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get() as { count: number }; + if (companyCount.count === 0) { + seedDatabase(db); + } + + const win = await createWindow(); + + // Register file handlers + registerFileHandlers(win); + + // Start data refresh service + const stopDataRefresh = startDataRefresh(db, win, { + priceInterval: 5, // 5 minutes + filingInterval: 60, // 1 hour + earningsInterval: 1440, // daily + }); + + // Start auto-snapshot service + const stopSnapshot = startAutoSnapshot(db, win, { + interval: 5, // 5 minutes + maxSnapshots: 100, + }); + + // Register Cmd+S for manual snapshot + globalShortcut.register("CommandOrControl+S", async () => { + const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as { + active_company_id: string | null; + } | undefined; + + if (activeCompanyId?.active_company_id) { + // Get the manual snapshot function from the snapshot service + const createManualSnapshot = (globalThis as any).createManualSnapshot; + if (createManualSnapshot) { + const snapshot = await createManualSnapshot(activeCompanyId.active_company_id); + + // Show notification + win.webContents.send("snapshot:notification", { + title: "Snapshot Created", + message: `Manual snapshot created for ${activeCompanyId.active_company_id.toUpperCase()}`, + snapshotId: snapshot?.id, + }); + + console.log(`[Shortcut] Manual snapshot created for ${activeCompanyId.active_company_id}`); + } + } + }); + + // Register Cmd+Shift+S for labeled snapshot + globalShortcut.register("CommandOrControl+Shift+S", async () => { + const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as { + active_company_id: string | null; + } | undefined; + + if (activeCompanyId?.active_company_id) { + // Send event to renderer to show input dialog + win.webContents.send("snapshot:request-label", { + companyId: activeCompanyId.active_company_id, + }); + } + }); + + // Forward event emitter events to renderer process + const eventTypes = ["agent.progress", "agent.completed", "agent.failed", "agent.started", "agent.streaming", "validation.updated", "memo.updated", "model.updated"]; + const unsubscribeForwarders: Array<() => void> = []; + + for (const eventType of eventTypes) { + const listener = (data: unknown) => { + win.webContents.send("server-event", data); + }; + unsubscribeForwarders.push(eventEmitter.on(eventType, listener)); + } + + win.on("closed", () => { + for (const unsubscribe of unsubscribeForwarders) unsubscribe(); + }); + + // Handle label response from renderer + ipcMain.on("snapshot:create-with-label", (_event, { companyId, label }) => { + const createManualSnapshot = (globalThis as any).createManualSnapshot; + if (createManualSnapshot) { + createManualSnapshot(companyId, label || undefined).then((snapshot: any) => { + win.webContents.send("snapshot:notification", { + title: "Snapshot Created", + message: `Snapshot "${label || "Manual"}" created`, + snapshotId: snapshot?.id, + }); + }); + } + }); + + // Cleanup on app quit + app.on("before-quit", () => { + for (const unsubscribe of unsubscribeForwarders) unsubscribe(); + stopDataRefresh(); + stopSnapshot(); + globalShortcut.unregisterAll(); + }); +}); + +// Save settings when they change +ipcMain.on("settings:save", (_event, settings: Partial) => { + const db = getDatabase(); + updateClientSettings(db, settings); + + // Also persist to JSON file for backup and portability + const settingsPath = path.join(getDataDir(), "settings.json"); + try { + const currentSettings = getClientSettings(db); + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), "utf-8"); + console.log("[Settings] Saved settings to", settingsPath); + } catch (error) { + console.error("[Settings] Failed to save settings file:", error); + } +}); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 49e4c79..082125b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js"; +import type { RpcClient, RpcMethod, RpcRequestMap, ServerEvent } from "../../../packages/contracts/src/rpc.js"; +import { ServerEventSchema, ServerEventTypeSchema } from "../../../packages/contracts/src/rpcSchemas.js"; const api: RpcClient = { call(method, payload) { @@ -7,7 +8,67 @@ const api: RpcClient = { } }; -contextBridge.exposeInMainWorld("mosaic", api); +// Event listener management +const eventListeners = new Map void>>(); + +// Subscribe to IPC events from main process +ipcRenderer.on("server-event", (_event, eventData) => { + const parsedEvent = ServerEventSchema.safeParse(eventData); + if (!parsedEvent.success) { + console.warn("[IPC] Ignoring invalid server event."); + return; + } + const event = parsedEvent.data as ServerEvent; + const listeners = eventListeners.get(event.type); + if (listeners) { + for (const listener of listeners) { + try { + listener(event); + } catch (error) { + console.error(`[IPC] Error in event listener for ${event.type}:`, error); + } + } + } +}); + +contextBridge.exposeInMainWorld("mosaic", { + ...api, + // Subscribe to server events + on(eventType: string, callback: (data: unknown) => void) { + const parsedEventType = ServerEventTypeSchema.safeParse(eventType); + if (!parsedEventType.success) { + console.warn(`[IPC] Ignoring subscription for unknown event type: ${eventType}`); + return () => {}; + } + const type = parsedEventType.data; + if (!eventListeners.has(type)) { + eventListeners.set(type, new Set()); + } + eventListeners.get(type)!.add(callback); + + // Return unsubscribe function + return () => { + const listeners = eventListeners.get(type); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + eventListeners.delete(type); + } + } + }; + }, + // Remove all listeners for an event type + removeAllListeners(eventType?: string) { + if (eventType) { + const parsedEventType = ServerEventTypeSchema.safeParse(eventType); + if (parsedEventType.success) { + eventListeners.delete(parsedEventType.data); + } + } else { + eventListeners.clear(); + } + } +}); declare global { interface Window { @@ -16,6 +77,8 @@ declare global { method: T, payload: RpcRequestMap[T] ): Promise>; + on(eventType: string, callback: (data: unknown) => void): () => void; + removeAllListeners(eventType?: string): void; }; } } diff --git a/apps/desktop/src/rpc.ts b/apps/desktop/src/rpc.ts index 02f2e1d..22ab25a 100644 --- a/apps/desktop/src/rpc.ts +++ b/apps/desktop/src/rpc.ts @@ -1,9 +1,19 @@ -import type { RpcMethod, RpcRequestMap, RpcResult } from "../../../packages/contracts/src/rpc.js"; -import { handleMockRpc } from "../../../packages/shared/src/mockRpc.js"; +import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc"; +import { getDatabase, createRpcHandler } from "@mosaiciq/shared/db"; + +// Get database instance (initialized in main.ts) +let dbInstance = getDatabase(); + +// Create RPC handler with database +const dbRpcHandler = createRpcHandler(dbInstance); export async function handleRpc( method: T, payload: RpcRequestMap[T] ): Promise> { - return handleMockRpc(method, payload); + return dbRpcHandler(method, payload); +} + +export function setDatabase(db: typeof dbInstance): void { + dbInstance = db; } diff --git a/apps/desktop/src/snapshotService.ts b/apps/desktop/src/snapshotService.ts new file mode 100644 index 0000000..9775bcd --- /dev/null +++ b/apps/desktop/src/snapshotService.ts @@ -0,0 +1,237 @@ +/** + * Auto-snapshot service + * Creates periodic snapshots of company state + */ + +import type { BrowserWindow } from "electron"; +import type { Db } from "@mosaiciq/shared/db"; +import { createSnapshot, listSnapshots, getSnapshot } from "@mosaiciq/shared/db"; +import type { Snapshot } from "@mosaiciq/contracts/rpc"; + +export interface SnapshotOptions { + interval?: number; // minutes + maxSnapshots?: number; // per company +} + +interface SnapshotChangeTracker { + [companyId: string]: { + changeCount: number; + lastSnapshot: number; + }; +} + +export function startAutoSnapshot( + db: Db, + mainWindow: BrowserWindow, + options: SnapshotOptions = {} +): () => void { + const { + interval = 5, // 5 minutes + maxSnapshots = 100, + } = options; + + let timeout: NodeJS.Timeout | null = null; + const tracker: SnapshotChangeTracker = {}; + + // Get active company ID + function getActiveCompanyId(): string | null { + const row = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as { + active_company_id: string | null; + } | undefined; + return row?.active_company_id || null; + } + + // Capture current company state as JSON + function captureCompanyState(companyId: string): string { + // Get memo sections + const memoSections = db.prepare(` + SELECT id, title, content, updated_at, primary_agent + FROM memo_sections + WHERE company_id = ? + `).all(companyId); + + // Get model data + const models = db.prepare(` + SELECT id, tab FROM models WHERE company_id = ? + `).all(companyId); + + const modelData = models.map((model: any) => { + const headers = db.prepare("SELECT label FROM model_headers WHERE model_id = ? ORDER BY position").all(model.id); + const rows = db.prepare("SELECT label, kind, values FROM model_rows WHERE model_id = ? ORDER BY position").all(model.id); + return { + id: model.id, + tab: model.tab, + headers, + rows, + }; + }); + + // Get workspace sections + const workspaceSections = db.prepare(` + SELECT id, title, content, validation_state, source_agent + FROM workspace_sections + WHERE company_id = ? + `).all(companyId); + + // Get annotations + const annotations = db.prepare(` + SELECT id, section_id, kind, selected_text, comment, created_by, created_at, status + FROM memo_annotations + WHERE company_id = ? + `).all(companyId); + + // Get risks + const risks = db.prepare(` + SELECT id, risk, category, severity, likelihood, mitigation, status + FROM risks + WHERE company_id = ? + `).all(companyId); + + // Get catalysts + const catalysts = db.prepare(` + SELECT id, date, event, impact, thesis_relevance, source + FROM catalysts + WHERE company_id = ? + `).all(companyId); + + return JSON.stringify({ + companyId, + capturedAt: new Date().toISOString(), + memoSections, + modelData, + workspaceSections, + annotations, + risks, + catalysts, + }); + } + + // Create a manual snapshot + async function createManualSnapshot(companyId?: string, label?: string): Promise { + const targetId = companyId || getActiveCompanyId(); + if (!targetId) return null; + + try { + const state = captureCompanyState(targetId); + const snapshot = createSnapshot(db, targetId, state, { + label, + type: "manual", + changeCount: tracker[targetId]?.changeCount || 0, + }); + + // Clean up old snapshots if over limit + cleanupOldSnapshots(targetId, maxSnapshots); + + // Notify UI + mainWindow?.webContents.send("snapshot:created", { + companyId: targetId, + snapshotId: snapshot.id, + label: snapshot.label || snapshot.type, + }); + + // Reset change count + if (tracker[targetId]) { + tracker[targetId].changeCount = 0; + tracker[targetId].lastSnapshot = Date.now(); + } + + console.log(`[Snapshot] Created manual snapshot for ${targetId}`); + return snapshot; + } catch (error) { + console.error("[Snapshot] Error creating manual snapshot:", error); + return null; + } + } + + // Auto-snapshot on interval + async function performAutoSnapshot() { + const companyId = getActiveCompanyId(); + if (!companyId) { + // Schedule next check + timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000); + return; + } + + // Initialize tracker if needed + if (!tracker[companyId]) { + tracker[companyId] = { + changeCount: 0, + lastSnapshot: Date.now(), + }; + } + + // Check if there are changes + const companyTracker = tracker[companyId]; + if (companyTracker.changeCount > 0) { + try { + const state = captureCompanyState(companyId); + createSnapshot(db, companyId, state, { + type: "auto", + changeCount: companyTracker.changeCount, + }); + + // Clean up old snapshots + cleanupOldSnapshots(companyId, maxSnapshots); + + // Notify UI + mainWindow?.webContents.send("snapshot:created", { + companyId, + snapshotId: `snapshot-${Date.now()}`, + label: "Auto-snapshot", + }); + + // Reset change count + companyTracker.changeCount = 0; + companyTracker.lastSnapshot = Date.now(); + + console.log(`[Snapshot] Auto-snapshot created for ${companyId}`); + } catch (error) { + console.error("[Snapshot] Error creating auto-snapshot:", error); + } + } + + // Schedule next snapshot + timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000); + } + + // Keep only the most recent snapshots + function cleanupOldSnapshots(companyId: string, maxCount: number) { + const snapshots = listSnapshots(db, companyId); + if (snapshots.length > maxCount) { + // Delete oldest snapshots beyond the limit + const toDelete = snapshots.slice(maxCount); + const stmt = db.prepare("DELETE FROM snapshots WHERE id = ?"); + for (const snapshot of toDelete) { + stmt.run(snapshot.id); + } + console.log(`[Snapshot] Cleaned up ${toDelete.length} old snapshots for ${companyId}`); + } + } + + // Track changes to data + function trackChange(companyId: string) { + if (!tracker[companyId]) { + tracker[companyId] = { + changeCount: 0, + lastSnapshot: Date.now(), + }; + } + tracker[companyId].changeCount++; + } + + // Start auto-snapshot cycle + console.log("[Snapshot] Starting auto-snapshot service"); + timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000); + + // Expose the manual snapshot function + (globalThis as any).createManualSnapshot = createManualSnapshot; + (globalThis as any).trackSnapshotChange = trackChange; + + // Return cleanup function + return () => { + if (timeout) clearTimeout(timeout); + delete (globalThis as any).createManualSnapshot; + delete (globalThis as any).trackSnapshotChange; + console.log("[Snapshot] Stopped auto-snapshot service"); + }; +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..520b537 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "electron"], + "lib": ["ES2022", "DOM"] + }, + "include": ["src", "tsdown.config.ts"] +} diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts new file mode 100644 index 0000000..982e310 --- /dev/null +++ b/apps/desktop/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsdown"; + +const shared = { + format: "cjs" as const, + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), +}; + +export default defineConfig([ + { + ...shared, + entry: ["src/main.ts"], + clean: true, + noExternal: (id) => id.startsWith("@mosaiciq/"), + }, + { + ...shared, + entry: ["src/preload.ts"], + }, +]); diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts index 002abe9..a82d22d 100644 --- a/apps/web/src/global.d.ts +++ b/apps/web/src/global.d.ts @@ -1,8 +1,11 @@ -import type { RpcClient } from "../../../packages/contracts/src/rpc"; +import type { RpcClient, ServerEvent } from "../../../packages/contracts/src/rpc"; declare global { interface Window { - mosaic?: RpcClient; + mosaic?: RpcClient & { + on(eventType: string, callback: (data: ServerEvent) => void): () => void; + removeAllListeners(eventType?: string): void; + }; } } diff --git a/apps/web/src/hooks/useServerEvents.ts b/apps/web/src/hooks/useServerEvents.ts new file mode 100644 index 0000000..fd980e1 --- /dev/null +++ b/apps/web/src/hooks/useServerEvents.ts @@ -0,0 +1,173 @@ +/** + * React hook for subscribing to server events + */ + +import { useEffect, useCallback, useRef } from "react"; +import type { ServerEvent } from "@mosaiciq/contracts/rpc"; + +type EventListener = (event: ServerEvent) => void; +type EventType = ServerEvent["type"]; + +export function useServerEvents( + eventType: EventType, + callback: (data: ServerEvent) => void, + deps: React.DependencyList = [] +) { + const callbackRef = useRef(callback); + + // Keep callback ref updated + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + // Check if mosaic API is available + if (!window.mosaic) { + console.warn("[useServerEvents] mosaic API not available"); + return; + } + + // Check if on method exists + if (typeof window.mosaic.on !== "function") { + console.warn("[useServerEvents] mosaic.on method not available"); + return; + } + + // Subscribe to events + const unsubscribe = window.mosaic.on(eventType, (data: unknown) => { + const event = data as ServerEvent; + callbackRef.current(event); + }); + + // Cleanup on unmount + return () => { + unsubscribe(); + }; + }, [eventType, ...deps]); +} + +export function useMultiServerEvents( + eventTypes: EventType[], + callback: (event: ServerEvent) => void, + deps: React.DependencyList = [] +) { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + if (!window.mosaic || typeof window.mosaic.on !== "function") { + return; + } + + const unsubscribes: Array<() => void> = []; + + for (const eventType of eventTypes) { + const unsubscribe = window.mosaic.on(eventType, (data: unknown) => { + const event = data as ServerEvent; + callbackRef.current(event); + }); + unsubscribes.push(unsubscribe); + } + + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }; + }, [eventTypes, ...deps]); +} + +/** + * Hook for agent-specific events + */ +export function useAgentEvents( + agentId: string | null, + onProgress?: (progress: number, action: string) => void, + onCompleted?: (output: unknown) => void, + onFailed?: (error: string) => void, + onStreaming?: (chunk: string) => void +) { + const handleEvent = useCallback((event: ServerEvent) => { + if (!agentId) return; + + if (event.type === "agent.progress" && event.data.agentId === agentId) { + onProgress?.(event.data.progress, event.data.action); + } else if (event.type === "agent.completed" && event.data.agentId === agentId) { + onCompleted?.(event.data.output); + } else if (event.type === "agent.failed" && event.data.agentId === agentId) { + onFailed?.(event.data.error); + } else if (event.type === "agent.streaming" && event.data.agentId === agentId) { + onStreaming?.(event.data.chunk); + } + }, [agentId, onProgress, onCompleted, onFailed, onStreaming]); + + useMultiServerEvents( + ["agent.progress", "agent.completed", "agent.failed", "agent.streaming"], + handleEvent, + [agentId] + ); +} + +/** + * Hook for all agent events (for agent list updates) + */ +export function useAllAgentEvents( + onAgentUpdate?: (agentId: string, update: { status?: string; progress?: number; action?: string }) => void +) { + const handleEvent = useCallback((event: ServerEvent) => { + if (event.type === "agent.progress") { + onAgentUpdate?.(event.data.agentId, { + progress: event.data.progress, + action: event.data.action, + status: "running" + }); + } else if (event.type === "agent.completed") { + onAgentUpdate?.(event.data.agentId, { + status: "completed", + progress: 100 + }); + } else if (event.type === "agent.failed") { + onAgentUpdate?.(event.data.agentId, { + status: "failed" + }); + } else if (event.type === "agent.started") { + onAgentUpdate?.(event.data.agentId, { + status: "running", + progress: 0 + }); + } + }, [onAgentUpdate]); + + useMultiServerEvents( + ["agent.progress", "agent.completed", "agent.failed", "agent.started"], + handleEvent, + [] + ); +} + +/** + * Hook for validation events + */ +export function useValidationEvents( + companyId: string | null, + onValidationUpdated?: (data: { + sectionId?: string; + validationState: "verified" | "flagged" | "unverified" | "failed"; + agentId: string; + notes?: string; + }) => void +) { + useServerEvents("validation.updated", (event) => { + if (companyId && event.data.companyId === companyId) { + onValidationUpdated?.(event.data as { + sectionId?: string; + validationState: "verified" | "flagged" | "unverified" | "failed"; + agentId: string; + notes?: string; + }); + } + }, [companyId, onValidationUpdated]); +} diff --git a/apps/web/src/rpcClient.ts b/apps/web/src/rpcClient.ts index 88f33e5..9bd2140 100644 --- a/apps/web/src/rpcClient.ts +++ b/apps/web/src/rpcClient.ts @@ -1,12 +1,18 @@ import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc"; +import { parseRpcResponse } from "../../../packages/contracts/src/rpcSchemas"; import { handleMockRpc } from "../../../packages/shared/src/mockRpc"; export const rpc: RpcClient = { - call(method: T, payload: RpcRequestMap[T]) { - if (!window.mosaic) { - return handleMockRpc(method, payload); + async call(method: T, payload: RpcRequestMap[T]) { + const useMockRpc = !window.mosaic; + if (useMockRpc) { + const result = await handleMockRpc(method, payload); + if (process.env.NODE_ENV !== "production" && result.ok) { + parseRpcResponse(method, result.data); + } + return result; } - return window.mosaic.call(method, payload); + return window.mosaic!.call(method, payload); } }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 6c32cf8..6347343 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -19,6 +19,16 @@ --density-cell-pad: 8px 12px; --density-metric-min: 140px; --density-nav-w: 240px; + + /* Component-specific tokens */ + --tag-bg: oklch(94% 0.01 80); + --tag-fg: oklch(48% 0.015 60); + --input-bg: oklch(99% 0.005 80); + --input-border: oklch(89% 0.012 80); + --hover-bg: oklch(94% 0.01 80); + --active-bg: oklch(92% 0.015 80); + --shadow: 0 1px 3px rgba(0,0,0,0.1); + --focus-ring: oklch(58% 0.16 35); } [data-theme="dark"] { @@ -30,6 +40,16 @@ --accent: oklch(65% 0.16 35); --green: oklch(60% 0.12 145); --red: oklch(60% 0.14 25); + + /* Component-specific overrides */ + --tag-bg: oklch(24% 0.01 80); + --tag-fg: oklch(70% 0.01 80); + --input-bg: oklch(18% 0.008 80); + --input-border: oklch(28% 0.01 80); + --hover-bg: oklch(22% 0.015 80); + --active-bg: oklch(25% 0.02 80); + --shadow: 0 1px 3px rgba(0,0,0,0.3); + --focus-ring: oklch(65% 0.16 35); } [data-density="compact"] { @@ -189,6 +209,21 @@ button, input, select, textarea { font: inherit; } background: oklch(28% 0.03 80); } +/* ─── Dark Mode Component Overrides ─── */ +[data-theme="dark"] .memo-editor-body { + color: var(--fg); +} +[data-theme="dark"] .memo-editor-body a { + color: oklch(70% 0.16 35); +} +[data-theme="dark"] .memo-editor-body code { + background: oklch(22% 0.01 80); +} +[data-theme="dark"] .memo-editor-body blockquote { + color: var(--muted); + border-left-color: oklch(65% 0.16 35); +} + /* ─── Responsive ─── */ @media (max-width: 1023px) { body > *:not(.floor-message) { display: none !important; } diff --git a/apps/web/src/ui/App.tsx b/apps/web/src/ui/App.tsx index bad6790..c2535e3 100644 --- a/apps/web/src/ui/App.tsx +++ b/apps/web/src/ui/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Agent, Company, ExportRecord, Holding, MemoAnnotation, MemoSection, MemoSectionReview, ModelRow, RpcResponseMap, Screen } from "@mosaiciq/contracts/rpc"; +import type { Agent, ClientSettings, Company, ExportRecord, Holding, MemoAnnotation, MemoSection, MemoSectionReview, ModelRow, RpcResponseMap, Screen } from "@mosaiciq/contracts/rpc"; import { rpc } from "../rpcClient"; import { cx } from "../lib/cn"; import { capitalize, formatPct, formatTime, toneText } from "../lib/format"; @@ -7,6 +7,7 @@ import { editableHtmlToMarkdown, getAnnotationSignature, getCaretTextOffset, get import { agentCatalog, demoCatalysts, demoAlerts, demoRisks, demoEarnings, demoFilings, demoExports, extraHoldings, keyboardShortcuts, modelTabs, screens, workspaceGroups } from "../lib/constants"; import { ui } from "../lib/styles"; import type { Alert, Catalyst, EarningsSchedule, Filing, Risk } from "@mosaiciq/contracts/rpc"; +import { useAllAgentEvents } from "../hooks/useServerEvents"; type AppData = { holdings: Holding[]; @@ -74,6 +75,31 @@ export function App() { } }, [theme, density]); + // Load settings from backend on mount + useEffect(() => { + async function loadSettings() { + const result = await rpc.call("settings.get", { scope: "client" }); + if (result.ok) { + const settings = result.data.settings as ClientSettings; + if (settings.theme) setTheme(settings.theme); + if (settings.density) setDensity(settings.density); + } + } + void loadSettings(); + }, []); + + // Persist theme changes + const handleThemeChange = useCallback(async (newTheme: Theme) => { + setTheme(newTheme); + await rpc.call("settings.update", { scope: "client", changes: { theme: newTheme } }); + }, []); + + // Persist density changes + const handleDensityChange = useCallback(async (newDensity: Density) => { + setDensity(newDensity); + await rpc.call("settings.update", { scope: "client", changes: { density: newDensity } }); + }, []); + useEffect(() => { let cancelled = false; async function load() { @@ -116,6 +142,19 @@ export function App() { const activeAgents = useMemo(() => data.agents.filter((a) => a.status === "running" || a.status === "paused"), [data.agents]); + // Subscribe to agent events for real-time updates + useAllAgentEvents(useCallback((agentId: string, update: { status?: string; progress?: number; action?: string }) => { + setData((prev) => ({ + ...prev, + agents: prev.agents.map((a) => { + if (a.id === agentId) { + return { ...a, ...update } as typeof a; + } + return a; + }), + })); + }, [])); + useEffect(() => { function handleKey(e: KeyboardEvent) { const mod = e.metaKey || e.ctrlKey; @@ -167,7 +206,7 @@ export function App() { if (res.ok) setAgentChatMessages((m) => [...m, { role: "agent", text: res.data.response }]); }} chatInput={agentChatInput} onChatInputChange={setAgentChatInput} /> - setSettingsOpen(false)} panel={settingsPanel} onPanelChange={setSettingsPanel} theme={theme} onThemeChange={setTheme} density={density} onDensityChange={setDensity} /> + setSettingsOpen(false)} panel={settingsPanel} onPanelChange={setSettingsPanel} theme={theme} onThemeChange={handleThemeChange} density={density} onDensityChange={handleDensityChange} /> setProfileOpen(false)} /> setAgentFullscreenOpen(false)} agents={data.agents} activeTab={agentFullscreenTab} onTabChange={setAgentFullscreenTab} chatMessages={agentChatMessages} chatInput={agentChatInput} onChatInputChange={setAgentChatInput} onChatSend={async (msg) => { setAgentChatMessages((m) => [...m, { role: "analyst", text: msg }]); diff --git a/apps/web/src/ui/components/AgentConfigPanel.tsx b/apps/web/src/ui/components/AgentConfigPanel.tsx new file mode 100644 index 0000000..6cfcb6f --- /dev/null +++ b/apps/web/src/ui/components/AgentConfigPanel.tsx @@ -0,0 +1,428 @@ +/** + * Agent Configuration Panel + * Per design doc §6.3 - Slide-in 360px panel for agent customization + */ + +import { useState, useEffect } from "react"; +import { cx } from "../../lib/cn"; +import { rpc } from "../../rpcClient"; +import { ui } from "../../lib/styles"; + +export interface AgentConfig { + dataSources: { + secFilings: boolean; + transcripts: boolean; + marketData: boolean; + analystReports: boolean; + pressReleases: boolean; + }; + model: { + llm: "pi" | "gpt-4" | "claude"; + temperature: number; + maxTokens: number; + }; + scheduling: "auto" | "daily" | "manual"; + outputFormat: { + includeCitations: boolean; + includeConfidence: boolean; + includeAssumptions: boolean; + }; +} + +const defaultConfig: AgentConfig = { + dataSources: { + secFilings: true, + transcripts: true, + marketData: false, + analystReports: false, + pressReleases: true, + }, + model: { + llm: "pi", + temperature: 0, + maxTokens: 4096, + }, + scheduling: "manual", + outputFormat: { + includeCitations: true, + includeConfidence: true, + includeAssumptions: true, + }, +}; + +export function AgentConfigPanel({ + agentId, + open, + onClose, +}: { + agentId: string; + open: boolean; + onClose: () => void; +}) { + const [config, setConfig] = useState(defaultConfig); + const [hasChanges, setHasChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Load existing config when panel opens + useEffect(() => { + if (open && agentId) { + loadConfig(); + } + }, [open, agentId]); + + async function loadConfig() { + const result = await rpc.call("settings.get", { scope: "server" }); + if (result.ok) { + const settings = result.data.settings as { agentConfigs?: Record }; + const agentConfig = settings.agentConfigs?.[agentId] as AgentConfig | undefined; + if (agentConfig) { + setConfig(agentConfig); + } + } + } + + async function saveConfig() { + setIsSaving(true); + await rpc.call("agent.configure", { agentId, config: config as unknown as Record }); + await rpc.call("settings.update", { scope: "server", changes: { agentConfigs: { [agentId]: config } } }); + setIsSaving(false); + setHasChanges(false); + } + + function updateConfig(key: K, value: AgentConfig[K]) { + setConfig((prev) => ({ ...prev, [key]: value })); + setHasChanges(true); + } + + function updateDataSource(key: keyof AgentConfig["dataSources"], value: boolean) { + setConfig((prev) => ({ + ...prev, + dataSources: { ...prev.dataSources, [key]: value }, + })); + setHasChanges(true); + } + + function updateOutputFormat(key: keyof AgentConfig["outputFormat"], value: boolean) { + setConfig((prev) => ({ + ...prev, + outputFormat: { ...prev.outputFormat, [key]: value }, + })); + setHasChanges(true); + } + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

Agent Configuration

+

{agentId.toUpperCase()}

+
+ +
+ + {/* Content */} +
+ {/* Data Sources */} +
+

+ Data Sources +

+
+ updateDataSource("secFilings", v)} + /> + updateDataSource("transcripts", v)} + /> + updateDataSource("marketData", v)} + /> + updateDataSource("analystReports", v)} + /> + updateDataSource("pressReleases", v)} + /> +
+
+ + {/* Model Settings */} +
+

+ Model Settings +

+
+ updateConfig("model", { ...config.model, llm: v as any })} + /> + v.toFixed(1)} + onChange={(v) => updateConfig("model", { ...config.model, temperature: v })} + /> + String(Math.round(v))} + onChange={(v) => updateConfig("model", { ...config.model, maxTokens: v })} + /> +
+
+ + {/* Scheduling */} +
+

+ Scheduling +

+
+ updateConfig("scheduling", "auto")} + /> + updateConfig("scheduling", "daily")} + /> + updateConfig("scheduling", "manual")} + /> +
+
+ + {/* Output Format */} +
+

+ Output Format +

+
+ updateOutputFormat("includeCitations", v)} + /> + updateOutputFormat("includeConfidence", v)} + /> + updateOutputFormat("includeAssumptions", v)} + /> +
+
+
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ); +} + +function ToggleRow({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( +
+ {label} + +
+ ); +} + +function RadioRow({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: () => void; +}) { + return ( +
+ {label} + +
+ ); +} + +function SelectRow({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +}) { + return ( +
+ + +
+ ); +} + +function RangeRow({ + label, + value, + min, + max, + step, + formatValue, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + formatValue: (v: number) => string; + onChange: (value: number) => void; +}) { + return ( +
+
+ + + {formatValue(value)} + +
+ onChange(Number(e.target.value))} + /> +
+ ); +} diff --git a/apps/web/src/ui/components/EmptyState.tsx b/apps/web/src/ui/components/EmptyState.tsx new file mode 100644 index 0000000..0f78d3c --- /dev/null +++ b/apps/web/src/ui/components/EmptyState.tsx @@ -0,0 +1,186 @@ +/** + * Empty State Components + * Displayed when there's no content to show + */ + +import { cx } from "../../lib/cn"; +import { ui } from "../../lib/styles"; + +export interface EmptyStateProps { + icon?: string; + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; + size?: "sm" | "md" | "lg"; + className?: string; +} + +const sizeStyles = { + sm: { + icon: "text-2xl", + title: "text-sm", + description: "text-xs", + }, + md: { + icon: "text-3xl", + title: "text-base", + description: "text-sm", + }, + lg: { + icon: "text-4xl", + title: "text-lg", + description: "text-sm", + }, +}; + +export function EmptyState({ + icon = "◇", + title, + description, + action, + size = "md", + className, +}: EmptyStateProps) { + const styles = sizeStyles[size]; + + return ( +
+ +

{title}

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ); +} + +/** + * Empty state for specific surfaces + */ + +export function EmptyHoldings({ onAdd }: { onAdd?: () => void }) { + return ( + + ); +} + +export function EmptyAgents({ onStart }: { onStart?: () => void }) { + return ( + + ); +} + +export function EmptyMemo({ onWrite }: { onWrite?: () => void }) { + return ( + + ); +} + +export function EmptyModel({ onBuild }: { onBuild?: () => void }) { + return ( + + ); +} + +export function EmptyCatalysts() { + return ( + + ); +} + +export function EmptyAlerts() { + return ( + + ); +} + +export function EmptyRisks({ onAdd }: { onAdd?: () => void }) { + return ( + + ); +} + +export function EmptyExports() { + return ( + + ); +} + +/** + * Empty state wrapper - shows empty state when array is empty + */ +export function ShowWhenEmpty({ + items, + empty, + children, +}: { + items: T[] | undefined; + empty: React.ReactNode; + children: React.ReactNode; +}) { + if (!items || items.length === 0) { + return <>{empty}; + } + return <>{children}; +} diff --git a/apps/web/src/ui/components/ErrorState.tsx b/apps/web/src/ui/components/ErrorState.tsx new file mode 100644 index 0000000..a86e3ea --- /dev/null +++ b/apps/web/src/ui/components/ErrorState.tsx @@ -0,0 +1,247 @@ +/** + * Error State Components + * Displayed when an error occurs + */ + +import { cx } from "../../lib/cn"; +import { ui } from "../../lib/styles"; + +export type ErrorSeverity = "info" | "warning" | "error" | "critical"; + +export interface ErrorStateProps { + title: string; + message?: string; + severity?: ErrorSeverity; + action?: { + label: string; + onClick: () => void; + }; + dismissible?: boolean; + onDismiss?: () => void; + className?: string; +} + +const severityConfig = { + info: { + icon: "ℹ", + containerClass: "border-l-[var(--muted)] bg-[oklch(97%_0.008_80)]", + iconClass: "text-[var(--muted)]", + titleClass: "text-[var(--fg)]", + }, + warning: { + icon: "⚠", + containerClass: "border-l-[var(--accent)] bg-[oklch(96%_0.03_80)]", + iconClass: "text-[var(--accent)]", + titleClass: "text-[var(--fg)]", + }, + error: { + icon: "!", + containerClass: "border-l-[var(--red)] bg-[oklch(96%_0.04_25)]", + iconClass: "text-[var(--red)]", + titleClass: "text-[var(--fg)]", + }, + critical: { + icon: "✕", + containerClass: "border-l-[var(--red)] bg-[oklch(94%_0.06_25)]", + iconClass: "text-[var(--red)]", + titleClass: "text-[var(--red)]", + }, +}; + +const darkModeOverrides = { + info: { + containerClass: "border-l-[var(--muted)] bg-[oklch(22%_0.01_80)]", + }, + warning: { + containerClass: "border-l-[var(--accent)] bg-[oklch(24%_0.02_80)]", + }, + error: { + containerClass: "border-l-[var(--red)] bg-[oklch(24%_0.03_25)]", + }, + critical: { + containerClass: "border-l-[var(--red)] bg-[oklch(22%_0.05_25)]", + }, +}; + +export function ErrorState({ + title, + message, + severity = "error", + action, + dismissible = false, + onDismiss, + className, +}: ErrorStateProps) { + const config = severityConfig[severity]; + + return ( +
+
+ +
+

{title}

+ {message && ( +

{message}

+ )} + {action && ( + + )} +
+ {dismissible && onDismiss && ( + + )} +
+
+ ); +} + +/** + * Inline error state - for smaller spaces + */ +export function InlineError({ + message, + onRetry, +}: { + message: string; + onRetry?: () => void; +}) { + return ( +
+ + {message} + {onRetry && ( + + )} +
+ ); +} + +/** + * Full-page error state + */ +export function FullPageError({ + title = "Something went wrong", + message, + onRetry, + onDismiss, +}: { + title?: string; + message?: string; + onRetry?: () => void; + onDismiss?: () => void; +}) { + return ( +
+
+ +

{title}

+ {message &&

{message}

} +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+
+ ); +} + +/** + * Error panel - for sections within a page + */ +export function ErrorPanel({ + title, + message, + severity = "error", + onRetry, + onDismiss, +}: { + title: string; + message?: string; + severity?: ErrorSeverity; + onRetry?: () => void; + onDismiss?: () => void; +}) { + return ( + + ); +} + +/** + * API error display - for RPC errors + */ +export function ApiError({ + error, + onRetry, + onDismiss, +}: { + error: { code: string; message: string; detail?: unknown }; + onRetry?: () => void; + onDismiss?: () => void; +}) { + const severityMap: Record = { + NOT_FOUND: "warning", + VALIDATION_ERROR: "warning", + INTERNAL_ERROR: "error", + AGENT_FAILED: "error", + CONFLICT: "warning", + RATE_LIMITED: "info", + }; + + const severity = severityMap[error.code] || "error"; + + return ( + + ); +} diff --git a/apps/web/src/ui/components/Skeleton.tsx b/apps/web/src/ui/components/Skeleton.tsx new file mode 100644 index 0000000..98db254 --- /dev/null +++ b/apps/web/src/ui/components/Skeleton.tsx @@ -0,0 +1,267 @@ +/** + * Skeleton Loading Components + * Display shimmer placeholders while content is loading + */ + +import { cx } from "../../lib/cn"; +import { ui } from "../../lib/styles"; + +/** + * Generic skeleton block + */ +export function Skeleton({ + className, + variant = "default", + width, + height, +}: { + className?: string; + variant?: "default" | "circle" | "rounded"; + width?: string; + height?: string; +}) { + return ( +