From f3f26a8d2384e90958373efdb45cbb3bc92c4922 Mon Sep 17 00:00:00 2001 From: jshay21 Date: Sat, 17 Jan 2026 17:32:13 -0800 Subject: [PATCH 01/32] feat: introduce agent activity feed, tool notifications, artifact management, and refined notification settings. --- docs/CLAUDE_INTEGRATION_ARCHITECTURE.md | 776 ++++++++++++++ docs/OBSERVER_COMPARISON.md | 955 ++++++++++++++++++ src/main/lib/trpc/routers/artifacts.ts | 164 +++ src/main/lib/trpc/routers/index.ts | 2 + src/main/windows/main.ts | 24 + src/preload/index.ts | 3 + .../settings-tabs/agents-preferences-tab.tsx | 78 ++ .../features/activity/activity-feed.tsx | 142 +++ src/renderer/features/activity/index.ts | 1 + .../agents/hooks/use-tool-notifications.ts | 241 +++++ .../features/agents/lib/ipc-chat-transport.ts | 31 + .../features/agents/main/active-chat.tsx | 41 +- .../features/layout/agents-layout.tsx | 5 + .../features/sidebar/agents-sidebar.tsx | 25 +- .../hooks/use-desktop-notifications.ts | 34 +- src/renderer/lib/atoms/index.ts | 81 ++ 16 files changed, 2581 insertions(+), 22 deletions(-) create mode 100644 docs/CLAUDE_INTEGRATION_ARCHITECTURE.md create mode 100644 docs/OBSERVER_COMPARISON.md create mode 100644 src/main/lib/trpc/routers/artifacts.ts create mode 100644 src/renderer/features/activity/activity-feed.tsx create mode 100644 src/renderer/features/activity/index.ts create mode 100644 src/renderer/features/agents/hooks/use-tool-notifications.ts diff --git a/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md b/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md new file mode 100644 index 00000000..176ee07a --- /dev/null +++ b/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md @@ -0,0 +1,776 @@ +# Claude Integration Architecture + +## Overview + +21st Agents is an Electron desktop application that provides a local-first interface to Claude Code. This document explains how the application integrates with Claude, manages the Claude binary, and orchestrates communication between the UI and Claude's execution environment. + +## Why Download the Claude Binary? + +The application downloads and bundles the native Claude Code binary for several critical reasons: + +### 1. **Offline-First Architecture** +- Users can run Claude Code without requiring an internet connection to fetch the binary each time +- Binary is bundled with the application for immediate availability +- No dependency on external CDN availability during execution + +### 2. **Version Control & Consistency** +- Specific Claude Code version (default: 2.1.5) ensures consistent behavior across all users +- Prevents "works on my machine" issues from version mismatches +- Controlled upgrade path for new Claude Code releases + +### 3. **Security & Integrity** +- SHA256 checksum verification ensures binary hasn't been tampered with +- Downloaded from official Google Cloud Storage: `storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819` +- Eliminates risk of runtime binary substitution attacks + +### 4. **Cross-Platform Support** +- Pre-built binaries for all supported platforms: + - `darwin-arm64` (Apple Silicon) + - `darwin-x64` (Intel Mac) + - `linux-arm64` (ARM Linux) + - `linux-x64` (x86_64 Linux) + - `win32-x64` (Windows) +- Users don't need to build or install Claude Code separately + +### 5. **Isolation & Control** +- Application controls exact binary location and execution environment +- No conflicts with user's global Claude Code installation +- Full control over environment variables, working directory, and configuration + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ React UI (active-chat.tsx) │ │ +│ │ - User types message │ │ +│ │ - Reads atoms (extended thinking, model selection) │ │ +│ │ - Calls IPCChatTransport.sendMessages() │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ tRPC Client (trpc.ts) │ │ +│ │ - trpcClient.claude.chat.subscribe() │ │ +│ │ - Type-safe IPC communication │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +└────────────────────────────┼──────────────────────────────────────┘ + │ + │ Electron IPC (via tRPC) + │ +┌────────────────────────────▼──────────────────────────────────────┐ +│ Main Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ tRPC Router (routers/claude.ts) │ │ +│ │ - Receives subscription request │ │ +│ │ - Loads messages from SQLite DB │ │ +│ │ - Parses @mentions for agents/skills/tools │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Environment Setup (lib/claude/env.ts) │ │ +│ │ 1. getBundledClaudeBinaryPath() │ │ +│ │ - Resolves: resources/bin/{platform}-{arch}/claude │ │ +│ │ - Verifies binary exists and is executable │ │ +│ │ │ │ +│ │ 2. buildClaudeEnv() │ │ +│ │ - Loads shell environment via `zsh -ilc env` │ │ +│ │ - Merges with process.env │ │ +│ │ - Adds CLAUDE_CODE_OAUTH_TOKEN if authenticated │ │ +│ │ - Sets HOME, USER, SHELL, TERM │ │ +│ │ - Sets CLAUDE_CODE_ENTRYPOINT="sdk-ts" │ │ +│ │ - Removes potentially conflicting API keys │ │ +│ │ │ │ +│ │ 3. setupIsolatedSessionDir() │ │ +│ │ - Creates: {userData}/claude-sessions/{subChatId}/ │ │ +│ │ - Symlinks ~/.claude/agents/ and ~/.claude/skills/ │ │ +│ │ - Prevents cross-chat configuration contamination │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Claude SDK Query (@anthropic-ai/claude-agent-sdk) │ │ +│ │ const { query } = await import("claude-agent-sdk") │ │ +│ │ │ │ +│ │ query({ │ │ +│ │ cwd: projectPath, │ │ +│ │ systemPrompt: "claude_code", │ │ +│ │ permissionMode: "plan" | "bypassPermissions", │ │ +│ │ agents: [...mentionedAgents], │ │ +│ │ mcpServers: {...projectMcpConfig}, │ │ +│ │ pathToClaudeCodeExecutable: binaryPath, │ │ +│ │ settingSources: ["project", "user"], │ │ +│ │ canUseTool: (toolName) => approvalCallback(), │ │ +│ │ extendedThinking: { maxTokens: 10000 }, │ │ +│ │ resume: sessionId, // For multi-turn conversations │ │ +│ │ }) │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ Claude Binary │ │ +│ │ (spawned process) │ │ +│ │ - Reads .claude.json│ │ +│ │ - Loads MCP servers │ │ +│ │ - Executes tools │ │ +│ │ - Streams responses │ │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Stream Transformer │ │ +│ │ - Converts SDK format → UIMessageChunk format │ │ +│ │ - Handles text, tool calls, thinking, system messages │ │ +│ │ - Accumulates parts into complete assistant message │ │ +│ │ - Saves to DB with sessionId for resumption │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +└───────────────────────────┼───────────────────────────────────────┘ + │ + │ tRPC Subscription Stream + │ +┌───────────────────────────▼───────────────────────────────────────┐ +│ Renderer Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Stream Consumer │ │ +│ │ - Receives UIMessageChunk objects │ │ +│ │ - Updates chat UI in real-time │ │ +│ │ - Renders text, tool calls, thinking, diffs │ │ +│ │ - Shows notifications for errors │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +## Binary Management + +### Download Process + +**Script:** `scripts/download-claude-binary.mjs` + +```bash +# Download for current platform +bun run claude:download + +# Download for all platforms (for building releases) +bun run claude:download:all + +# Download specific version +bun run claude:download --version=2.1.5 +``` + +**Process:** +1. Detect platform and architecture +2. Fetch manifest from: `https://storage.googleapis.com/claude-code-dist-.../claude-code-releases/{version}/manifest.json` +3. Download binary for platform +4. Verify SHA256 checksum +5. Save to: `resources/bin/{platform}-{arch}/claude` (or `claude.exe` on Windows) +6. Make executable (chmod 0o755 on Unix) +7. Write version metadata to `resources/bin/VERSION` + +**Version Detection:** +- Default: 2.1.5 +- Fallback: Auto-detect latest from `https://claude.ai/install.sh` + +### Binary Storage Locations + +| Environment | Path | +|-------------|------| +| Development | `resources/bin/{platform}-{arch}/claude` | +| Production (macOS) | `{app.asar.unpacked}/resources/bin/claude` | +| Production (Windows) | `{app.asar.unpacked}/resources/bin/claude.exe` | + +### Binary Path Resolution + +**Function:** `getBundledClaudeBinaryPath()` in `src/main/lib/claude/env.ts` + +```typescript +// Logic: +if (is.dev) { + // Development: platform-specific subdirectory + return path.join(resources, 'bin', `${platform}-${arch}`, binaryName) +} else { + // Production: resources/bin/ (copied during build) + return path.join(process.resourcesPath, 'bin', binaryName) +} +``` + +The function includes extensive logging and verification: +- Platform and architecture detection +- File existence check +- Executable permission verification +- File size logging +- Debug output with `[getBundledClaudeBinaryPath]` prefix + +## Environment Configuration + +### buildClaudeEnv() Process + +**Location:** `src/main/lib/claude/env.ts:166-216` + +This is a sophisticated multi-step process that ensures Claude Code runs with the correct environment: + +#### Step 1: Shell Environment Loading + +```typescript +// Spawn interactive login shell to capture full environment +const { stdout } = await execAsync('zsh -ilc env', { + env: { + HOME: app.getPath('home'), + USER: process.env.USER, + LOGNAME: process.env.LOGNAME, + }, + timeout: 5000, +}) + +// Parse key=value pairs from shell output +const shellEnv = parseEnvOutput(stdout) +``` + +**Why?** Electron apps have a minimal PATH that doesn't include user-installed tools (brew, npm, etc.). Loading the shell environment ensures Claude has access to all user tools. + +#### Step 2: Environment Merging + +```typescript +const mergedEnv = { + ...shellEnv, // Shell environment (base) + ...process.env, // Current process env (overlays) + PATH: shellEnv.PATH // Restore shell PATH (critical!) +} +``` + +#### Step 3: Environment Stripping + +Remove potentially interfering variables: +```typescript +delete mergedEnv.ANTHROPIC_API_KEY // Prevent key confusion +delete mergedEnv.OPENAI_API_KEY +delete mergedEnv.CLAUDE_CODE_USE_BEDROCK +delete mergedEnv.CLAUDE_CODE_USE_VERTEX +``` + +#### Step 4: Required Variables + +```typescript +const finalEnv = { + ...mergedEnv, + HOME: app.getPath('home'), + USER: os.userInfo().username, + SHELL: process.env.SHELL || '/bin/zsh', + TERM: 'xterm-256color', + CLAUDE_CODE_ENTRYPOINT: 'sdk-ts', // Identifies this as SDK usage +} +``` + +#### Step 5: Authentication Token + +If user has authenticated with Claude Code OAuth: +```typescript +if (authToken) { + finalEnv.CLAUDE_CODE_OAUTH_TOKEN = authToken +} +``` + +**Token Storage:** +- Stored in SQLite DB: `{userData}/data/agents.db` +- Encrypted using Electron's `safeStorage` API (OS keychain) +- Retrieved via `authStore.getClaudeCodeOAuthToken()` + +### Fallback PATH Strategy + +If shell environment loading fails (timeout, error), uses hardcoded fallback: + +```typescript +const fallbackPath = [ + path.join(homeDir, '.local', 'bin'), + '/opt/homebrew/bin', // Apple Silicon Homebrew + '/usr/local/bin', // Intel Homebrew + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', +].join(':') +``` + +## Session Isolation + +### Isolated Configuration Directories + +**Function:** `setupIsolatedSessionDir()` in `routers/claude.ts` + +Each sub-chat gets its own isolated configuration directory to prevent cross-contamination: + +``` +{userData}/claude-sessions/{subChatId}/ +├── agents/ → symlink to ~/.claude/agents/ +└── skills/ → symlink to ~/.claude/skills/ +``` + +**Why?** +- Prevents one chat's configuration from affecting another +- Allows safe concurrent Claude sessions +- Each session can have different tool approvals, settings, etc. + +**Implementation:** +```typescript +const sessionConfigDir = path.join( + app.getPath('userData'), + 'claude-sessions', + subChatId +) + +await fs.ensureDir(sessionConfigDir) + +// Symlink shared agents and skills +const agentsDir = path.join(os.homedir(), '.claude', 'agents') +const skillsDir = path.join(os.homedir(), '.claude', 'skills') + +await fs.ensureSymlink(agentsDir, path.join(sessionConfigDir, 'agents')) +await fs.ensureSymlink(skillsDir, path.join(sessionConfigDir, 'skills')) +``` + +## Message Flow & Streaming + +### End-to-End Message Flow + +``` +User Input → IPCChatTransport → tRPC Subscription → Claude Router + ↓ +Claude Router: + 1. Load existing messages from DB + 2. Save user message + 3. Parse @mentions + 4. Setup environment + 5. Call SDK query() + ↓ +Claude SDK: + 1. Spawn binary with environment + 2. Stream messages back + ↓ +Stream Transformer: + 1. Convert SDK format → UIMessageChunk + 2. Handle text, tools, thinking, system messages + 3. Accumulate complete assistant message + 4. Save to DB with sessionId + ↓ +tRPC Stream → Renderer → UI Update +``` + +### Message Structure + +**User Message:** +```typescript +{ + id: generateId(), + role: "user", + parts: [ + { type: "text", text: "User's message" }, + { type: "image", data: "base64...", mimeType: "image/png" } // if image + ] +} +``` + +**Assistant Message:** +```typescript +{ + id: generateId(), + role: "assistant", + parts: [ + { type: "text", text: "Assistant response" }, + { + type: "tool-Bash", + toolCallId: "call_123", + toolName: "Bash", + state: "call", + input: { command: "ls -la" } + }, + { + type: "tool-Bash", + toolCallId: "call_123", + toolName: "Bash", + state: "result", + result: "total 16\ndrwxr-xr-x..." + } + ], + metadata: { + sessionId: "sess_abc123", + inputTokens: 1234, + outputTokens: 567, + cost: 0.0045, + durationMs: 3500, + stopReason: "end_turn" + } +} +``` + +### Stream Transformer + +**Location:** `routers/claude.ts` transform function + +Converts SDK message format to UI message chunks: + +```typescript +async *transform(sdkMessage) { + switch (sdkMessage.type) { + case "text": + yield { type: "text-delta", text: sdkMessage.delta } + break + + case "tool-call": + yield { + type: "tool-call", + toolCallId: sdkMessage.toolCallId, + toolName: sdkMessage.toolName, + input: sdkMessage.input + } + break + + case "tool-result": + yield { + type: "tool-result", + toolCallId: sdkMessage.toolCallId, + result: sdkMessage.result + } + break + + case "extended_thinking": + // Transform thinking blocks into tool-like chunks + yield { + type: "thinking-delta", + text: sdkMessage.delta, + thinkingId: sdkMessage.thinkingId + } + break + } +} +``` + +## Authentication + +### Claude Code OAuth Flow + +**Router:** `src/main/lib/trpc/routers/claude-code.ts` + +#### Step 1: Start Auth +```typescript +trpcClient.claudeCode.startAuth.mutate() +``` +- Creates CodeSandbox environment +- Returns sandbox ID and status URL + +#### Step 2: Poll for OAuth URL +```typescript +trpcClient.claudeCode.pollStatus.mutate({ sandboxId }) +``` +- Polls sandbox until OAuth URL is ready +- Returns URL for user to visit in browser + +#### Step 3: User Completes OAuth +- User visits URL in browser +- Logs in to Anthropic account +- Authorizes 21st Agents +- Receives authorization code + +#### Step 4: Submit Code +```typescript +trpcClient.claudeCode.submitCode.mutate({ sandboxId, code }) +``` +- Sends code to sandbox +- Receives OAuth token +- Encrypts token with `safeStorage.encryptString()` +- Saves to SQLite DB + +#### Step 5: Token Usage +```typescript +const token = authStore.getClaudeCodeOAuthToken() +// Decrypt: safeStorage.decryptString(Buffer.from(encrypted, 'hex')) + +// Add to environment +env.CLAUDE_CODE_OAUTH_TOKEN = token + +// Pass to SDK +query({ ..., pathToClaudeCodeExecutable: binaryPath }) +``` + +### Token Security + +- **Storage:** SQLite DB at `{userData}/data/agents.db` +- **Encryption:** Electron's `safeStorage` API + - macOS: Keychain + - Windows: DPAPI + - Linux: libsecret or fallback to plain text (with warning) +- **Access:** Main process only (renderer never sees token) + +## SDK Integration + +### Dynamic Import + +**Location:** `routers/claude.ts:125` + +```typescript +const { query } = await import("@anthropic-ai/claude-agent-sdk") +``` + +**Why dynamic?** +- SDK is ESM-only module +- Electron main process uses CommonJS by default +- Dynamic import allows mixing module systems + +### Query Options + +```typescript +query({ + // Core options + cwd: projectPath, // Working directory + systemPrompt: "claude_code", // Preset system prompt + permissionMode: "bypassPermissions", // or "plan" for read-only + + // Session management + resume: sessionId, // Resume previous session + continue: true, // Continue after tool use + + // Configuration + pathToClaudeCodeExecutable: binaryPath, + settingSources: ["project", "user"], // Load .claude.json from both + + // Features + agents: [...registeredAgents], // @[agent:name] mentions + mcpServers: {...projectConfig}, // MCP server configuration + extendedThinking: { + maxTokens: 10000 // Thinking token budget + }, + + // Callbacks + canUseTool: async (toolName) => { + // Request user approval for destructive tools + if (destructiveTools.includes(toolName)) { + return await showApprovalDialog(toolName) + } + return true + }, + + // Messages + messages: [...previousMessages, userMessage], +}) +``` + +### MCP Server Configuration + +Reads from `~/.claude.json` in project directory: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"] + }, + "postgres": { + "command": "docker", + "args": ["exec", "-i", "postgres", "psql", "-U", "user", "-d", "db"] + } + } +} +``` + +These servers provide tools Claude can use (file operations, database queries, etc.). + +## Database Schema + +### sub_chats Table + +```sql +CREATE TABLE sub_chats ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + chat_id TEXT NOT NULL, + session_id TEXT, -- Claude session ID for resumption + mode TEXT NOT NULL, -- "plan" or "agent" + messages TEXT NOT NULL, -- JSON array of message objects + stream_id TEXT, -- Set during streaming, cleared on finish + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (chat_id) REFERENCES chats(id) +) +``` + +**Messages JSON Structure:** +```json +[ + { + "id": "msg_001", + "role": "user", + "parts": [ + { "type": "text", "text": "Hello" } + ] + }, + { + "id": "msg_002", + "role": "assistant", + "parts": [ + { "type": "text", "text": "Hi!" } + ], + "metadata": { + "sessionId": "sess_abc", + "inputTokens": 10, + "outputTokens": 5 + } + } +] +``` + +## Error Handling + +### Error Categories + +Defined in `routers/claude.ts`: + +```typescript +const errorCategories = { + AUTH_FAILED_SDK: "You are not logged in", + INVALID_API_KEY_SDK: "Invalid API key", + RATE_LIMIT_SDK: "Rate limit exceeded", + PROCESS_CRASH: "Claude process crashed", + EXECUTABLE_NOT_FOUND: "Claude binary not found", + NETWORK_ERROR: "Network connection error", +} +``` + +### Error Flow + +``` +Error occurs in Claude SDK + ↓ +Stream transformer catches error + ↓ +Emits error chunk with category + ↓ +Frontend displays toast notification + ↓ +If AUTH_FAILED_SDK: Shows login modal +``` + +### Logging + +**Debug Logging:** +```typescript +console.log('[SD]', 'Stream message:', message) +``` +- Prefix: `[SD]` = Stream Debug +- Logs all messages from Claude SDK + +**Raw Message Logging:** +```bash +export CLAUDE_RAW_LOG=1 +``` +- Logs raw SDK messages to JSONL files +- Location: `{userData}/logs/claude/` +- Rotation: 10MB per file +- Retention: 7 days +- File format: `claude-raw-{timestamp}.jsonl` + +## Build Configuration + +### electron-builder Configuration + +**File:** `electron-builder.yml` + +```yaml +asarUnpack: + - node_modules/better-sqlite3/**/* + - node_modules/node-pty/**/* + - node_modules/@anthropic-ai/claude-agent-sdk/**/* + +files: + - from: resources/bin/${platform}-${arch} + to: bin +``` + +**Why unpack SDK?** +- ASAR archives can break native modules +- SDK may use dynamic imports that need real filesystem +- Ensures SDK can spawn Claude binary properly + +### Binary Distribution + +**macOS:** +``` +Agents.app/ +└── Contents/ + └── Resources/ + ├── app.asar + └── app.asar.unpacked/ + └── resources/ + └── bin/ + └── claude (copied from resources/bin/darwin-arm64/) +``` + +**Windows:** +``` +Agents/ +├── resources/ +│ └── app.asar +└── app.asar.unpacked/ + └── resources/ + └── bin/ + └── claude.exe (copied from resources/bin/win32-x64/) +``` + +## Performance Considerations + +### Why Bundled Binary is Faster + +1. **No Download Wait:** Binary is immediately available +2. **No Version Check:** No need to contact remote server +3. **Predictable Location:** No PATH searching required +4. **Optimized Environment:** Pre-configured environment variables + +### Memory & Process Management + +- Claude binary runs as separate process (spawned by SDK) +- Process is terminated when session ends +- Multiple concurrent sessions supported (isolated config dirs) +- Each session has independent process + +## Security Considerations + +### Binary Verification + +```typescript +// SHA256 checksum verification during download +const actualHash = crypto + .createHash('sha256') + .update(binaryBuffer) + .digest('hex') + +if (actualHash !== expectedHash) { + throw new Error('Binary checksum mismatch') +} +``` + +### Token Security + +- OAuth tokens encrypted at rest +- Never exposed to renderer process +- Passed to Claude via environment variable (memory only) +- Removed from environment after SDK call + +### Isolated Sessions + +- Each chat has separate config directory +- No cross-chat data leakage +- Tool approvals scoped to session +- File system access controlled per session + +## Summary + +The 21st Agents application provides a sophisticated, secure, and performant integration with Claude Code by: + +1. **Bundling native binaries** for offline-first operation and version consistency +2. **Managing complex environments** by loading shell profiles and merging configurations +3. **Isolating sessions** to prevent cross-contamination and enable concurrent usage +4. **Securing authentication** with OS-level encryption and main-process-only access +5. **Streaming responses** efficiently via tRPC subscriptions and transform streams +6. **Supporting advanced features** like MCP servers, extended thinking, and session resumption +7. **Providing robust error handling** with categorized errors and comprehensive logging + +This architecture ensures users get a reliable, fast, and secure Claude Code experience integrated seamlessly into their desktop workflow. diff --git a/docs/OBSERVER_COMPARISON.md b/docs/OBSERVER_COMPARISON.md new file mode 100644 index 00000000..55e38a77 --- /dev/null +++ b/docs/OBSERVER_COMPARISON.md @@ -0,0 +1,955 @@ +# Observer vs 21st Agents Feature Comparison + +## Executive Summary + +**Observer** is a terminal-first AI workspace with comprehensive artifact tracking, while **21st Agents** is an Electron desktop app focused on git-isolated chat sessions. This document compares both architectures and highlights what 21st Agents can adopt from Observer. + +--- + +## 🎯 Core Architecture Comparison + +| Feature | Observer | 21st Agents | Winner | +|---------|----------|-------------|--------| +| **Framework** | Tauri (Rust) + Vanilla JS | Electron + React 19 | Tie | +| **Backend** | Python (asyncio) | Node.js (tRPC) | Tie | +| **Database** | SQLite (dual-layer: memory + disk) | SQLite (Drizzle ORM) | Tie | +| **State Management** | Vanilla JS (manual) | Jotai + Zustand + React Query | **21st** | +| **Communication** | JSON-RPC 2.0 | tRPC (type-safe) | **21st** | +| **Bundle Size** | Smaller (Rust) | Larger (Electron) | **Observer** | + +--- + +## 🔔 **NOTIFICATIONS** (Critical Gap for 21st Agents) + +### Observer's Approach ✅ + +**1. Real-Time Artifact Notifications** +- Backend sends `artifact_added` notification immediately when Claude uses a tool +- Frontend listens via JSON-RPC and updates UI in real-time +- No polling required + +```javascript +// Observer: Real-time notification pattern +window.rpc.onNotification('artifact_added', (data) => { + const { session_id, artifact } = data; + // Find session and add artifact + for (const date in sessions) { + const sessionList = sessions[date]; + const session = sessionList.find(s => s.session_id === session_id); + if (session) { + if (!session.artifacts) session.artifacts = []; + session.artifacts.push(artifact); + session.artifact_count = session.artifacts.length; + render(); // Update UI immediately + return; + } + } +}); +``` + +**2. Text-to-Speech (TTS) for Agent Events** +- Uses Web Speech API for voice notifications +- Priority fallback chain for consistent voice across platforms +- Tunable parameters for "computer" effect + +```javascript +// Observer: TTS implementation +const synth = window.speechSynthesis; +let voice = voices.find(v => v.name === 'Fred') || // macOS robot + voices.find(v => v.name === 'Google US English') || + voices.find(v => v.name === 'Samantha') || // Siri-like + voices[0]; + +function speak(text, interrupt = true) { + if (interrupt) synth.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.voice = voice; + utterance.pitch = 1.0; + utterance.rate = 1.1; // 10% faster + utterance.volume = 0.8; + synth.speak(utterance); +} + +// Usage +speak("Task completed successfully"); +``` + +### 21st Agents' Current State ❌ + +**Notification Status: STUB ONLY** + +```typescript +// 21st Agents: src/renderer/features/agents/hooks/use-desktop-notifications.ts +export function useDesktopNotifications() { + return { + showNotification: (_title: string, _body: string) => { + // Desktop notification - TODO: implement real notifications + }, + notifyAgentComplete: (_chatName: string) => { + // Agent complete notification - TODO: implement real notifications + }, + requestPermission: () => Promise.resolve('granted' as NotificationPermission), + } +} +``` + +**Critical Issues:** +1. ❌ No Electron native notifications implemented +2. ❌ No TTS for agent completion +3. ❌ No real-time updates when Claude executes tools +4. ❌ User has no idea what's happening during long-running agent sessions + +### **RECOMMENDATION FOR 21ST AGENTS** 🎯 + +**Implement Electron Native Notifications + TTS** + +```typescript +// src/preload/index.ts - Add to desktopApi +desktopApi: { + // ... existing methods + notification: { + show: (title: string, body: string, options?: NotificationOptions) => + ipcRenderer.invoke('notification:show', { title, body, options }), + speak: (text: string, interrupt?: boolean) => + ipcRenderer.invoke('notification:speak', { text, interrupt }), + } +} + +// src/main/index.ts - IPC handlers +ipcMain.handle('notification:show', (_, { title, body, options }) => { + const notification = new Notification({ + title, + body, + icon: path.join(__dirname, '../../resources/icon.png'), + ...options + }); + notification.show(); +}); + +ipcMain.handle('notification:speak', (_, { text, interrupt }) => { + // Use say command on macOS, or Windows Speech API + if (process.platform === 'darwin') { + exec(`say "${text}"`); + } + // TODO: Windows/Linux TTS +}); + +// src/renderer/features/agents/hooks/use-desktop-notifications.ts +export function useDesktopNotifications() { + return { + showNotification: (title: string, body: string) => { + window.desktopApi.notification.show(title, body); + }, + notifyAgentComplete: (chatName: string) => { + window.desktopApi.notification.show( + 'Agent Complete', + `${chatName} has finished working.`, + ); + window.desktopApi.notification.speak('Task complete', true); + }, + notifyToolExecuted: (toolName: string, summary: string) => { + window.desktopApi.notification.show( + `Tool: ${toolName}`, + summary, + ); + }, + } +} +``` + +**Use terminal-notifier per CLAUDE.md instructions:** +```typescript +// When task completes +exec('terminal-notifier -message "Completed: [task]" -title "Claude Code"'); +``` + +--- + +## 💾 **PERSISTENCE & CHAT HISTORY** (Where Both Apps Need Work) + +### Observer's Approach ✅ + +**1. Dual-Layer Artifact Storage** + +**Architecture:** +``` +Memory Layer (Fast) Disk Layer (Persistent) +self._sessions ~/.observer/artifacts/{session_id}/ +self._artifacts ├─ _session.json + ├─ artifact_001.json + ├─ artifact_002.json + └─ ... +``` + +**Why It's Brilliant:** +- Active sessions stay in memory for instant access +- All artifacts automatically persist to disk on creation +- Lazy-loading from disk for historical sessions +- **Survives app restarts** - full history always available + +```python +# Observer: artifact_manager.py +class ArtifactManager: + def __init__(self, artifact_dir: str = "~/.observer/artifacts"): + self._sessions: Dict[str, Dict] = {} # In-memory + self._artifacts: Dict[str, Dict] = {} # In-memory + self.artifact_dir = os.path.expanduser(artifact_dir) + + def add_artifact(self, session_id: str, tool_name: str, tool_input: dict) -> str: + # 1. Create artifact in memory + artifact = { + "id": artifact_id, + "session_id": session_id, + "type": tool_name.lower(), + "timestamp": now.isoformat() + "Z", + "input": tool_input, + } + self._artifacts[artifact_id] = artifact + + # 2. Persist to disk IMMEDIATELY + self._persist_artifact(session_id, artifact_id, artifact) + + # 3. Notify frontend (real-time update) + if hasattr(self, 'notification_callback'): + self.notification_callback('artifact_added', { + 'session_id': session_id, + 'artifact': artifact + }) + + return artifact_id + + def _persist_artifact(self, session_id: str, artifact_id: str, artifact: dict): + """Write artifact to disk immediately""" + session_dir = os.path.join(self.artifact_dir, session_id) + os.makedirs(session_dir, exist_ok=True) + artifact_path = os.path.join(session_dir, f"{artifact_id}.json") + with open(artifact_path, 'w') as f: + json.dump(artifact, f, indent=2) +``` + +**2. Hierarchical Lazy-Loading UI** + +``` +┌─ Artifact History ──────────────────────────────┐ +│ [Search box] │ +│ │ +│ ▼ Today (3 sessions, 45 artifacts) │ +│ ▼ Session abc123 (15 artifacts) │ +│ ✓ 📖 Read: src/main.py │ +│ ✓ ✏️ Edit: src/config.py │ +│ ⋯ 🖥️ Bash: npm install │ +│ ▶ Session def456 (20 artifacts) │ +│ ▶ Yesterday (2 sessions, 30 artifacts) │ +└─────────────────────────────────────────────────┘ +``` + +**Why It's Brilliant:** +- Only loads dates on panel open (fast initial render) +- Fetches sessions only when date expanded +- Fetches artifacts only when session expanded +- **Can browse thousands of historical artifacts without performance issues** + +### 21st Agents' Current State ⚠️ + +**Chat Persistence: GOOD** + +```typescript +// 21st Agents: Database schema (src/main/lib/db/schema/index.ts) +export const chats = sqliteTable("chats", { + id: text("id").primaryKey(), + name: text("name"), + projectId: text("project_id").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), + archivedAt: integer("archived_at", { mode: "timestamp" }), + // Git isolation per chat + worktreePath: text("worktree_path"), + branch: text("branch"), + baseBranch: text("base_branch"), + prUrl: text("pr_url"), + prNumber: integer("pr_number"), +}) + +export const subChats = sqliteTable("sub_chats", { + id: text("id").primaryKey(), + name: text("name"), + chatId: text("chat_id").notNull(), + sessionId: text("session_id"), // Claude SDK session for resume + streamId: text("stream_id"), + mode: text("mode").default("agent"), + messages: text("messages").default("[]"), // JSON array + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), +}) +``` + +**Artifact Tracking: MISSING ❌** + +**Critical Gaps:** +1. ❌ **No tool execution history** - Can't see what Claude did in past sessions +2. ❌ **No artifact panel** - Can't browse historical Read/Write/Bash operations +3. ❌ **No search across past tool calls** - Can't find "when did Claude read package.json?" +4. ❌ Messages stored as JSON blob - not queryable by tool type + +**What Gets Saved:** ✅ +- Chat messages (user + assistant) → `subChats.messages` +- Session IDs for resuming → `subChats.sessionId` +- Git worktree per chat → `chats.worktreePath` + +**What DOESN'T Get Saved:** ❌ +- Individual tool executions (Read, Write, Bash, etc.) +- File paths touched per session +- Command history per chat +- Tool results (stdout, file contents, etc.) + +### **RECOMMENDATION FOR 21ST AGENTS** 🎯 + +**Add Observer-Style Artifact Tracking** + +**Option 1: New Artifacts Table (Recommended)** + +```typescript +// src/main/lib/db/schema/index.ts +export const artifacts = sqliteTable("artifacts", { + id: text("id").primaryKey(), + subChatId: text("sub_chat_id").notNull().references(() => subChats.id, { onDelete: "cascade" }), + type: text("type").notNull(), // "read", "write", "bash", "edit", etc. + timestamp: integer("timestamp", { mode: "timestamp" }).$defaultFn(() => new Date()), + // Tool-specific metadata (extracted for quick queries) + filePath: text("file_path"), // For Read/Write/Edit + command: text("command"), // For Bash + pattern: text("pattern"), // For Grep/Glob + toolName: text("tool_name").notNull(), + // Full data (JSON) + input: text("input").notNull(), // JSON blob + output: text("output"), // JSON blob + isError: integer("is_error", { mode: "boolean" }).default(false), +}) + +// Relations +export const artifactsRelations = relations(artifacts, ({ one }) => ({ + subChat: one(subChats, { + fields: [artifacts.subChatId], + references: [subChats.id], + }), +})) +``` + +**Option 2: Extract from Existing Messages (Quick Win)** + +Since 21st Agents already stores messages with tool parts, you can: + +```typescript +// src/main/lib/trpc/routers/artifacts.ts (NEW FILE) +export const artifactsRouter = router({ + // Extract artifacts from existing messages + list: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => { + const db = getDatabase() + const subChat = db.select().from(subChats).where(eq(subChats.id, input.subChatId)).get() + if (!subChat) return { artifacts: [] } + + const messages = JSON.parse(subChat.messages || "[]") + const artifacts = [] + + // Extract all tool parts from assistant messages + for (const msg of messages) { + if (msg.role !== 'assistant') continue + for (const part of msg.parts || []) { + if (part.type?.startsWith('tool-')) { + artifacts.push({ + id: part.toolCallId, + type: part.type.replace('tool-', '').toLowerCase(), + toolName: part.toolName, + input: part.input, + result: part.result, + state: part.state, + }) + } + } + } + + return { artifacts } + }), + + // Search across all artifacts + search: publicProcedure + .input(z.object({ query: z.string() })) + .query(({ input }) => { + // Search file paths, commands, patterns + const db = getDatabase() + const allSubChats = db.select().from(subChats).all() + const results = [] + + for (const subChat of allSubChats) { + const messages = JSON.parse(subChat.messages || "[]") + for (const msg of messages) { + if (msg.role !== 'assistant') continue + for (const part of msg.parts || []) { + if (part.type?.startsWith('tool-')) { + // Search in file_path, command, pattern, etc. + const searchableText = JSON.stringify(part.input).toLowerCase() + if (searchableText.includes(input.query.toLowerCase())) { + results.push({ + subChatId: subChat.id, + chatId: subChat.chatId, + artifact: part, + }) + } + } + } + } + } + + return { results } + }), +}) +``` + +**UI Component (React):** + +```typescript +// src/renderer/features/artifacts/artifact-panel.tsx +import { trpc } from '@/lib/trpc' + +export function ArtifactPanel({ subChatId }: { subChatId: string }) { + const { data } = trpc.artifacts.list.useQuery({ subChatId }) + const [expanded, setExpanded] = useState>(new Set()) + + return ( +
+

Tool History

+ {data?.artifacts.map((artifact) => ( +
+ + {expanded.has(artifact.id) && ( +
{JSON.stringify(artifact, null, 2)}
+ )} +
+ ))} +
+ ) +} + +function getToolIcon(toolName: string): string { + const icons = { + 'Read': '📖', + 'Write': '📝', + 'Edit': '✏️', + 'Bash': '🖥️', + 'Grep': '🔎', + 'Glob': '🔍', + } + return icons[toolName] || '🔧' +} + +function getArtifactSummary(artifact: any): string { + switch (artifact.type) { + case 'read': + case 'write': + case 'edit': + return artifact.input?.file_path || 'unknown' + case 'bash': + const cmd = artifact.input?.command || '' + return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd + case 'grep': + case 'glob': + return artifact.input?.pattern || '' + default: + return '' + } +} +``` + +--- + +## 🎨 **UI/UX Patterns** + +### Observer's Innovations + +**1. Progressive Disclosure for Large Outputs** + +```javascript +// Observer: Truncate long outputs, show "View All" button +const MAX_LINES = 100; + +if (lines.length > MAX_LINES) { + displayContent = lines.slice(0, MAX_LINES).join('\n'); + truncated = true; +} + +if (truncated) { + const moreInfo = document.createElement('div'); + moreInfo.innerHTML = `Showing ${MAX_LINES} of ${lines.length} lines + `; + moreInfo.querySelector('.show-all-btn').onclick = () => { + // Replace with full content + body.appendChild(createCodeBlock(fullContent)); + }; + body.appendChild(moreInfo); +} +``` + +**Why It Matters:** +- Prevents UI freeze on 10k+ line outputs +- Fast initial render +- User controls detail level + +**2. Tool-Specific Rendering** + +Each tool gets custom UI: +- **Read**: Syntax-highlighted file with line numbers +- **Edit**: Before/after diff view +- **Bash**: Terminal-style output with ANSI colors +- **Grep**: Highlighted matches with context + +### 21st Agents' Strengths + +**1. Advanced Diff Viewing** +- Uses `@git-diff-view/react` + Shiki (NOT Monaco) +- Split/Unified view modes +- Virtualization for large diffs +- Auto-collapse when >10 files + +**2. Modern React State** +- Jotai for UI atoms (selected chat, sidebar open) +- Zustand for sub-chat tabs (persisted to localStorage) +- React Query for server state via tRPC + +**3. Git Worktree Isolation** +- Each chat gets its own git worktree +- Prevents cross-contamination +- PR tracking per chat + +--- + +## ⚡ **PERFORMANCE COMPARISON** + +| Metric | Observer | 21st Agents | Notes | +|--------|----------|-------------|-------| +| **Startup Time** | Faster (Tauri/Rust) | Slower (Electron) | Electron bundles Chromium | +| **Memory Usage** | Lower | Higher | Electron overhead ~100-200MB | +| **Bundle Size** | ~30-50MB | ~150-200MB | Electron + node_modules | +| **UI Responsiveness** | Vanilla JS (fast) | React (fast with optimization) | Observer has edge on raw speed | +| **Database Queries** | Manual SQL | Drizzle ORM (type-safe) | 21st has better DX | +| **IPC Speed** | JSON-RPC (fast) | tRPC (fast + type-safe) | Tie | + +**User's Observation:** +> "this reacts kind of slow, to tell you the truth" + +**Likely Causes in 21st Agents:** +1. Large React component trees re-rendering +2. No virtualization for chat messages (only for diffs) +3. JSON parsing on every message update +4. Lack of memoization in message rendering + +**Fixes:** +```typescript +// Use React.memo for message components +const MessageItem = React.memo(({ message }: { message: Message }) => { + // ... render logic +}, (prev, next) => prev.message.id === next.message.id) + +// Use virtual scrolling for chat messages +import { useVirtualizer } from '@tanstack/react-virtual' + +// Debounce expensive operations +import { useDebouncedValue } from '@/hooks/use-debounced-value' +``` + +--- + +## 📝 **CHAT RECOVERY COMPARISON** + +### Observer + +**Q: Can you always get back to your chats?** +✅ **YES** +- All sessions stored in `~/.observer/artifacts/{session_id}/` +- Sessions organized by date +- Full artifact history per session +- Searchable across all sessions + +**Q: Can you always get back to changes?** +✅ **YES** +- Every tool execution saved as artifact +- File contents captured in Read/Write artifacts +- Command history in Bash artifacts +- Diffs in Edit artifacts + +### 21st Agents + +**Q: Can you always get back to your chats?** +✅ **YES** +- All chats stored in SQLite: `{userData}/data/agents.db` +- Messages persisted in `subChats.messages` (JSON) +- Can resume sessions via `sessionId` +- Archive feature for old chats + +**Q: Can you always get back to changes?** +⚠️ **PARTIAL** +- ✅ Chat messages saved (including tool parts) +- ✅ Git worktree preserves file changes +- ❌ No dedicated tool history panel +- ❌ No search across historical tool executions +- ❌ Can't easily answer "what files did Claude touch in this chat?" + +**Git Advantage:** +Since 21st Agents uses git worktrees, you CAN recover changes via git: +```bash +cd /path/to/worktree +git log --stat # See all file changes +git diff main # See all changes vs main +``` + +But this requires manual git commands - no UI for it. + +--- + +## 🏆 **FEATURE SCORECARD** + +| Feature | Observer | 21st Agents | Recommendation | +|---------|----------|-------------|----------------| +| **Artifact Tracking** | ✅ Full system | ❌ None | 🎯 **ADD TO 21ST** | +| **Real-Time Notifications** | ✅ JSON-RPC | ❌ Stub only | 🎯 **ADD TO 21ST** | +| **TTS Notifications** | ✅ Web Speech API | ❌ None | 🎯 **ADD TO 21ST** | +| **Tool History Panel** | ✅ Lazy-load tree | ❌ None | 🎯 **ADD TO 21ST** | +| **Progressive Disclosure** | ✅ Yes | ⚠️ Partial | 🎯 **IMPROVE 21ST** | +| **Git Isolation** | ❌ None | ✅ Worktrees | 🏅 **21ST WINS** | +| **Type Safety** | ❌ Python/JS | ✅ TypeScript + tRPC | 🏅 **21ST WINS** | +| **Modern UI Framework** | ❌ Vanilla JS | ✅ React 19 | 🏅 **21ST WINS** | +| **Diff Viewing** | ⚠️ Basic | ✅ Advanced (git-diff-view) | 🏅 **21ST WINS** | +| **Session Resume** | ✅ Yes | ✅ Yes | Tie | +| **Search Artifacts** | ✅ Full-text | ❌ None | 🎯 **ADD TO 21ST** | +| **Auto-Migration** | ❌ Manual | ✅ Drizzle auto-migrate | 🏅 **21ST WINS** | + +--- + +## 🎯 **PRIORITY ACTION ITEMS FOR 21ST AGENTS** + +### 1. **IMPLEMENT NOTIFICATIONS** (CRITICAL) + +**Why:** User can't tell what's happening during long agent sessions + +**What to Build:** +- Electron native notifications when tools execute +- TTS on agent completion (using `terminal-notifier` per CLAUDE.md) +- Real-time tool execution updates in UI + +**Effort:** 1-2 days + +**Files to Create/Modify:** +- `src/preload/index.ts` - Add `notification` API +- `src/main/index.ts` - IPC handlers for notifications +- `src/renderer/features/agents/hooks/use-desktop-notifications.ts` - Real implementation +- `src/renderer/features/agents/main/active-chat.tsx` - Call notifications on tool execution + +**Code Snippet:** +```typescript +// src/renderer/features/agents/hooks/use-chat.tsx +// In the chunk handler: +case "tool-output-available": + // Show notification for completed tool + const toolPart = findToolPart(chunk.toolCallId) + if (toolPart) { + showNotification( + `Tool: ${toolPart.toolName}`, + getToolSummary(toolPart), + ) + } + break + +case "finish": + // Notify on completion + notifyAgentComplete(chatName) + // Use terminal-notifier per CLAUDE.md + exec('terminal-notifier -message "Completed: Agent task" -title "Claude Code"') + break +``` + +--- + +### 2. **ADD ARTIFACT TRACKING** (HIGH PRIORITY) + +**Why:** Can't browse historical tool executions, search past operations + +**What to Build:** +- New `artifacts` table (or extract from existing messages) +- Artifact panel component (lazy-load tree like Observer) +- Search across all artifacts + +**Effort:** 2-3 days + +**Option A: New Table (Better long-term)** +```bash +bun run db:generate # Generate migration for new artifacts table +bun run db:push # Apply migration +``` + +**Option B: Extract from Messages (Quick win)** +- No schema changes needed +- Use tRPC router to parse existing `subChats.messages` +- Build UI panel to display extracted artifacts + +--- + +### 3. **PERFORMANCE OPTIMIZATION** (MEDIUM PRIORITY) + +**Why:** User reports "reacts kind of slow" + +**What to Optimize:** +- Add virtualization for chat messages (like diffs) +- Memoize message components with `React.memo` +- Debounce search inputs +- Lazy-load old messages (only show recent 50, load more on scroll) + +**Effort:** 1-2 days + +**Code Snippet:** +```typescript +// src/renderer/features/agents/main/active-chat.tsx +import { useVirtualizer } from '@tanstack/react-virtual' + +const rowVirtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 100, + overscan: 5, +}) + +// Only render visible messages +{rowVirtualizer.getVirtualItems().map((virtualRow) => { + const message = messages[virtualRow.index] + return +})} +``` + +--- + +### 4. **TOOL HISTORY SIDEBAR** (MEDIUM PRIORITY) + +**Why:** Can't easily see "what files did Claude touch?" or "what commands ran?" + +**What to Build:** +- New sidebar panel (next to artifact panel) +- Group tools by type (File Operations, Commands, Searches) +- Click to jump to that point in chat + +**Effort:** 1-2 days + +**UI Mockup:** +``` +┌─ Tool History ──────────────────────┐ +│ 📁 Files (12) │ +│ 📖 Read: package.json │ +│ ✏️ Edit: src/main.ts │ +│ 📝 Write: output.txt │ +│ │ +│ 🖥️ Commands (5) │ +│ npm install │ +│ git status │ +│ │ +│ 🔍 Searches (3) │ +│ Grep: "TODO" │ +│ Glob: "**/*.ts" │ +└─────────────────────────────────────┘ +``` + +--- + +## 💡 **OBSERVER PATTERNS TO ADOPT** + +### 1. **Dual-Layer Storage Pattern** + +**Use for:** Artifact caching, session history + +```typescript +// In-memory for fast queries +private _activeSessions: Map = new Map() + +// Disk for persistence +private async persistSession(sessionId: string, data: Session) { + const sessionPath = path.join(this.artifactsDir, sessionId, '_session.json') + await fs.writeFile(sessionPath, JSON.stringify(data, null, 2)) +} + +// Lazy-load from disk +private async loadSession(sessionId: string): Promise { + if (this._activeSessions.has(sessionId)) { + return this._activeSessions.get(sessionId)! + } + const sessionPath = path.join(this.artifactsDir, sessionId, '_session.json') + const data = await fs.readFile(sessionPath, 'utf-8') + return JSON.parse(data) +} +``` + +### 2. **Real-Time Notification Flow** + +**Use for:** Live tool execution updates + +```typescript +// Backend: Emit on tool execution +for (const chunk of transform(msg)) { + if (chunk.type === 'tool-output-available') { + // Send notification to all connected clients + broadcastNotification('tool_executed', { + subChatId: input.subChatId, + toolName: chunk.toolName, + toolCallId: chunk.toolCallId, + }) + } + emit.next(chunk) +} + +// Frontend: Listen for notifications +trpc.claude.onToolExecuted.useSubscription(undefined, { + onData: (data) => { + showNotification(`Tool: ${data.toolName}`, 'Completed') + } +}) +``` + +### 3. **Progressive Disclosure** + +**Use for:** Large file contents, long command outputs + +```typescript +const MAX_LINES = 100 + +function renderLargeContent(content: string) { + const lines = content.split('\n') + const [truncated, setTruncated] = useState(true) + + const displayContent = truncated + ? lines.slice(0, MAX_LINES).join('\n') + : content + + return ( + <> +
{displayContent}
+ {lines.length > MAX_LINES && truncated && ( + + )} + + ) +} +``` + +### 4. **Tool-Agnostic Metadata Extraction** + +**Use for:** Making artifacts searchable without parsing JSON + +```typescript +function extractArtifactMetadata(toolPart: ToolPart): ArtifactMetadata { + const metadata: ArtifactMetadata = { + type: toolPart.type.replace('tool-', '').toLowerCase(), + toolName: toolPart.toolName, + } + + switch (toolPart.toolName) { + case 'Read': + case 'Write': + case 'Edit': + metadata.filePath = toolPart.input?.file_path + break + case 'Bash': + metadata.command = toolPart.input?.command?.substring(0, 100) + break + case 'Grep': + case 'Glob': + metadata.pattern = toolPart.input?.pattern + break + } + + return metadata +} +``` + +--- + +## 🚀 **QUICK WINS (Do These First)** + +1. **Terminal-Notifier on Completion** (30 mins) + - Add `exec('terminal-notifier ...')` when agent finishes + - Per CLAUDE.md instructions + +2. **Extract Artifacts from Messages** (2 hours) + - Add tRPC route to parse existing messages + - No DB schema changes needed + - Instant tool history visibility + +3. **Memoize Message Components** (1 hour) + - Wrap `` in `React.memo` + - Immediate performance boost + +4. **Debounce Search** (30 mins) + - Add 300ms debounce to search inputs + - Reduce unnecessary re-renders + +--- + +## 📊 **FINAL VERDICT** + +### Observer Strengths +- ✅ **Artifact tracking is world-class** +- ✅ **Real-time notifications work great** +- ✅ **Progressive disclosure handles large outputs well** +- ✅ **TTS adds nice touch for long-running tasks** +- ❌ No git isolation (chats share same workspace) +- ❌ Vanilla JS (harder to maintain than React) + +### 21st Agents Strengths +- ✅ **Git worktree isolation is brilliant** (Observer should copy this!) +- ✅ **Type safety with tRPC + TypeScript** +- ✅ **Modern React UI with great state management** +- ✅ **Advanced diff viewing** +- ❌ **No artifact tracking** (critical gap) +- ❌ **No real notifications** (critical gap) +- ❌ Performance issues (user-reported) + +### **Hybrid Recommendation** 🎯 + +**Build "21st Agents v2" with:** +1. Keep: Git worktrees, tRPC, React, TypeScript (your strengths) +2. Add: Artifact tracking from Observer (their strength) +3. Add: Real-time notifications + TTS from Observer (their strength) +4. Add: Progressive disclosure patterns from Observer (their strength) +5. Fix: Performance with virtualization + memoization + +**Result:** Best of both worlds +- Enterprise-grade git isolation (21st) +- Industrial-strength artifact tracking (Observer) +- Modern type-safe architecture (21st) +- Real-time user feedback (Observer) + +--- + +## 📚 **APPENDIX: Code Locations** + +### Observer Files to Study +- `src/python/artifact_manager.py` - Dual-layer storage +- `src/frontend/components/artifact-panel.js` - Lazy-load tree +- `src/frontend/components/tts.js` - Web Speech API +- `src/frontend/components/sp-terminal/tool-renderers.js` - Tool-specific rendering +- `src/python/sp_manager.py` - Real-time notifications + +### 21st Agents Files to Modify +- `src/main/lib/db/schema/index.ts` - Add artifacts table +- `src/renderer/features/agents/hooks/use-desktop-notifications.ts` - Real implementation +- `src/renderer/features/agents/main/active-chat.tsx` - Performance optimizations +- `src/preload/index.ts` - Add notification API +- `src/main/index.ts` - Notification IPC handlers + +--- + +**Last Updated:** 2026-01-17 +**Author:** Claude Code Analysis +**Version:** 1.0 diff --git a/src/main/lib/trpc/routers/artifacts.ts b/src/main/lib/trpc/routers/artifacts.ts new file mode 100644 index 00000000..a9316770 --- /dev/null +++ b/src/main/lib/trpc/routers/artifacts.ts @@ -0,0 +1,164 @@ +import { z } from "zod" +import { eq } from "drizzle-orm" +import { router, publicProcedure } from "../index" +import { getDatabase, subChats } from "../../db" + +/** + * Artifact type representing a tool execution + */ +interface Artifact { + id: string + type: string + toolName: string + input: Record + result?: unknown + state?: string + // Extracted key fields for display + filePath?: string + command?: string + pattern?: string +} + +/** + * Artifacts router - query tool execution history from chat messages + * Extracts tool parts from existing messages (no DB schema changes needed) + */ +export const artifactsRouter = router({ + /** + * Get tool history for a sub-chat + */ + list: publicProcedure + .input( + z.object({ + subChatId: z.string(), + toolName: z.string().optional(), + }), + ) + .query(({ input }) => { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!subChat) return { artifacts: [] } + + const messages = JSON.parse(subChat.messages || "[]") + const artifacts: Artifact[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + if (input.toolName && part.toolName !== input.toolName) continue + + artifacts.push({ + id: part.toolCallId || `${msg.id}-${artifacts.length}`, + type: part.type.replace("tool-", ""), + toolName: part.toolName || "unknown", + input: part.input || {}, + result: part.result, + state: part.state, + // Extract key fields for display + filePath: part.input?.file_path, + command: part.input?.command?.substring(0, 100), + pattern: part.input?.pattern, + }) + } + } + + return { artifacts } + }), + + /** + * Search across all sub-chats for matching artifacts + */ + search: publicProcedure + .input( + z.object({ + query: z.string(), + chatId: z.string().optional(), + }), + ) + .query(({ input }) => { + const db = getDatabase() + const allSubChats = input.chatId + ? db + .select() + .from(subChats) + .where(eq(subChats.chatId, input.chatId)) + .all() + : db.select().from(subChats).all() + + const results: Array<{ + subChatId: string + chatId: string + artifact: { + id: string + toolName: string + filePath?: string + command?: string + } + }> = [] + + const queryLower = input.query.toLowerCase() + + for (const subChat of allSubChats) { + const messages = JSON.parse(subChat.messages || "[]") + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + + const searchable = JSON.stringify(part.input).toLowerCase() + if (searchable.includes(queryLower)) { + results.push({ + subChatId: subChat.id, + chatId: subChat.chatId, + artifact: { + id: part.toolCallId || `${msg.id}-${results.length}`, + toolName: part.toolName || "unknown", + filePath: part.input?.file_path, + command: part.input?.command?.substring(0, 50), + }, + }) + } + } + } + } + + // Limit results to prevent performance issues + return { results: results.slice(0, 50) } + }), + + /** + * Get summary of tool usage for a sub-chat + */ + summary: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!subChat) return { summary: {} } + + const messages = JSON.parse(subChat.messages || "[]") + const summary: Record = {} + + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + const toolName = part.toolName || "unknown" + summary[toolName] = (summary[toolName] || 0) + 1 + } + } + + return { summary } + }), +}) diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index 58ca6279..0b408df2 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -9,6 +9,7 @@ import { filesRouter } from "./files" import { debugRouter } from "./debug" import { skillsRouter } from "./skills" import { agentsRouter } from "./agents" +import { artifactsRouter } from "./artifacts" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -28,6 +29,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { debug: debugRouter, skills: skillsRouter, agents: agentsRouter, + artifacts: artifactsRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index d5938678..c039f812 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -8,6 +8,7 @@ import { session, } from "electron" import { join } from "path" +import { exec } from "child_process" import { createIPCHandler } from "trpc-electron/main" import { createAppRouter } from "../lib/trpc/routers" import { getAuthManager, handleAuthCode, getBaseUrl } from "../index" @@ -35,6 +36,29 @@ function registerIpcHandlers(getWindow: () => BrowserWindow | null): void { }, ) + // Terminal-notifier for macOS (per CLAUDE.md instructions) + ipcMain.handle( + "app:terminal-notify", + (_event, { message, title }: { message: string; title: string }) => { + if (process.platform === "darwin") { + // Escape quotes to prevent command injection + const safeMessage = message.replace(/"/g, '\\"') + const safeTitle = title.replace(/"/g, '\\"') + exec( + `terminal-notifier -message "${safeMessage}" -title "${safeTitle}"`, + (err) => { + if (err) { + console.warn( + "[Notification] terminal-notifier failed:", + err.message, + ) + } + }, + ) + } + }, + ) + // API base URL for fetch requests ipcMain.handle("app:get-api-base-url", () => getBaseUrl()) diff --git a/src/preload/index.ts b/src/preload/index.ts index ec8ee8d3..d48218bc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,8 @@ contextBridge.exposeInMainWorld("desktopApi", { setBadge: (count: number | null) => ipcRenderer.invoke("app:set-badge", count), showNotification: (options: { title: string; body: string }) => ipcRenderer.invoke("app:show-notification", options), + terminalNotify: (message: string, title: string) => + ipcRenderer.invoke("app:terminal-notify", { message, title }), openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url), // API base URL (for fetch requests to server) @@ -193,6 +195,7 @@ export interface DesktopApi { setAnalyticsOptOut: (optedOut: boolean) => Promise setBadge: (count: number | null) => Promise showNotification: (options: { title: string; body: string }) => Promise + terminalNotify: (message: string, title: string) => Promise openExternal: (url: string) => Promise getApiBaseUrl: () => Promise clipboardWrite: (text: string) => Promise diff --git a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx index 916cfcc3..5003c65d 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -5,7 +5,11 @@ import { soundNotificationsEnabledAtom, analyticsOptOutAtom, ctrlTabTargetAtom, + notificationModeAtom, + toastNotificationsEnabledAtom, + activityFeedEnabledAtom, type CtrlTabTarget, + type NotificationMode, } from "../../../lib/atoms" import { Switch } from "../../ui/switch" import { @@ -40,6 +44,9 @@ export function AgentsPreferencesTab() { const [soundEnabled, setSoundEnabled] = useAtom(soundNotificationsEnabledAtom) const [analyticsOptOut, setAnalyticsOptOut] = useAtom(analyticsOptOutAtom) const [ctrlTabTarget, setCtrlTabTarget] = useAtom(ctrlTabTargetAtom) + const [notificationMode, setNotificationMode] = useAtom(notificationModeAtom) + const [toastEnabled, setToastEnabled] = useAtom(toastNotificationsEnabledAtom) + const [activityFeedEnabled, setActivityFeedEnabled] = useAtom(activityFeedEnabledAtom) const isNarrowScreen = useIsNarrowScreen() // Sync opt-out status to main process @@ -101,6 +108,77 @@ export function AgentsPreferencesTab() { + {/* Notifications Section */} +
+
+

Notifications

+

+ Configure how you get notified about agent activity +

+
+ +
+
+ {/* Notification Mode */} +
+
+ + Notification Mode + + + When to show notifications + +
+ +
+ + {/* Toast Notifications Toggle */} +
+
+ + Toast Notifications + + + Show toast popups for tool executions + +
+ +
+ + {/* Activity Feed Toggle */} +
+
+ + Activity Feed + + + Show activity feed panel on the right side + +
+ +
+
+
+
+ {/* Keyboard Shortcuts Section */}
diff --git a/src/renderer/features/activity/activity-feed.tsx b/src/renderer/features/activity/activity-feed.tsx new file mode 100644 index 00000000..22e4c440 --- /dev/null +++ b/src/renderer/features/activity/activity-feed.tsx @@ -0,0 +1,142 @@ +"use client" + +import { useAtomValue, useSetAtom } from "jotai" +import { + activityFeedEnabledAtom, + toolActivityAtom, + clearToolActivityAtom, + type ToolActivity, +} from "../../lib/atoms" +import { LoadingDot } from "../../icons" +import { Button } from "../../components/ui/button" +import { cn } from "../../lib/utils" + +// Tool icons for display +const TOOL_ICONS: Record = { + Read: "📖", + Write: "📝", + Edit: "✏️", + Bash: "🖥️", + Glob: "🔍", + Grep: "🔎", + WebFetch: "🌐", + Task: "🤖", + TodoWrite: "📋", + WebSearch: "🔎", + AskUserQuestion: "❓", + NotebookEdit: "📓", +} + +function getToolIcon(toolName: string): string { + return TOOL_ICONS[toolName] || "🔧" +} + +/** + * Format relative time (e.g., "2s ago", "1m ago") + */ +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + + if (seconds < 5) return "now" + if (seconds < 60) return `${seconds}s ago` + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + return `${Math.floor(hours / 24)}d ago` +} + +/** + * Single activity item in the feed + */ +function ActivityItem({ activity }: { activity: ToolActivity }) { + return ( +
+
+ {getToolIcon(activity.toolName)} + + {activity.toolName} + + {activity.state === "running" && ( + + )} + {activity.state === "error" && ( + Error + )} + {activity.state === "complete" && ( + + )} +
+
+ {activity.summary} +
+
+ {activity.chatName} + + {formatRelativeTime(activity.timestamp)} +
+
+ ) +} + +/** + * Activity Feed Panel + * Shows real-time tool execution history + */ +export function ActivityFeed({ className }: { className?: string }) { + const activities = useAtomValue(toolActivityAtom) + const enabled = useAtomValue(activityFeedEnabledAtom) + const clearActivities = useSetAtom(clearToolActivityAtom) + + if (!enabled) return null + + // Count running activities + const runningCount = activities.filter((a) => a.state === "running").length + + return ( +
+ {/* Header */} +
+
+

Activity

+ {runningCount > 0 && ( + + {runningCount} + + )} +
+ {activities.length > 0 && ( + + )} +
+ + {/* Activity list */} +
+ {activities.length > 0 ? ( + activities.map((activity) => ( + + )) + ) : ( +
+ No recent activity +
+ )} +
+
+ ) +} diff --git a/src/renderer/features/activity/index.ts b/src/renderer/features/activity/index.ts new file mode 100644 index 00000000..c0d1106e --- /dev/null +++ b/src/renderer/features/activity/index.ts @@ -0,0 +1 @@ +export { ActivityFeed } from "./activity-feed" diff --git a/src/renderer/features/agents/hooks/use-tool-notifications.ts b/src/renderer/features/agents/hooks/use-tool-notifications.ts new file mode 100644 index 00000000..016f82ac --- /dev/null +++ b/src/renderer/features/agents/hooks/use-tool-notifications.ts @@ -0,0 +1,241 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import { useAtomValue, useSetAtom } from "jotai" +import { toast } from "sonner" +import { + notificationModeAtom, + toastNotificationsEnabledAtom, + addToolActivityAtom, + updateToolActivityAtom, + type ToolActivity, +} from "../../../lib/atoms" + +// Tool icons for toast notifications +const TOOL_ICONS: Record = { + Read: "📖", + Write: "📝", + Edit: "✏️", + Bash: "🖥️", + Glob: "🔍", + Grep: "🔎", + WebFetch: "🌐", + Task: "🤖", + TodoWrite: "📋", + WebSearch: "🔎", + AskUserQuestion: "❓", + NotebookEdit: "📓", +} + +/** + * Extract a human-readable summary from tool input + */ +function getToolSummary(toolName: string, input: Record): string { + switch (toolName) { + case "Read": + case "Write": + case "Edit": { + const filePath = input?.file_path as string + return filePath?.split("/").pop() || "file" + } + case "Bash": { + const cmd = (input?.command as string) || "" + return cmd.length > 40 ? cmd.substring(0, 40) + "..." : cmd + } + case "Glob": + case "Grep": { + return (input?.pattern as string) || "pattern" + } + case "WebFetch": { + try { + const url = input?.url as string + return url ? new URL(url).hostname : "url" + } catch { + return "url" + } + } + case "WebSearch": { + return (input?.query as string)?.substring(0, 40) || "search" + } + case "Task": { + return (input?.description as string)?.substring(0, 40) || "task" + } + case "TodoWrite": { + const todos = input?.todos as unknown[] + return todos ? `${todos.length} items` : "todos" + } + default: + return toolName + } +} + +/** + * Get icon for a tool + */ +function getToolIcon(toolName: string): string { + return TOOL_ICONS[toolName] || "🔧" +} + +// Track window focus state +let isWindowFocused = typeof document !== "undefined" ? document.hasFocus() : true + +// Setup focus tracking (runs once) +if (typeof window !== "undefined") { + window.addEventListener("focus", () => { + isWindowFocused = true + }) + window.addEventListener("blur", () => { + isWindowFocused = false + }) +} + +// Custom event types for tool notifications +declare global { + interface WindowEventMap { + "tool-start": CustomEvent<{ + toolCallId: string + toolName: string + input: Record + subChatId: string + chatName: string + }> + "tool-complete": CustomEvent<{ + toolCallId: string + isError: boolean + }> + } +} + +/** + * Hook for tool execution notifications + * - Shows toast notifications when tools start (if enabled) + * - Adds activities to the activity feed + * - Respects notification mode settings + */ +export function useToolNotifications(subChatId: string, chatName: string) { + const notificationMode = useAtomValue(notificationModeAtom) + const toastsEnabled = useAtomValue(toastNotificationsEnabledAtom) + const addActivity = useSetAtom(addToolActivityAtom) + const updateActivity = useSetAtom(updateToolActivityAtom) + + // Track tool call IDs to activity IDs mapping + const toolCallToActivityId = useRef>(new Map()) + + /** + * Check if we should show notifications based on current mode + */ + const shouldNotify = useCallback((): boolean => { + if (notificationMode === "always") return true + if (notificationMode === "never") return false + return !isWindowFocused // "unfocused" mode + }, [notificationMode]) + + /** + * Notify when a tool starts executing + */ + const notifyToolStart = useCallback( + (toolCallId: string, toolName: string, input: Record) => { + const summary = getToolSummary(toolName, input) + + // Add to activity feed (always, regardless of notification mode) + const activityId = addActivity({ + subChatId, + chatName, + toolName, + summary, + state: "running", + }) + + // Track mapping for later updates + if (activityId) { + toolCallToActivityId.current.set(toolCallId, activityId) + } + + // Show toast if enabled and should notify + if (toastsEnabled && shouldNotify()) { + toast(`${getToolIcon(toolName)} ${toolName}`, { + description: summary, + duration: 3000, + }) + } + }, + [subChatId, chatName, toastsEnabled, shouldNotify, addActivity], + ) + + /** + * Notify when a tool completes + */ + const notifyToolComplete = useCallback( + (toolCallId: string, isError: boolean) => { + const activityId = toolCallToActivityId.current.get(toolCallId) + if (activityId) { + updateActivity({ + id: activityId, + state: isError ? "error" : "complete", + }) + toolCallToActivityId.current.delete(toolCallId) + } + }, + [updateActivity], + ) + + // Listen for global tool events + useEffect(() => { + const handleToolStart = (e: WindowEventMap["tool-start"]) => { + // Only handle events for this sub-chat + if (e.detail.subChatId === subChatId) { + notifyToolStart(e.detail.toolCallId, e.detail.toolName, e.detail.input) + } + } + + const handleToolComplete = (e: WindowEventMap["tool-complete"]) => { + notifyToolComplete(e.detail.toolCallId, e.detail.isError) + } + + window.addEventListener("tool-start", handleToolStart) + window.addEventListener("tool-complete", handleToolComplete) + + return () => { + window.removeEventListener("tool-start", handleToolStart) + window.removeEventListener("tool-complete", handleToolComplete) + } + }, [subChatId, notifyToolStart, notifyToolComplete]) + + return { + notifyToolStart, + notifyToolComplete, + shouldNotify, + } +} + +/** + * Dispatch a tool start event (called from ipc-chat-transport) + */ +export function dispatchToolStart( + toolCallId: string, + toolName: string, + input: Record, + subChatId: string, + chatName: string, +) { + if (typeof window === "undefined") return + + window.dispatchEvent( + new CustomEvent("tool-start", { + detail: { toolCallId, toolName, input, subChatId, chatName }, + }), + ) +} + +/** + * Dispatch a tool complete event (called from ipc-chat-transport) + */ +export function dispatchToolComplete(toolCallId: string, isError: boolean) { + if (typeof window === "undefined") return + + window.dispatchEvent( + new CustomEvent("tool-complete", { + detail: { toolCallId, isError }, + }), + ) +} diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 94df2211..2446a695 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -17,6 +17,10 @@ import { pendingUserQuestionsAtom, } from "../atoms" import { useAgentSubChatStore } from "../stores/sub-chat-store" +import { + dispatchToolStart, + dispatchToolComplete, +} from "../hooks/use-tool-notifications" // Error categories and their user-friendly messages const ERROR_TOAST_CONFIG: Record< @@ -220,6 +224,33 @@ export class IPCChatTransport implements ChatTransport { }) } + // Dispatch tool events for notification system + if (chunk.type === "tool-input-available") { + // Get chat name from store for display + const subChat = useAgentSubChatStore + .getState() + .allSubChats.find((sc) => sc.id === this.config.subChatId) + const chatName = subChat?.name || "Agent" + + dispatchToolStart( + chunk.toolCallId, + chunk.toolName, + chunk.input || {}, + this.config.subChatId, + chatName, + ) + } + + if ( + chunk.type === "tool-output-available" || + chunk.type === "tool-output-error" + ) { + dispatchToolComplete( + chunk.toolCallId, + chunk.type === "tool-output-error", + ) + } + // Clear pending questions ONLY when agent has moved on // Don't clear on tool-input-* chunks (still building the question input) // Clear when we get tool-output-* (answer received) or text-delta (agent moved on) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 4d1b4346..d6e2ead8 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -76,7 +76,7 @@ import { createPortal } from "react-dom" import { toast } from "sonner" import { trackMessageSent } from "../../../lib/analytics" import { apiFetch } from "../../../lib/api-fetch" -import { soundNotificationsEnabledAtom } from "../../../lib/atoms" +import { soundNotificationsEnabledAtom, notificationModeAtom } from "../../../lib/atoms" import { appStore } from "../../../lib/jotai-store" import { api } from "../../../lib/mock-api" import { trpc, trpcClient } from "../../../lib/trpc" @@ -124,6 +124,7 @@ import { PreviewSetupHoverCard } from "../components/preview-setup-hover-card" import { useAgentsFileUpload } from "../hooks/use-agents-file-upload" import { useChangedFilesTracking } from "../hooks/use-changed-files-tracking" import { useDesktopNotifications } from "../hooks/use-desktop-notifications" +import { useToolNotifications } from "../hooks/use-tool-notifications" import { useFocusInputOnEnter } from "../hooks/use-focus-input-on-enter" import { useHaptic } from "../hooks/use-haptic" import { useToggleFocusOnCmdEsc } from "../hooks/use-toggle-focus-on-cmd-esc" @@ -1210,7 +1211,7 @@ function ChatViewInner({ id: subChatId, chat, resume: !!streamId, - // experimental_throttle: 200, + experimental_throttle: 200, }) // Stream debug: log status changes and scroll to plan/response start when streaming finishes @@ -3574,6 +3575,14 @@ export function ChatView({ const setUndoStack = useSetAtom(undoStackAtom) const { notifyAgentComplete } = useDesktopNotifications() + // Tool notifications (toast + activity feed) - listens for tool events via window events + // Uses activeSubChatId which is set when this component mounts/updates + const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) + useToolNotifications( + activeSubChatId || "", + agentChat?.name || "Agent", + ) + // Check if any chat has unseen changes const hasAnyUnseenChanges = unseenChanges.size > 0 const [, forceUpdate] = useState({}) @@ -4271,9 +4280,15 @@ export function ChatView({ }) // Play completion sound only if NOT manually aborted and sound is enabled + // Respect notification mode setting if (!wasManuallyAborted) { const isSoundEnabled = appStore.get(soundNotificationsEnabledAtom) - if (isSoundEnabled) { + const notifMode = appStore.get(notificationModeAtom) + const shouldNotify = + notifMode === "always" || + (notifMode === "unfocused" && !document.hasFocus()) + + if (isSoundEnabled && shouldNotify) { try { const audio = new Audio("./sound.mp3") audio.volume = 1.0 @@ -4283,8 +4298,10 @@ export function ChatView({ } } - // Show native notification (desktop app, when window not focused) - notifyAgentComplete(agentChat?.name || "Agent") + // Show native notification (desktop app) based on notification mode + if (shouldNotify) { + notifyAgentComplete(agentChat?.name || "Agent") + } } } @@ -4396,9 +4413,15 @@ export function ChatView({ }) // Play completion sound only if NOT manually aborted and sound is enabled + // Respect notification mode setting if (!wasManuallyAborted) { const isSoundEnabled = appStore.get(soundNotificationsEnabledAtom) - if (isSoundEnabled) { + const notifMode = appStore.get(notificationModeAtom) + const shouldNotify = + notifMode === "always" || + (notifMode === "unfocused" && !document.hasFocus()) + + if (isSoundEnabled && shouldNotify) { try { const audio = new Audio("./sound.mp3") audio.volume = 1.0 @@ -4408,8 +4431,10 @@ export function ChatView({ } } - // Show native notification (desktop app, when window not focused) - notifyAgentComplete(agentChat?.name || "Agent") + // Show native notification (desktop app) based on notification mode + if (shouldNotify) { + notifyAgentComplete(agentChat?.name || "Agent") + } } } diff --git a/src/renderer/features/layout/agents-layout.tsx b/src/renderer/features/layout/agents-layout.tsx index 7a94c480..6bb79b3e 100644 --- a/src/renderer/features/layout/agents-layout.tsx +++ b/src/renderer/features/layout/agents-layout.tsx @@ -12,6 +12,7 @@ import { isDesktopAtom, isFullscreenAtom, anthropicOnboardingCompletedAtom, + activityFeedEnabledAtom, } from "../../lib/atoms" import { selectedAgentChatIdAtom, selectedProjectAtom } from "../agents/atoms" import { trpc } from "../../lib/trpc" @@ -26,6 +27,7 @@ import { AgentsContent } from "../agents/ui/agents-content" import { UpdateBanner } from "../../components/update-banner" import { useUpdateChecker } from "../../lib/hooks/use-update-checker" import { useAgentSubChatStore } from "../../lib/stores/sub-chat-store" +import { ActivityFeed } from "../activity" // ============================================================================ // Constants @@ -252,6 +254,9 @@ export function AgentsLayout() {
+ {/* Activity Feed (Right Panel) */} + {!isMobile && } + {/* Update Banner */}
diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index a6c4aa63..3d36a2ab 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -152,7 +152,19 @@ const ChatIcon = React.memo(function ChatIcon({ } return ( -
+ {/* Checkbox slides in from left, icon slides out */}
{/* Badge in bottom-right corner: loader → amber dot → blue dot - hidden during multi-select */} {(isLoading || hasUnseenChanges || hasPendingPlan) && !isMultiSelectMode && ( -
{/* Priority: loader > amber dot (pending plan) > blue dot (unseen) */} {isLoading ? ( - + ) : hasPendingPlan ? (
) : ( )} -
+ )} -
+ ) }) diff --git a/src/renderer/features/sidebar/hooks/use-desktop-notifications.ts b/src/renderer/features/sidebar/hooks/use-desktop-notifications.ts index b1350777..528e0df0 100644 --- a/src/renderer/features/sidebar/hooks/use-desktop-notifications.ts +++ b/src/renderer/features/sidebar/hooks/use-desktop-notifications.ts @@ -78,6 +78,7 @@ export function useDesktopNotifications() { /** * Show a notification for agent completion * Only shows if window is not focused (in desktop app) + * Uses terminal-notifier per CLAUDE.md instructions (with Electron fallback) */ const notifyAgentComplete = useCallback( (agentName: string) => { @@ -88,11 +89,19 @@ export function useDesktopNotifications() { // Increment badge count setPendingCount((prev) => prev + 1) - // Show native notification - window.desktopApi?.showNotification({ - title: "Agent finished", - body: `${agentName} completed the task`, - }) + // Use terminal-notifier per CLAUDE.md (preferred on macOS) + if (window.desktopApi?.terminalNotify) { + window.desktopApi.terminalNotify( + `Completed: ${agentName}`, + "Claude Code", + ) + } else { + // Fallback to Electron notification + window.desktopApi?.showNotification({ + title: "Agent finished", + body: `${agentName} completed the task`, + }) + } } }, [setPendingCount], @@ -118,15 +127,22 @@ export function useDesktopNotifications() { /** * Standalone function to show notification (for use outside React components) + * Uses terminal-notifier per CLAUDE.md instructions (with Electron fallback) */ export function showAgentNotification(agentName: string) { if (!isDesktopApp() || typeof window === "undefined") return // Only notify if window is not focused if (!document.hasFocus()) { - window.desktopApi?.showNotification({ - title: "Agent finished", - body: `${agentName} completed the task`, - }) + // Use terminal-notifier per CLAUDE.md (preferred on macOS) + if (window.desktopApi?.terminalNotify) { + window.desktopApi.terminalNotify(`Completed: ${agentName}`, "Claude Code") + } else { + // Fallback to Electron notification + window.desktopApi?.showNotification({ + title: "Agent finished", + body: `${agentName} completed the task`, + }) + } } } diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index 86f3091b..7356a8bf 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -404,3 +404,84 @@ export const sessionInfoAtom = atomWithStorage( undefined, { getOnInit: true }, ) + +// ============================================ +// NOTIFICATION SYSTEM ATOMS +// ============================================ + +// Notification mode preference +// "always" - notify regardless of window focus +// "unfocused" - only notify when app is not focused (default) +// "never" - disable all notifications +export type NotificationMode = "always" | "unfocused" | "never" + +export const notificationModeAtom = atomWithStorage( + "preferences:notification-mode", + "unfocused", + undefined, + { getOnInit: true }, +) + +// Toast notifications for tool events +// When enabled, shows toast when tools start/complete +export const toastNotificationsEnabledAtom = atomWithStorage( + "preferences:toast-notifications-enabled", + true, + undefined, + { getOnInit: true }, +) + +// Activity feed panel +// When enabled, shows the activity feed sidebar panel +export const activityFeedEnabledAtom = atomWithStorage( + "preferences:activity-feed-enabled", + true, + undefined, + { getOnInit: true }, +) + +// Activity feed state (in-memory, not persisted) +export interface ToolActivity { + id: string + subChatId: string + chatName: string + toolName: string + summary: string // "package.json", "npm install", "*.tsx" + state: "running" | "complete" | "error" + timestamp: number +} + +export const toolActivityAtom = atom([]) +export const MAX_ACTIVITY_ITEMS = 50 + +// Helper to add activity +export const addToolActivityAtom = atom( + null, + (get, set, activity: Omit) => { + const prev = get(toolActivityAtom) + const newActivity: ToolActivity = { + ...activity, + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + timestamp: Date.now(), + } + set(toolActivityAtom, [newActivity, ...prev].slice(0, MAX_ACTIVITY_ITEMS)) + return newActivity.id + }, +) + +// Helper to update activity state +export const updateToolActivityAtom = atom( + null, + (get, set, { id, state }: { id: string; state: ToolActivity["state"] }) => { + const prev = get(toolActivityAtom) + set( + toolActivityAtom, + prev.map((a) => (a.id === id ? { ...a, state } : a)), + ) + }, +) + +// Helper to clear activities +export const clearToolActivityAtom = atom(null, (_get, set) => { + set(toolActivityAtom, []) +}) From 4be1001d85101bdfd89b84a0fb1064e36b188659 Mon Sep 17 00:00:00 2001 From: jshay21 Date: Sat, 17 Jan 2026 17:48:26 -0800 Subject: [PATCH 02/32] refactor: relocate `useToolNotifications` hook and `activeSubChatId` declaration in `active-chat.tsx`. --- .../features/agents/main/active-chat.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index d6e2ead8..55e77d3f 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -3575,13 +3575,8 @@ export function ChatView({ const setUndoStack = useSetAtom(undoStackAtom) const { notifyAgentComplete } = useDesktopNotifications() - // Tool notifications (toast + activity feed) - listens for tool events via window events - // Uses activeSubChatId which is set when this component mounts/updates + // Get active sub-chat ID for tracking purposes const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) - useToolNotifications( - activeSubChatId || "", - agentChat?.name || "Agent", - ) // Check if any chat has unseen changes const hasAnyUnseenChanges = unseenChanges.size > 0 @@ -3690,9 +3685,6 @@ export function ChatView({ }) }, [chatId, setUnseenChanges]) - // Get sub-chat state from store - const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) - // Clear sub-chat "unseen changes" indicator when sub-chat becomes active useEffect(() => { if (!activeSubChatId) return @@ -3725,6 +3717,13 @@ export function ChatView({ { chatId }, { enabled: !!chatId }, ) + + // Tool notifications (toast + activity feed) - listens for tool events via window events + useToolNotifications( + activeSubChatId || "", + agentChat?.name || "Agent", + ) + const agentSubChats = (agentChat?.subChats ?? []) as Array<{ id: string name?: string | null From b8cc4fa112f6dabf359e8815e9aeb03a1a379fab Mon Sep 17 00:00:00 2001 From: jshay21 Date: Sat, 17 Jan 2026 19:06:09 -0800 Subject: [PATCH 03/32] feat: add test notification button in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Test" button to the Notifications section in Settings → Preferences that sends a test macOS notification to verify notifications are working. Co-Authored-By: Claude Opus 4.5 --- bun.lock | 177 +++++- package.json | 3 + .../settings-tabs/agents-preferences-tab.tsx | 25 + src/renderer/features/agents/atoms/index.ts | 15 + .../features/agents/stores/sub-chat-store.ts | 15 + .../features/agents/ui/sub-chat-selector.tsx | 545 +++++++++++------ src/renderer/features/git/git-panel.tsx | 551 ++++++++++++++++++ src/renderer/features/git/index.ts | 1 + .../features/sidebar/agents-sidebar.tsx | 41 +- src/renderer/lib/atoms/index.ts | 4 + 10 files changed, 1188 insertions(+), 189 deletions(-) create mode 100644 src/renderer/features/git/git-panel.tsx create mode 100644 src/renderer/features/git/index.ts diff --git a/bun.lock b/bun.lock index 2a6e878c..61fc8186 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,21 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "^0.2.3", + "@anthropic-ai/claude-agent-sdk": "^0.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", @@ -43,6 +49,7 @@ "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", + "gray-matter": "^4.0.3", "jotai": "^2.11.1", "lucide-react": "^0.468.0", "motion": "^11.15.0", @@ -55,8 +62,12 @@ "react-dom": "19.2.1", "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "shiki": "^1.24.4", + "simple-git": "^3.28.0", "sonner": "^1.7.1", "superjson": "^2.2.2", "tailwind-merge": "^2.6.0", @@ -102,7 +113,7 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.3", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-6h54MzD4R5S15esMiK7sPKwotwoYd3qxXdqzRWqSkYo96IwvtSoK5yb0jbWEdDKSW71jjEctFJZBkonGalmTAQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.12", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lto5qlffODYa3He4jbSVdXtPCWVWUxEqWFj+8mWp4tSnY6tMsQBXjwalm7Bz8YgBsEbrCZrceYMcKSw0eL7H+A=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -152,6 +163,14 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@electron-toolkit/preload": ["@electron-toolkit/preload@3.0.2", "", { "peerDependencies": { "electron": ">=13.0.0" } }, "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg=="], @@ -290,6 +309,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -394,6 +417,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -422,6 +447,10 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], @@ -600,6 +629,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -726,6 +757,8 @@ "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -980,7 +1013,11 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -990,6 +1027,10 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], @@ -1078,6 +1119,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -1094,6 +1137,8 @@ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -1104,6 +1149,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -1136,6 +1183,8 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1150,6 +1199,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1164,6 +1215,8 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1202,6 +1255,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -1228,6 +1283,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1242,20 +1299,98 @@ "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], @@ -1472,6 +1607,8 @@ "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1502,6 +1639,16 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], @@ -1540,6 +1687,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1560,6 +1709,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -1580,7 +1731,7 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -1598,8 +1749,14 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], @@ -1646,6 +1803,8 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-electron": ["trpc-electron@0.1.2", "", { "peerDependencies": { "@trpc/client": ">=11.0.0", "@trpc/server": ">=11.0.0", "electron": ">19.0.0" } }, "sha512-sQpWBwQWzsgrERugjzUpPqY/+/n8NxkUq6YssQ5+5rALkvGCWq45T5Dreiwm2kh91dZMFlALTyMd8PhB0vgbIg=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -1662,6 +1821,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], "unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], @@ -1838,6 +1999,8 @@ "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -1856,6 +2019,8 @@ "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1884,6 +2049,8 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1976,6 +2143,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/package.json b/package.json index b39a9864..7190d05e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "dependencies": { "@ai-sdk/react": "^3.0.14", "@anthropic-ai/claude-agent-sdk": "^0.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@radix-ui/react-accordion": "^1.2.11", diff --git a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx index 5003c65d..c9857985 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -12,6 +12,7 @@ import { type NotificationMode, } from "../../../lib/atoms" import { Switch } from "../../ui/switch" +import { Button } from "../../ui/button" import { Select, SelectContent, @@ -175,6 +176,30 @@ export function AgentsPreferencesTab() {
+ + {/* Test Notification Button */} +
+
+ + Test Notification + + + Send a test notification to verify it's working + +
+ +
diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 18464f25..a8c9c50a 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -504,3 +504,18 @@ export type UndoItem = | { type: "subchat"; subChatId: string; chatId: string; timeoutId: ReturnType } export const undoStackAtom = atom([]) + +// Git panel state +export const gitPanelOpenAtom = atomWithStorage( + "agents:gitPanelOpen", + false, + undefined, + { getOnInit: true }, +) + +export const gitPanelHeightAtom = atomWithStorage( + "agents:gitPanelHeight", + 250, + undefined, + { getOnInit: true }, +) diff --git a/src/renderer/features/agents/stores/sub-chat-store.ts b/src/renderer/features/agents/stores/sub-chat-store.ts index 89cc9e94..d08f5386 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -30,6 +30,7 @@ interface AgentSubChatStore { updateSubChatName: (subChatId: string, name: string) => void updateSubChatMode: (subChatId: string, mode: "plan" | "agent") => void updateSubChatTimestamp: (subChatId: string) => void + reorderOpenSubChats: (oldIndex: number, newIndex: number) => void reset: () => void } @@ -188,6 +189,20 @@ export const useAgentSubChatStore = create((set, get) => ({ }) }, + reorderOpenSubChats: (oldIndex: number, newIndex: number) => { + const { openSubChatIds, chatId } = get() + if (oldIndex === newIndex) return + if (oldIndex < 0 || oldIndex >= openSubChatIds.length) return + if (newIndex < 0 || newIndex >= openSubChatIds.length) return + + const newIds = [...openSubChatIds] + const [removed] = newIds.splice(oldIndex, 1) + newIds.splice(newIndex, 0, removed) + + set({ openSubChatIds: newIds }) + if (chatId) saveToLS(chatId, "open", newIds) + }, + reset: () => { set({ chatId: null, diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index 5a421bb5..93c1ade2 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -9,7 +9,7 @@ import { pendingUserQuestionsAtom, } from "../atoms" import { trpc } from "../../../lib/trpc" -import { X, Plus, AlignJustify, Play } from "lucide-react" +import { X, Plus, AlignJustify, Play, GripVertical } from "lucide-react" import { IconSpinner, PlanIcon, @@ -45,6 +45,25 @@ import { toast } from "sonner" import { SearchCombobox } from "../../../components/ui/search-combobox" import { SubChatContextMenu } from "./sub-chat-context-menu" import { formatTimeAgo } from "../utils/format-time-ago" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent, + DragOverlay, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" interface DiffStats { fileCount: number @@ -54,6 +73,223 @@ interface DiffStats { hasChanges: boolean } +interface SortableTabProps { + subChat: SubChatMeta + isActive: boolean + isLoading: boolean + hasUnseen: boolean + isPinned: boolean + hasPendingQuestion: boolean + hasPendingPlan: boolean + isEditing: boolean + editName: string + editLoading: boolean + isTruncated: boolean + canClose: boolean + onSwitch: (id: string) => void + onRename: (subChat: SubChatMeta) => void + onEditSave: (subChat: SubChatMeta) => void + onEditCancel: (subChat: SubChatMeta) => void + onEditNameChange: (name: string) => void + onClose: (id: string) => void + tabRef: (el: HTMLButtonElement | null) => void + textRef: (el: HTMLSpanElement | null) => void + isDragging?: boolean +} + +function SortableTab({ + subChat, + isActive, + isLoading, + hasUnseen, + isPinned, + hasPendingQuestion, + hasPendingPlan, + isEditing, + editName, + editLoading, + isTruncated, + canClose, + onSwitch, + onRename, + onEditSave, + onEditCancel, + onEditNameChange, + onClose, + tabRef, + textRef, + isDragging = false, +}: SortableTabProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: isSortableDragging, + } = useSortable({ id: subChat.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isSortableDragging ? 0.5 : 1, + } + + const mode = subChat.mode || "agent" + + return ( + + ) +} + +// Overlay component for drag preview (simpler version without refs) +function TabDragOverlay({ subChat, isActive }: { subChat: SubChatMeta; isActive: boolean }) { + const mode = subChat.mode || "agent" + + return ( +
+
+ {mode === "plan" ? ( + + ) : ( + + )} +
+ {subChat.name || "New Chat"} +
+ ) +} + interface SubChatSelectorProps { onCreateNew: () => void isMobile?: boolean @@ -78,7 +314,7 @@ export function SubChatSelector({ diffStats, }: SubChatSelectorProps) { // Use shallow comparison to prevent re-renders when arrays have same content - const { activeSubChatId, openSubChatIds, pinnedSubChatIds, allSubChats, parentChatId, togglePinSubChat } = useAgentSubChatStore( + const { activeSubChatId, openSubChatIds, pinnedSubChatIds, allSubChats, parentChatId, togglePinSubChat, reorderOpenSubChats } = useAgentSubChatStore( useShallow((state) => ({ activeSubChatId: state.activeSubChatId, openSubChatIds: state.openSubChatIds, @@ -86,6 +322,7 @@ export function SubChatSelector({ allSubChats: state.allSubChats, parentChatId: state.chatId, togglePinSubChat: state.togglePinSubChat, + reorderOpenSubChats: state.reorderOpenSubChats, })) ) const [loadingSubChats] = useAtom(loadingSubChatsAtom) @@ -118,6 +355,7 @@ export function SubChatSelector({ const [truncatedTabs, setTruncatedTabs] = useState>(new Set()) const [showLeftGradient, setShowLeftGradient] = useState(false) const [showRightGradient, setShowRightGradient] = useState(false) + const [activeDragId, setActiveDragId] = useState(null) // Map open IDs to metadata and sort: pinned first, then preserve user's tab order const openSubChats = useMemo(() => { @@ -147,6 +385,40 @@ export function SubChatSelector({ return [...pinnedChats, ...unpinnedChats] }, [openSubChatIds, allSubChats, pinnedSubChatIds]) + // dnd-kit sensors with distance threshold to allow clicks + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px drag distance before activation + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDragId(event.active.id as string) + }, []) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + setActiveDragId(null) + + if (over && active.id !== over.id) { + const oldIndex = openSubChatIds.indexOf(active.id as string) + const newIndex = openSubChatIds.indexOf(over.id as string) + if (oldIndex !== -1 && newIndex !== -1) { + reorderOpenSubChats(oldIndex, newIndex) + } + } + }, [openSubChatIds, reorderOpenSubChats]) + + const activeDragSubChat = useMemo(() => { + if (!activeDragId) return null + return openSubChats.find((sc) => sc.id === activeDragId) || null + }, [activeDragId, openSubChats]) + const onSwitch = useCallback( (subChatId: string) => { const store = useAgentSubChatStore.getState() @@ -491,193 +763,102 @@ export function SubChatSelector({ )} {/* Scrollable tabs container - with padding-right for plus button */} -
- {hasNoChats - ? null - : openSubChats.map((subChat, index) => { - const isActive = activeSubChatId === subChat.id - const isLoading = loadingSubChats.has(subChat.id) - const hasUnseen = subChatUnseenChanges.has(subChat.id) - const hasTabsToRight = index < openSubChats.length - 1 - const isPinned = pinnedSubChatIds.includes(subChat.id) - // Get mode from sub-chat itself (defaults to "agent") - const mode = subChat.mode || "agent" - // Check if this chat is waiting for user answer - const hasPendingQuestion = pendingQuestions?.subChatId === subChat.id - // Check if this chat has a pending plan approval - const hasPendingPlan = pendingPlanApprovals.has(subChat.id) - - return ( - - - - - 2} - /> - - ) - })} -
+ /> + + 2} + /> + + ) + })} + + + + {activeDragSubChat ? ( + + ) : null} + + {/* Plus button - absolute positioned on right with gradient cover */} {(isMobile || (!isMobile && subChatsSidebarMode === "tabs")) && ( diff --git a/src/renderer/features/git/git-panel.tsx b/src/renderer/features/git/git-panel.tsx new file mode 100644 index 00000000..e4b86215 --- /dev/null +++ b/src/renderer/features/git/git-panel.tsx @@ -0,0 +1,551 @@ +"use client" + +import { useState, useMemo, useCallback } from "react" +import { useAtomValue, useSetAtom } from "jotai" +import { trpc } from "../../lib/trpc" +import { + GitBranch, + Plus, + Minus, + RefreshCw, + Check, + X, + ChevronDown, + ChevronRight, + Undo2, + FileText, + Upload, + Download, + MoreHorizontal, +} from "lucide-react" +import { cn } from "../../lib/utils" +import { Button } from "../../components/ui/button" +import { Textarea } from "../../components/ui/textarea" +import { ScrollArea } from "../../components/ui/scroll-area" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../../components/ui/collapsible" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip" +import { toast } from "sonner" +import type { ChangedFile, FileStatus } from "../../../shared/changes-types" + +interface GitPanelProps { + worktreePath: string | null + defaultBranch?: string + onFileSelect?: (filePath: string, category: "staged" | "unstaged") => void +} + +const statusColors: Record = { + added: "text-green-500", + modified: "text-blue-500", + deleted: "text-red-500", + renamed: "text-purple-500", + copied: "text-purple-500", + untracked: "text-muted-foreground", +} + +const statusLabels: Record = { + added: "A", + modified: "M", + deleted: "D", + renamed: "R", + copied: "C", + untracked: "U", +} + +function FileItem({ + file, + category, + onStage, + onUnstage, + onDiscard, + onSelect, + isStaging, +}: { + file: ChangedFile + category: "staged" | "unstaged" | "untracked" + onStage?: () => void + onUnstage?: () => void + onDiscard?: () => void + onSelect?: () => void + isStaging?: boolean +}) { + const [isHovered, setIsHovered] = useState(false) + const fileName = file.path.split("/").pop() || file.path + const dirPath = file.path.includes("/") + ? file.path.substring(0, file.path.lastIndexOf("/")) + : "" + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} + > + + {statusLabels[file.status]} + + + {fileName} + {dirPath && ( + {dirPath} + )} + + {file.additions > 0 && ( + +{file.additions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} + {isHovered && !isStaging && ( +
+ {category === "staged" && onUnstage && ( + + + + + Unstage + + )} + {(category === "unstaged" || category === "untracked") && onStage && ( + + + + + Stage + + )} + {category === "unstaged" && onDiscard && ( + + + + + Discard Changes + + )} +
+ )} +
+ ) +} + +export function GitPanel({ + worktreePath, + defaultBranch = "main", + onFileSelect, +}: GitPanelProps) { + const [commitMessage, setCommitMessage] = useState("") + const [stagedOpen, setStagedOpen] = useState(true) + const [changesOpen, setChangesOpen] = useState(true) + const [isStaging, setIsStaging] = useState(false) + + const utils = trpc.useUtils() + + const { data: status, isLoading, refetch } = trpc.changes.getStatus.useQuery( + { worktreePath: worktreePath || "", defaultBranch }, + { enabled: !!worktreePath, refetchInterval: 5000 } + ) + + const stageMutation = trpc.changes.stageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to stage: ${err.message}`), + }) + + const unstageMutation = trpc.changes.unstageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to unstage: ${err.message}`), + }) + + const stageAllMutation = trpc.changes.stageAll.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to stage all: ${err.message}`), + }) + + const unstageAllMutation = trpc.changes.unstageAll.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to unstage all: ${err.message}`), + }) + + const discardMutation = trpc.changes.discardChanges.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to discard: ${err.message}`), + }) + + const commitMutation = trpc.changes.commit.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + setCommitMessage("") + toast.success("Changes committed!") + }, + onError: (err) => toast.error(`Commit failed: ${err.message}`), + }) + + const pushMutation = trpc.changes.push.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + toast.success("Pushed to remote!") + }, + onError: (err) => toast.error(`Push failed: ${err.message}`), + }) + + const pullMutation = trpc.changes.pull.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + toast.success("Pulled from remote!") + }, + onError: (err) => toast.error(`Pull failed: ${err.message}`), + }) + + const handleStage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + setIsStaging(true) + try { + await stageMutation.mutateAsync({ worktreePath, filePath }) + } finally { + setIsStaging(false) + } + }, + [worktreePath, stageMutation] + ) + + const handleUnstage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + setIsStaging(true) + try { + await unstageMutation.mutateAsync({ worktreePath, filePath }) + } finally { + setIsStaging(false) + } + }, + [worktreePath, unstageMutation] + ) + + const handleDiscard = useCallback( + async (filePath: string) => { + if (!worktreePath) return + if (!confirm(`Discard changes to ${filePath}?`)) return + await discardMutation.mutateAsync({ worktreePath, filePath }) + }, + [worktreePath, discardMutation] + ) + + const handleCommit = useCallback(async () => { + if (!worktreePath || !commitMessage.trim()) return + await commitMutation.mutateAsync({ + worktreePath, + message: commitMessage.trim(), + }) + }, [worktreePath, commitMessage, commitMutation]) + + const handleStageAll = useCallback(async () => { + if (!worktreePath) return + await stageAllMutation.mutateAsync({ worktreePath }) + }, [worktreePath, stageAllMutation]) + + const handleUnstageAll = useCallback(async () => { + if (!worktreePath) return + await unstageAllMutation.mutateAsync({ worktreePath }) + }, [worktreePath, unstageAllMutation]) + + const unstagedFiles = useMemo(() => { + if (!status) return [] + return [...status.unstaged, ...status.untracked] + }, [status]) + + const stagedCount = status?.staged.length || 0 + const unstagedCount = unstagedFiles.length + const hasChanges = stagedCount > 0 || unstagedCount > 0 + + if (!worktreePath) { + return ( +
+ +

No project selected

+
+ ) + } + + if (isLoading) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + {status?.branch || "—"} +
+
+ {status?.pullCount ? ( + + + + + Pull ({status.pullCount}) + + ) : null} + {status?.pushCount ? ( + + + + + Push ({status.pushCount}) + + ) : null} + + + + + Refresh + +
+
+ + {/* Commit input */} +
+