diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4933885..af39358 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,7 +3,10 @@ ## Workflow - v1 is a single-file Python app (`bterminal.py`). Changes are localized. -- v2 planning docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`. +- v2 docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`. +- v2 Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Extras: SSH, ctx, themes, detached mode, auto-updater, shiki, copy/paste, session resume, drag-resize, session groups, Deno sidecar, Claude profiles, skill discovery. +- v3 Mission Control (All Phases 1-10 Complete + S-1 Phase 1/1.5/2/3 + S-2 Session Anchors + Provider Adapter Pattern + Provider Runners + Memora Adapter + SOLID Phase 3 + Multi-Agent Orchestration): project groups, workspace store, 15 Workspace components, session continuity, workspace teardown, file overlap conflict detection, inotify-based external write detection, multi-provider adapter pattern (3 phases + Codex/Ollama runners), worktree isolation, session anchors, Memora adapter (read-only SQLite), SOLID refactoring (agent-dispatcher split → 4 utils, session.rs split → 7 sub-modules, branded types), multi-agent orchestration (btmsg inter-agent messaging, bttask kanban task board, agent prompt generator, BTMSG_AGENT_ID env passthrough, periodic re-injection, role-specific tabs: Manager=Tasks, Architect=Arch, Tester=Selenium+Tests), dead v2 component cleanup. 286 vitest + 49 cargo tests. +- v3 docs: `docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`. - Consult Memora (tag: `bterminal`) before making architectural changes. ## Documentation References @@ -12,21 +15,78 @@ - Implementation phases: [docs/phases.md](../docs/phases.md) - Research findings: [docs/findings.md](../docs/findings.md) - Progress log: [docs/progress.md](../docs/progress.md) +- v3 architecture: [docs/v3-task_plan.md](../docs/v3-task_plan.md) +- v3 findings: [docs/v3-findings.md](../docs/v3-findings.md) +- v3 progress: [docs/v3-progress.md](../docs/v3-progress.md) ## Rules - Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable. -- v2 work goes on a feature branch (`v2-mission-control`), not master. -- All v2 architecture decisions must reference `docs/task_plan.md` Decisions Log. +- v2/v3 work goes on the `v2-mission-control` branch, not master. +- v2 architecture decisions must reference `docs/task_plan.md` Decisions Log. +- v3 architecture decisions must reference `docs/v3-task_plan.md` Decisions Log. - When adding new decisions, append to the Decisions Log table with date. - Update `docs/progress.md` after each significant work session. ## Key Technical Constraints - WebKit2GTK has no WebGL — xterm.js must use Canvas addon explicitly. -- Claude Agent SDK is 0.2.x (pre-1.0) — all SDK interactions go through the adapter layer (`src/lib/adapters/sdk-messages.ts`). -- Node.js sidecar communicates via stdio NDJSON, not sockets. -- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues. +- Agent sessions use `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning due to piped stdio hang bug). SDK handles subprocess management internally. All output goes through the adapter layer (`src/lib/adapters/claude-messages.ts` via `message-adapters.ts` registry) — SDK message format matches CLI stream-json. Multi-provider support: message-adapters.ts routes by ProviderId to provider-specific parsers (claude-messages.ts, codex-messages.ts, ollama-messages.ts — all 3 registered). +- Sidecar uses per-provider runner bundles (`sidecar/dist/{provider}-runner.mjs`). Currently only `claude-runner.mjs` exists. SidecarManager.resolve_sidecar_for_provider(provider) finds the right runner file. Deno preferred (faster startup), Node.js fallback. Communicates with Rust via stdio NDJSON. Claude CLI auto-detected at startup via `findClaudeCli()` — checks ~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude, then `which claude`. Path passed to SDK via `pathToClaudeCodeExecutable` option. Agents error immediately if CLI not found. Provider env var stripping: strip_provider_env_var() strips CLAUDE*/CODEX*/OLLAMA* vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*). Dual-layer: (1) Rust env_clear() + clean_env, (2) JS runner SDK `env` option. Session stop uses AbortController.abort(). `agent-runner-deno.ts` exists as standalone alternative runner but is NOT used by SidecarManager. +- AgentPane does NOT stop agents in onDestroy — onDestroy fires on layout remounts, not just explicit close. Stop-on-close is handled externally (was TilingGrid in v2, now workspace teardown in v3). +- Agent dispatcher (`src/lib/agent-dispatcher.ts`) is a thin coordinator (260 lines) routing sidecar events to the agent store. Delegates to extracted modules: `utils/session-persistence.ts` (session-project maps, persistSessionForProject), `utils/subagent-router.ts` (spawn + route subagent panes), `utils/auto-anchoring.ts` (triggerAutoAnchor on compaction), `utils/worktree-detection.ts` (detectWorktreeFromCwd pure function). Provider-aware via message-adapters.ts. +- AgentQueryOptions supports `provider` field (defaults to 'claude', flows Rust -> sidecar), `provider_config` blob (Rust passes through as serde_json::Value), `permission_mode` (defaults to 'bypassPermissions'), `setting_sources` (defaults to ['user', 'project']), `system_prompt`, `model`, `claude_config_dir` (for multi-account), `additional_directories`, `worktree_name` (when set, passed as `extraArgs: { worktree: name }` to SDK → `--worktree ` CLI flag), `extra_env` (HashMap, injected into sidecar process env; used for BTMSG_AGENT_ID). +- Multi-agent orchestration: Tier 1 (management agents: Manager, Architect, Tester, Reviewer) defined in groups.json `agents[]`, converted to ProjectConfig via `agentToProject()`, rendered as full ProjectBoxes. Tier 2 (project agents) are regular ProjectConfig entries. Both tiers get system prompts. Tier 1 prompt built by `generateAgentPrompt()` (utils/agent-prompts.ts): 7 sections (Identity, Environment, Team, btmsg docs, bttask docs, Custom context, Workflow). Tier 2 gets optional `project.systemPrompt` as custom context. BTMSG_AGENT_ID env var injected for Tier 1 agents only (enables btmsg/bttask CLI usage). Periodic re-injection: AgentSession runs 1-hour timer, sends context refresh prompt when agent is idle (autoPrompt → AgentPane → startQuery with resume=true). +- bttask kanban: Rust bttask.rs module reads/writes tasks table in shared btmsg.db (~/. local/share/bterminal/btmsg.db). 6 operations: list_tasks, create_task, update_task_status, delete_task, add_comment, task_comments. Frontend: TaskBoardTab.svelte (kanban 5 columns, 5s poll). CLI `bttask` tool gives agents direct access; Manager has full CRUD, other roles have read-only + comments. +- ArchitectureTab: PlantUML diagram viewer/editor. Stores .puml files in `.architecture/` project dir. Renders via plantuml.com server using ~h hex encoding (no Java dependency). 4 templates: Class, Sequence, State, Component. Editor + SVG preview toggle. +- TestingTab: Dual-mode component (mode='selenium'|'tests'). Selenium: watches `.selenium/screenshots/` for PNG/JPG, displays in gallery with session log, 3s poll. Tests: discovers files in standard dirs (tests/, test/, spec/, __tests__/, e2e/), shows content. +- Worktree isolation (S-1 Phase 3): Per-project `useWorktrees` toggle in SettingsTab. When enabled, AgentPane passes `worktree_name=sessionId` in queryAgent(). Agent runs in `/.claude/worktrees//`. CWD-based detection: `utils/worktree-detection.ts` `detectWorktreeFromCwd()` matches `.claude/worktrees/`, `.codex/worktrees/`, `.cursor/worktrees/` patterns on init events → calls `setSessionWorktree()` for conflict suppression. Dual detection: CWD-based (primary, from init event) + tool_call-based `extractWorktreePath()` (subagent fallback). +- Claude profiles: claude_list_profiles() reads ~/.config/switcher/profiles/ with profile.toml metadata. Profile set per-project in Settings (project.profile field), passed through AgentSession -> AgentPane `profile` prop -> resolved to config_dir for SDK. Profile name shown as info-only in ProjectHeader. +- ProjectBox has project-level tab bar: Model | Docs | Context | Files | SSH | Memory + role-specific tabs. Three mount strategies: PERSISTED-EAGER (Model, Docs, Context — always mounted, display:flex/none), PERSISTED-LAZY (Files, SSH, Memory, Tasks, Architecture, Selenium, Tests — mount on first activation via {#if everActivated} + display:flex/none). Tab type: `'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'tasks' | 'architecture' | 'selenium' | 'tests'`. Role-specific tabs: Manager gets Tasks (kanban), Architect gets Arch (PlantUML), Tester gets Selenium+Tests. Conditional on `isAgent && agentRole`. Model tab = AgentSession+TeamAgentsPanel. Docs tab = ProjectFiles (markdown viewer). Context tab = ContextTab.svelte (LLM context window visualization: stats bar, segmented token meter, file references, turn breakdown; reads from agent store via sessionId prop; replaced old ContextPane ctx database viewer). Files tab = FilesTab.svelte (VSCode-style directory tree + CodeMirror 6 editor with 15 language modes, dirty tracking, Ctrl+S save, save-on-blur setting, image display via convertFileSrc, 10MB gate; CodeEditor.svelte wrapper; PdfViewer.svelte for PDF files via pdfjs-dist with canvas multi-page rendering + zoom 0.5x–3x; CsvTable.svelte for CSV with RFC 4180 parser, delimiter auto-detect, sortable columns). SSH tab = SshTab.svelte (CRUD for SSH connections, launch spawns terminal tab in Model tab). Memory tab = MemoriesTab.svelte (pluggable via MemoryAdapter interface in memory-adapter.ts; MemoraAdapter registered at startup, reads ~/.local/share/memora/memories.db via Rust memora.rs). Tasks tab = TaskBoardTab.svelte (kanban board, 5 columns, 5s poll, Manager only). Arch tab = ArchitectureTab.svelte (PlantUML viewer/editor, .architecture/ dir, plantuml.com ~h hex encoding, Architect only). Selenium tab = TestingTab.svelte mode=selenium (screenshot gallery, session log, 3s poll, Tester only). Tests tab = TestingTab.svelte mode=tests (test file discovery, content viewer, Tester only). Rust backend: list_directory_children + read_file_content + write_file_content (FileContent tagged union: Text/Binary/TooLarge). Frontend bridge: files-bridge.ts. +- ProjectHeader shows CWD (ellipsized from START via `direction: rtl`) + profile name as info-only text on right side. AgentPane no longer has DIR/ACC toolbar — CWD and profile are props from parent. +- Skill discovery: claude_list_skills() reads ~/.claude/skills/ (dirs with SKILL.md or .md files). claude_read_skill() reads content. AgentPane `/` prefix triggers autocomplete menu. Skill content injected as prompt via expandSkillPrompt(). +- claude-bridge.ts adapter wraps profile/skill Tauri commands (ClaudeProfile, ClaudeSkill interfaces). provider-bridge.ts wraps claude-bridge as generic provider bridge (delegates by ProviderId). +- Provider adapter pattern: ProviderId = 'claude' | 'codex' | 'ollama'. ProviderCapabilities flags gate UI (hasProfiles, hasSkills, hasModelSelection, hasSandbox, supportsSubagents, supportsCost, supportsResume). ProviderMeta registered via registerProvider() in App.svelte onMount. AgentPane receives provider + capabilities props. SettingsTab has Providers section with collapsible per-provider config panels. ProjectConfig.provider field for per-project selection. Settings persisted as `provider_settings` JSON blob. +- Sidecar build: `npm run build:sidecar` builds all 3 runners via esbuild (claude-runner.mjs, codex-runner.mjs, ollama-runner.mjs). Each is a standalone ESM bundle. Codex runner dynamically imports @openai/codex-sdk (graceful failure if not installed). Ollama runner uses native fetch (zero deps). +- Agent preview terminal: `AgentPreviewPane.svelte` is a read-only xterm.js terminal (disableStdin:true) that subscribes to an agent session's messages via `$derived(getAgentSession(sessionId))` and renders tool calls/results in real-time. Bash commands shown as cyan `❯ cmd`, file ops as yellow `[Read] path`, results as plain text (80-line truncation), errors in red. Spawned via 👁 button in TerminalTabs (appears when agentSessionId prop is set). TerminalTab type: `'agent-preview'` with `agentSessionId` field. Deduplicates — won't create two previews for the same session. ProjectBox passes mainSessionId to TerminalTabs. +- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues. Agent preview uses disableStdin and no PTY so is lighter, but still counts. +- Store files using Svelte 5 runes (`$state`, `$derived`) MUST have `.svelte.ts` extension (not `.ts`). Import with `.svelte` suffix. Plain `.ts` compiles but fails at runtime with "rune_outside_svelte". +- Session persistence uses rusqlite (bundled) with WAL mode. Data dir: `dirs::data_dir()/bterminal/sessions.db`. +- Layout store persists to SQLite on every addPane/removePane/setPreset/setPaneGroup change (fire-and-forget). Restores on app startup via `restoreFromDb()`. +- Session groups: Pane.group? field in layout store, group_name column in sessions table, collapsible group headers in sidebar. Right-click pane to set group. +- File watcher uses notify crate v6, watches parent directory (NonRecursive), emits `file-changed` Tauri events. +- Settings use key-value `settings` table in SQLite (session/settings.rs). Frontend: `settings-bridge.ts` adapter. v3 uses SettingsTab.svelte rendered in sidebar drawer panel (v2 SettingsDialog.svelte deleted in P10). SettingsTab has two sections: Global (single-column layout, split into Appearance [theme dropdown, UI font dropdown with sans-serif options + size stepper, Terminal font dropdown with monospace options + size stepper] and Defaults [shell, CWD] — all custom themed dropdowns, no native ``), all persisted via settings-bridge +- Typography CSS custom properties (`--ui-font-family`, `--ui-font-size`, `--term-font-family`, `--term-font-size`) in catppuccin.css with defaults; consumed by app.css body rule +- `initTheme()` now restores 4 saved font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup alongside theme restoration +- v3 Mission Control (All Phases 1-10 complete): multi-project dashboard with project groups, per-project Claude sessions, team agents panel, terminal tabs, 3 workspace tabs (Sessions/Docs/Context) + settings drawer +- v3 session continuity (P6): `persistSessionForProject()` saves agent state + messages to SQLite on session complete; `registerSessionProject()` maps session to project; `ClaudeSession.restoreMessagesFromRecords()` restores cached messages on mount +- v3 workspace teardown (P7): `clearAllAgentSessions()` clears agent sessions on group switch; terminal tabs reset via `switchGroup()` +- v3 data model: `groups.rs` (Rust structs + load/save `~/.config/bterminal/groups.json`), `groups.ts` (TypeScript interfaces), `groups-bridge.ts` (IPC adapter), `--group` CLI argument +- v3 workspace store (`workspace.svelte.ts`): replaces `layout.svelte.ts`, manages groups/activeGroupId/activeTab/focusedProjectId with Svelte 5 runes +- v3 SQLite migrations: `agent_messages` table (per-project message persistence), `project_agent_state` table (sdkSessionId/cost/status per project), `project_id` column on sessions +- 12 new Workspace components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, ClaudeSession, TeamAgentsPanel, AgentCard, TerminalTabs, CommandPalette, DocsTab, ContextTab, SettingsTab +- v3 App.svelte full rewrite: GlobalTabBar + tab content area + StatusBar (no sidebar, no TilingGrid) +- 24 new vitest tests for workspace store, 7 new cargo tests for groups (total: 138 vitest + 36 cargo) +- v3 adversarial architecture review: 3 agents (Architect, Devil's Advocate, UX+Performance Specialist), 12 issues identified and resolved +- v3 Mission Control redesign planning: architecture docs (`docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`), codebase reuse analysis +- Claude profile/account switching: `claude_list_profiles()` reads `~/.config/switcher/profiles/` directories with `profile.toml` metadata (email, subscription_type, display_name); profile selector dropdown in AgentPane toolbar when multiple profiles available; selected profile's `config_dir` passed as `CLAUDE_CONFIG_DIR` env override to SDK +- Skill discovery and autocomplete: `claude_list_skills()` reads `~/.claude/skills/` (directories with `SKILL.md` or standalone `.md` files); type `/` in agent prompt textarea to trigger autocomplete menu with arrow key navigation, Tab/Enter selection, Escape dismiss; `expandSkillPrompt()` reads skill content and injects as prompt +- New frontend adapter `claude-bridge.ts`: `ClaudeProfile` and `ClaudeSkill` interfaces, `listProfiles()`, `listSkills()`, `readSkill()` IPC wrappers +- AgentPane session toolbar: editable working directory input, profile/account selector (shown when >1 profile), all rendered above prompt form +- Extended `AgentQueryOptions` with 5 new fields across full stack (Rust struct, sidecar JSON, SDK options): `setting_sources` (defaults to `['user', 'project']`), `system_prompt`, `model`, `claude_config_dir`, `additional_directories` +- 4 new Tauri commands: `claude_list_profiles`, `claude_list_skills`, `claude_read_skill`, `pick_directory` +- Claude CLI path auto-detection: `findClaudeCli()` in both sidecar runners checks common paths (~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude) then falls back to `which`/`where`; resolved path passed to SDK via `pathToClaudeCodeExecutable` option +- Early error reporting when Claude CLI is not found — sidecar emits `agent_error` immediately instead of cryptic SDK failure + +### Changed +- SettingsTab global settings restructured to single-column layout with labels above controls, split into "Appearance" (theme, UI font, terminal font) and "Defaults" (shell, CWD) subsections; all native `` with custom themed dropdown in SettingsTab.svelte +- [x] Trigger: color swatch (base) + label + arrow; menu: grouped sections with styled headers +- [x] Options show color swatch + label + 4 accent dots (red/green/blue/yellow) via getPalette() +- [x] Click-outside and Escape to close; aria-haspopup/aria-expanded for a11y +- [x] Uses --ctp-* CSS vars — fully themed with active theme + +### Session: 2026-03-07 (continued) — Global Font Controls + +#### SettingsTab Font Controls + Layout Restructure +- [x] Added font family select (9 monospace fonts + Default) with live CSS var preview +- [x] Added font size +/- stepper (8-24px range) with live CSS var preview +- [x] Restructured global settings: 2-column grid layout with labels above controls (replaced inline rows) +- [x] Added --ui-font-family and --ui-font-size CSS custom properties to catppuccin.css +- [x] app.css body rule now uses CSS vars instead of hardcoded font values +- [x] initTheme() in theme.svelte.ts restores saved font settings on startup (try/catch, non-fatal) +- [x] Font settings persisted as 'font_family' and 'font_size' keys in SQLite settings table + +### Session: 2026-03-07 (continued) — SettingsTab Global Settings Redesign + +#### Font Settings Split (UI Font + Terminal Font) +- [x] Split single font into UI font (sans-serif: System Sans-Serif, Inter, Roboto, etc.) and Terminal font (monospace: JetBrains Mono, Fira Code, etc.) +- [x] Each font dropdown renders preview text in its own typeface +- [x] Independent size steppers (8-24px) for UI and Terminal font +- [x] Setting keys changed: font_family/font_size -> ui_font_family/ui_font_size + term_font_family/term_font_size + +#### SettingsTab Layout + CSS Updates +- [x] Rewrote global settings: single-column layout, "Appearance" + "Defaults" subsections +- [x] All dropdowns are custom themed (no native `` with custom themed dropdown in SettingsTab.svelte +- [x] Dropdown trigger shows color swatch (base color from getPalette()) + theme label + arrow indicator +- [x] Dropdown menu groups themes by category (Catppuccin/Editor/Deep Dark) with styled uppercase headers +- [x] Each option shows: color swatch + label + 4 accent color dots (red/green/blue/yellow) +- [x] Active theme highlighted with surface0 background + bold text +- [x] Click-outside handler and Escape key to close dropdown +- [x] Uses --ctp-* CSS vars throughout — fully themed with any active theme +- [x] Added `getPalette` import from themes.ts for live color rendering +- [x] Added aria-haspopup/aria-expanded attributes for accessibility + +#### Verification +- No test changes needed — UI-only change, no logic changes + +### Session: 2026-03-07 — Theme Dropdown CSS Polish + +#### SettingsTab Dropdown Sizing Fix +- [x] Set `min-width: 180px` on `.theme-dropdown` container (was `min-width: 0`) to prevent trigger from collapsing +- [x] Set `min-width: 280px` on `.theme-options` dropdown menu (was `right: 0`) to ensure full theme names visible +- [x] Increased `max-height` from 320px to 400px on dropdown menu for better scrolling experience +- [x] Added `white-space: nowrap` on `.theme-option-label` (was `min-width: 0`) to prevent label text wrapping + +#### Verification +- No test changes needed — CSS-only change + +### Session: 2026-03-07 — Global Font Controls + +#### SettingsTab Font Family + Font Size Controls +- [x] Added font family `` anywhere) + +#### CSS + Theme Store Updates +- [x] Added `--term-font-family` and `--term-font-size` CSS custom properties to catppuccin.css +- [x] Updated `initTheme()` in theme.svelte.ts: loads 4 font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) instead of 2 +- [x] UI font fallback changed from monospace to sans-serif + +#### Verification +- No test changes needed — UI/CSS-only changes, no logic changes + +### Session: 2026-03-08 — CSS Relative Units Rule + +#### New Rule: 18-relative-units.md +- [x] Created `.claude/rules/18-relative-units.md` enforcing rem/em for layout CSS +- [x] Pixels allowed only for icon sizes, borders/outlines, box shadows +- [x] Exception: --ui-font-size/--term-font-size CSS vars store px (xterm.js API requirement) +- [x] Added rule #18 to `.claude/CLAUDE.md` rule index + +#### CSS Conversions +- [x] GlobalTabBar.svelte: rail width 36px -> 2.75rem, button 28px -> 2rem, gap 2px -> 0.25rem, padding 6px 4px -> 0.5rem 0.375rem, border-radius 4px -> 0.375rem +- [x] App.svelte: sidebar header padding 8px 12px -> 0.5rem 0.75rem, close button 22px -> 1.375rem, border-radius 4px -> 0.25rem +- [x] Also changed GlobalTabBar rail-btn color from --ctp-overlay1 to --ctp-subtext0 for better contrast + +### Session: 2026-03-08 — Content-Driven Sidebar Width + +#### Sidebar Panel Sizing +- [x] Changed `.sidebar-panel` from fixed `width: 28em` to `width: max-content` with `min-width: 16em` and `max-width: 50%` +- [x] Changed `.sidebar-panel` and `.panel-content` from `overflow: hidden` to `overflow-y: auto` — hidden was blocking content from driving parent width +- [x] Each tab component now defines its own `min-width: 22em` (SettingsTab, ContextTab, DocsTab) + +#### Additional px → rem Conversions +- [x] SettingsTab.svelte: padding 12px 16px → 0.75rem 1rem +- [x] DocsTab.svelte: file-picker 220px → 14em, picker-title padding → rem, file-btn padding → rem, empty/loading padding → rem +- [x] ContextPane.svelte: font-size, padding, margin, gap converted from px to rem; added `white-space: nowrap` on `.ctx-header`/`.ctx-error` for intrinsic width measurement + +#### Fix: Sidebar Drawer Content-Driven Width +- [x] Root cause found: `#app` in `app.css` had leftover v2 grid layout (`display: grid; grid-template-columns: var(--sidebar-width) 1fr`) constraining `.app-shell` to 260px first column +- [x] Removed v2 grid + both media queries from `#app` — v3 `.app-shell` manages its own flexbox layout +- [x] Added JS `$effect` in App.svelte: measures content width via `requestAnimationFrame` + `querySelectorAll` for nowrap elements, headings, inputs, tab-specific selectors; `panelWidth` state drives inline `style:width` +- [x] Verified all 4 tabs scale to content: Sessions ~473px, Settings ~322px, Context ~580px, Docs varies by content +- [x] Investigation path: CSS intrinsic sizing (max-content, fit-content) failed due to column-flex circular dependency → JS measurement approach → discovered inline style set but rendered width wrong → Playwright inspection revealed parent `.main-row` only 260px → traced to `#app` grid layout + +### Session: 2026-03-08 — Native Directory Picker + +#### tauri-plugin-dialog Integration +- [x] Added `tauri-plugin-dialog` Rust crate + `@tauri-apps/plugin-dialog` npm package +- [x] Registered plugin in lib.rs (`tauri_plugin_dialog::init()`) +- [x] Removed stub `pick_directory` Tauri command (always returned None) +- [x] Added `browseDirectory()` helper in SettingsTab.svelte using `open({ directory: true })` +- [x] Added folder browse button (folder SVG icon) to: Default CWD, existing project CWD, Add Project path +- [x] Styled `.input-with-browse` layout (flex row, themed browse button) +- [x] Fixed nested input theme: `.setting-field .input-with-browse input` selector for dark background +- [x] Fixed dialog not opening: added `"dialog:default"` permission to `v2/src-tauri/capabilities/default.json` — Tauri IPC security blocked invoke() without capability +- [x] Verified via Playwright: error was `Cannot read properties of undefined (reading 'invoke')` in browser context (expected — Tauri IPC only exists in WebView), confirming code is correct +- [x] Clean rebuild required after capability changes (cached binary doesn't pick up new permissions) + +#### Modal + Dark-Themed Dialog +- [x] Root cause: `tauri-plugin-dialog` skips `set_parent(&window)` on Linux via `cfg(any(windows, target_os = "macos"))` gate in commands.rs — dialog not modal +- [x] Root cause: native GTK file chooser uses system GTK theme, not app's CSS theme — dialog appears light +- [x] Fix: custom `pick_directory` Tauri command using `rfd::AsyncFileDialog` directly with `.set_parent(&window)` — modal on Linux +- [x] Fix: `std::env::set_var("GTK_THEME", "Adwaita:dark")` at start of `run()` in lib.rs — dark-themed dialog +- [x] Added `rfd = { version = "0.16", default-features = false, features = ["gtk3"] }` as direct dep — MUST disable defaults to avoid gtk3+xdg-portal feature conflict +- [x] Switched SettingsTab from `@tauri-apps/plugin-dialog` `open()` to `invoke('pick_directory')` + +### Session: 2026-03-08 — Project Workspace Layout Redesign + Icon Fix + +#### Icon Fix +- [x] Replaced Nerd Font codepoints (`\uf120`) with emoji (`📁` default) — Nerd Font not installed, showed "?" +- [x] Added emoji picker grid (24 project-relevant emoji, 8-column popup) in SettingsTab instead of plain text input +- [x] Removed `font-family: 'NerdFontsSymbols Nerd Font'` from ProjectHeader and TerminalTabs + +#### ProjectBox Layout Redesign +- [x] Switched ProjectBox from flex to CSS grid (`grid-template-rows: auto 1fr auto`) — header | session | terminal zones +- [x] Terminal area: explicit `height: 16rem` instead of collapsing to content +- [x] Session area: `min-height: 0` for proper flex child overflow + +#### AgentPane Prompt Layout +- [x] Prompt area anchored to bottom (`justify-content: flex-end`) instead of vertical center +- [x] Removed `max-width: 600px` constraint on form and toolbar — uses full panel width +- [x] Toolbar sits directly above textarea + +#### CSS px → rem Conversions +- [x] ProjectGrid.svelte: gap 4px → 0.25rem, padding 4px → 0.25rem, min-width 480px → 30rem +- [x] TerminalTabs.svelte: tab bar, tabs, close/add buttons all converted to rem +- [x] ProjectBox.svelte: min-width 480px → 30rem + +### Session: 2026-03-08 — Project-Level Tabs + Clean AgentPane + +#### ProjectHeader Info Bar +- [x] Added CWD path display (ellipsized from START via `direction: rtl` + `text-overflow: ellipsis`) +- [x] Added profile name as info-only text (right side of header) +- [x] Home dir shortening: `/home/user/foo` → `~/foo` + +#### Project-Level Tab Bar +- [x] Added tab bar in ProjectBox below header: Claude | Files | Context +- [x] Content area switches between ClaudeSession, ProjectFiles, ContextPane based on selected tab +- [x] CSS grid updated to 4 rows: `auto auto 1fr auto` (header | tabs | content | terminal) +- [x] TeamAgentsPanel still renders alongside ClaudeSession in Claude tab + +#### ProjectFiles Component (NEW) +- [x] Created `ProjectFiles.svelte` — project-scoped markdown file viewer +- [x] Accepts `cwd` + `projectName` props (not workspace store) +- [x] File picker sidebar (10rem) + MarkdownPane content area +- [x] Auto-selects priority file or first file + +#### AgentPane Cleanup +- [x] Removed entire session toolbar (DIR/ACC interactive inputs + all CSS) +- [x] Added `profile` prop — resolved via `listProfiles()` to get config_dir +- [x] CWD passed as prop from parent (project.cwd), no longer editable in pane +- [x] Clean chat interface: prompt (bottom-anchored) + messages + send button +- [x] ClaudeSession now passes `project.profile` to AgentPane + +#### Verification +- All 138 vitest tests pass +- Vite build succeeds + +### Session: 2026-03-08 — Security Audit Fixes + OTEL Telemetry + +#### Security Audit Fixes +- [x] Fixed all CRITICAL (5) + HIGH (4) findings — path traversal, race conditions, memory leaks, listener leaks, transaction safety +- [x] Fixed all MEDIUM (6) findings — runtime type guards, ANTHROPIC_* env stripping, timestamp mismatch, async lock, error propagation +- [x] Fixed all LOW (8) findings — input validation, mutex poisoning, log warnings, payload validation +- [x] 3 false positives dismissed with rationale +- [x] 172/172 tests pass (138 vitest + 34 cargo) + +#### OTEL Telemetry Implementation +- [x] Added 6 Rust deps: tracing, tracing-subscriber, opentelemetry 0.28, opentelemetry_sdk 0.28, opentelemetry-otlp 0.28, tracing-opentelemetry 0.29 +- [x] Created `v2/src-tauri/src/telemetry.rs` — TelemetryGuard, layer composition, OTLP export via BTERMINAL_OTLP_ENDPOINT env var +- [x] Integrated into lib.rs: TelemetryGuard in AppState, init before Tauri builder +- [x] Instrumented 10 Tauri commands with `#[tracing::instrument]`: pty_spawn, pty_kill, agent_query/stop/restart, remote_connect/disconnect/agent_query/agent_stop/pty_spawn +- [x] Added `frontend_log` Tauri command for frontend→Rust tracing bridge +- [x] Created `v2/src/lib/adapters/telemetry-bridge.ts` — `tel.info/warn/error/debug/trace()` convenience API +- [x] Wired agent dispatcher lifecycle events: agent_started, agent_stopped, agent_error, sidecar_crashed, cost metrics +- [x] Created Docker compose stack: `docker/tempo/` — Tempo (4317/4318/3200) + Grafana (port 9715) + +### Session: 2026-03-08 — Teardown Race Fix + px→rem Conversion + +#### Workspace Teardown Race Fix +- [x] Added `pendingPersistCount` counter + `waitForPendingPersistence()` export in agent-dispatcher.ts +- [x] `persistSessionForProject()` increments/decrements counter in try/finally +- [x] `switchGroup()` in workspace.svelte.ts now awaits `waitForPendingPersistence()` before clearing state +- [x] SettingsTab.svelte switchGroup onclick handler made async with await +- [x] Added test for `waitForPendingPersistence` in agent-dispatcher.test.ts +- [x] Added mock for `waitForPendingPersistence` in workspace.test.ts +- [x] Last open HIGH audit finding resolved (workspace teardown race) + +#### px→rem Conversion (Rule 18 Compliance) +- [x] Converted ~100 px layout violations to rem across 10 components +- [x] AgentPane.svelte (~35 violations: font-size, padding, gap, margin, max-height, border-radius) +- [x] ToastContainer.svelte, CommandPalette.svelte, TeamAgentsPanel.svelte, AgentCard.svelte +- [x] StatusBar.svelte, AgentTree.svelte, TerminalPane.svelte, AgentPreviewPane.svelte, SettingsTab.svelte +- [x] Icon/decorative dot dimensions kept as px per rule 18 +- [x] 139 vitest + 34 cargo tests pass, vite build succeeds + +### Session: 2026-03-08 — E2E Testing Infrastructure + +#### WebdriverIO + tauri-driver Setup +- [x] Installed @wdio/cli, @wdio/local-runner, @wdio/mocha-framework, @wdio/spec-reporter (v9.24.0) +- [x] Created wdio.conf.js with tauri-driver lifecycle hooks (onPrepare builds debug binary, beforeSession/afterSession spawns/kills tauri-driver) +- [x] Created tsconfig.json for e2e test TypeScript compilation +- [x] Created smoke.test.ts with 6 tests: app title, status bar, version text, sidebar rail, workspace area, sidebar toggle +- [x] Added `test:e2e` npm script (`wdio run tests/e2e/wdio.conf.js`) +- [x] Updated README.md with complete setup instructions and CI guide +- [x] Key decision: WebdriverIO over Playwright (Playwright cannot control Tauri/WebKit2GTK apps) +- [x] Prerequisites: tauri-driver (cargo install), webkit2gtk-driver (apt), display server or xvfb-run + +#### E2E Fixes (wdio v9 + tauri-driver compatibility) +- [x] Fixed wdio v9 BiDi: added `wdio:enforceWebDriverClassic: true` — wdio v9 injects webSocketUrl:true which tauri-driver rejects +- [x] Removed `browserName: 'wry'` from capabilities (not needed in wdio, only Selenium) +- [x] Fixed binary path: Cargo workspace target is v2/target/debug/, not v2/src-tauri/target/debug/ +- [x] Fixed tauri-plugin-log panic: telemetry::init() registers tracing-subscriber before plugin-log → removed tauri-plugin-log entirely (redundant with telemetry::init()) +- [x] Removed tauri-plugin-log from Cargo.toml dependency + +#### E2E Coverage Expansion (25 tests, single spec file) +- [x] Consolidated 4 spec files into single bterminal.test.ts — Tauri creates one app session per spec file; after first spec completes, app closes and subsequent specs get "invalid session id" +- [x] Added Workspace & Projects tests (8): project grid, project boxes, header with name, 3 project tabs, active highlight, tab switching, status bar counts +- [x] Added Settings Panel tests (6): settings tab, sections, theme dropdown, dropdown open+options, group list, close button +- [x] Added Keyboard Shortcuts tests (5): Ctrl+K command palette, Ctrl+, settings, Ctrl+B sidebar, Escape close, palette group list +- [x] Fixed WebDriver clicks on Svelte 5 components: `element.click()` doesn't reliably trigger onclick inside complex components via WebKit2GTK/tauri-driver — use `browser.execute()` for JS-level clicks +- [x] Fixed CSS text-transform: `.ptab` getText() returns uppercase — use `.toLowerCase()` for comparison +- [x] Fixed element scoping: `browser.$('.ptab')` returns ALL tabs across project boxes — scope via `box.$('.ptab')` +- [x] Fixed keyboard focus: `browser.execute(() => document.body.focus())` before sending shortcuts +- [x] Removed old individual spec files (smoke.test.ts, keyboard.test.ts, settings.test.ts, workspace.test.ts) +- [x] All 25 E2E tests pass (9s runtime after build) + +### Session: 2026-03-10 — Tab System Overhaul + +#### Tab Renames + New Tabs +- [x] Renamed Claude → Model, Files → Docs in ProjectBox +- [x] Added 3 new tabs: Files (directory browser), SSH (connection manager), Memory (knowledge explorer) +- [x] Implemented PERSISTED-EAGER (Model/Docs/Context — display:flex/none) vs PERSISTED-LAZY (Files/SSH/Memory — {#if everActivated} + display:flex/none) mount strategy +- [x] Tab type union: 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' + +#### Files Tab (FilesTab.svelte) +- [x] VSCode-style tree sidebar (14rem) + content viewer +- [x] Rust list_directory_children command: lazy expansion, hidden files skipped, dirs-first sort +- [x] Rust read_file_content command: FileContent tagged union (Text/Binary/TooLarge), 10MB gate, 30+ language mappings +- [x] Frontend files-bridge.ts adapter (DirEntry, FileContent types) +- [x] Shiki syntax highlighting for code files, image display via convertFileSrc, emoji file icons + +#### SSH Tab (SshTab.svelte) +- [x] CRUD panel for SSH connections using existing ssh-bridge.ts/SshSession model +- [x] Launch button spawns terminal tab in Model tab's TerminalTabs section via addTerminalTab() + +#### Memory Tab (MemoriesTab.svelte) +- [x] Pluggable MemoryAdapter interface (memory-adapter.ts): name, available, list(), search(), get() +- [x] Adapter registry: registerMemoryAdapter(), getDefaultAdapter(), getAvailableAdapters() +- [x] UI: search bar, tag display, expandable cards, adapter switcher, placeholder when no adapter + +#### Context Tab Repurpose (ContextTab.svelte) +- [x] Replaced ContextPane (ctx database viewer) with LLM context window visualization +- [x] Tribunal debate for design (S-1-R4 winner at 82% confidence) +- [x] Stats bar: input/output tokens, cost, turns, duration +- [x] Segmented token meter: CSS flex bar with color-coded categories (assistant/thinking/tool calls/tool results) +- [x] File references: extracted from tool_call messages, colored op badges +- [x] Turn breakdown: collapsible message groups by user prompt +- [x] Token estimation via ~4 chars/token heuristic +- [x] Wired into ProjectBox (replaces ContextPane, passes sessionId) +- [x] Sub-tab navigation: Overview | AST | Graph +- [x] AST tab: per-turn SVG conversation trees (Thinking/Response/ToolCall/File nodes, bezier edges, token counts) +- [x] Graph tab: bipartite tool→file DAG (tools left, files right, curved edges, count badges) +- [x] Compaction detection: sdk-messages.ts adapts `compact_boundary` system messages → `CompactionContent` type +- [x] Stats bar compaction pill: yellow count badge with tooltip (last trigger, tokens removed) +- [x] AST compaction boundaries: red "Compacted" nodes inserted between turns at compaction points + +#### FilesTab Fixes & CodeMirror Editor +- [x] Fixed HTML nesting error: ` + +
+ {#if activeTab === 'comms'} + + {:else} + + {/if} +
+ + {/if} + +
+ + +
+ + + + + + paletteOpen = false} /> +{:else} +
Loading workspace...
+{/if} + + + diff --git a/v2/src/app.css b/v2/src/app.css new file mode 100644 index 0000000..c0bc9aa --- /dev/null +++ b/v2/src/app.css @@ -0,0 +1,44 @@ +@import './lib/styles/catppuccin.css'; + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--ui-font-family); + font-size: var(--ui-font-size); + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} + +#app { + height: 100%; + width: 100%; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--ctp-surface2); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--ctp-overlay0); +} diff --git a/v2/src/lib/adapters/agent-bridge.test.ts b/v2/src/lib/adapters/agent-bridge.test.ts new file mode 100644 index 0000000..e9a2b8e --- /dev/null +++ b/v2/src/lib/adapters/agent-bridge.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Use vi.hoisted to declare mocks that are accessible inside vi.mock factories +const { mockInvoke, mockListen } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockListen: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: mockListen, +})); + +import { + queryAgent, + stopAgent, + isAgentReady, + restartAgent, + onSidecarMessage, + onSidecarExited, + type AgentQueryOptions, +} from './agent-bridge'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('agent-bridge', () => { + describe('queryAgent', () => { + it('invokes agent_query with options', async () => { + mockInvoke.mockResolvedValue(undefined); + + const options: AgentQueryOptions = { + session_id: 'sess-1', + prompt: 'Hello Claude', + cwd: '/tmp', + max_turns: 10, + max_budget_usd: 1.0, + }; + + await queryAgent(options); + + expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); + }); + + it('passes minimal options (only required fields)', async () => { + mockInvoke.mockResolvedValue(undefined); + + const options: AgentQueryOptions = { + session_id: 'sess-2', + prompt: 'Do something', + }; + + await queryAgent(options); + + expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); + }); + + it('propagates invoke errors', async () => { + mockInvoke.mockRejectedValue(new Error('Sidecar not running')); + + await expect( + queryAgent({ session_id: 'sess-3', prompt: 'test' }), + ).rejects.toThrow('Sidecar not running'); + }); + }); + + describe('stopAgent', () => { + it('invokes agent_stop with session ID', async () => { + mockInvoke.mockResolvedValue(undefined); + + await stopAgent('sess-1'); + + expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 'sess-1' }); + }); + }); + + describe('isAgentReady', () => { + it('returns true when sidecar is ready', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await isAgentReady(); + + expect(result).toBe(true); + expect(mockInvoke).toHaveBeenCalledWith('agent_ready'); + }); + + it('returns false when sidecar is not ready', async () => { + mockInvoke.mockResolvedValue(false); + + const result = await isAgentReady(); + + expect(result).toBe(false); + }); + }); + + describe('restartAgent', () => { + it('invokes agent_restart', async () => { + mockInvoke.mockResolvedValue(undefined); + + await restartAgent(); + + expect(mockInvoke).toHaveBeenCalledWith('agent_restart'); + }); + }); + + describe('onSidecarMessage', () => { + it('registers listener on sidecar-message event', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const callback = vi.fn(); + const result = await onSidecarMessage(callback); + + expect(mockListen).toHaveBeenCalledWith('sidecar-message', expect.any(Function)); + expect(result).toBe(unlisten); + }); + + it('extracts payload and passes to callback', async () => { + mockListen.mockImplementation(async (_event: string, handler: (e: unknown) => void) => { + // Simulate Tauri event delivery + handler({ + payload: { + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }, + }); + return vi.fn(); + }); + + const callback = vi.fn(); + await onSidecarMessage(callback); + + expect(callback).toHaveBeenCalledWith({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }); + }); + }); + + describe('onSidecarExited', () => { + it('registers listener on sidecar-exited event', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const callback = vi.fn(); + const result = await onSidecarExited(callback); + + expect(mockListen).toHaveBeenCalledWith('sidecar-exited', expect.any(Function)); + expect(result).toBe(unlisten); + }); + + it('invokes callback without arguments on exit', async () => { + mockListen.mockImplementation(async (_event: string, handler: () => void) => { + handler(); + return vi.fn(); + }); + + const callback = vi.fn(); + await onSidecarExited(callback); + + expect(callback).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts new file mode 100644 index 0000000..1277f9d --- /dev/null +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -0,0 +1,77 @@ +// Agent Bridge — Tauri IPC adapter for sidecar communication +// Mirrors pty-bridge.ts pattern: invoke for commands, listen for events + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import type { ProviderId } from '../providers/types'; + +export interface AgentQueryOptions { + provider?: ProviderId; + session_id: string; + prompt: string; + cwd?: string; + max_turns?: number; + max_budget_usd?: number; + resume_session_id?: string; + permission_mode?: string; + setting_sources?: string[]; + system_prompt?: string; + model?: string; + claude_config_dir?: string; + additional_directories?: string[]; + /** When set, agent runs in a git worktree for isolation */ + worktree_name?: string; + provider_config?: Record; + /** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */ + extra_env?: Record; + remote_machine_id?: string; +} + +export async function queryAgent(options: AgentQueryOptions): Promise { + if (options.remote_machine_id) { + const { remote_machine_id: machineId, ...agentOptions } = options; + return invoke('remote_agent_query', { machineId, options: agentOptions }); + } + return invoke('agent_query', { options }); +} + +export async function stopAgent(sessionId: string, remoteMachineId?: string): Promise { + if (remoteMachineId) { + return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId }); + } + return invoke('agent_stop', { sessionId }); +} + +export async function isAgentReady(): Promise { + return invoke('agent_ready'); +} + +export async function restartAgent(): Promise { + return invoke('agent_restart'); +} + +export interface SidecarMessage { + type: string; + sessionId?: string; + event?: Record; + message?: string; + exitCode?: number | null; + signal?: string | null; +} + +export async function onSidecarMessage( + callback: (msg: SidecarMessage) => void, +): Promise { + return listen('sidecar-message', (event) => { + const payload = event.payload; + if (typeof payload !== 'object' || payload === null) return; + callback(payload as SidecarMessage); + }); +} + +export async function onSidecarExited(callback: () => void): Promise { + return listen('sidecar-exited', () => { + callback(); + }); +} diff --git a/v2/src/lib/adapters/anchors-bridge.ts b/v2/src/lib/adapters/anchors-bridge.ts new file mode 100644 index 0000000..abc51ca --- /dev/null +++ b/v2/src/lib/adapters/anchors-bridge.ts @@ -0,0 +1,25 @@ +// Anchors Bridge — Tauri IPC adapter for session anchor CRUD +// Mirrors groups-bridge.ts pattern + +import { invoke } from '@tauri-apps/api/core'; +import type { SessionAnchorRecord } from '../types/anchors'; + +export async function saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise { + return invoke('session_anchors_save', { anchors }); +} + +export async function loadSessionAnchors(projectId: string): Promise { + return invoke('session_anchors_load', { projectId }); +} + +export async function deleteSessionAnchor(id: string): Promise { + return invoke('session_anchor_delete', { id }); +} + +export async function clearProjectAnchors(projectId: string): Promise { + return invoke('session_anchors_clear', { projectId }); +} + +export async function updateAnchorType(id: string, anchorType: string): Promise { + return invoke('session_anchor_update_type', { id, anchorType }); +} diff --git a/v2/src/lib/adapters/btmsg-bridge.ts b/v2/src/lib/adapters/btmsg-bridge.ts new file mode 100644 index 0000000..2060a6b --- /dev/null +++ b/v2/src/lib/adapters/btmsg-bridge.ts @@ -0,0 +1,160 @@ +/** + * btmsg bridge — reads btmsg SQLite database for agent notifications. + * Used by GroupAgentsPanel to show unread counts and agent statuses. + * Polls the database periodically for new messages. + */ + +import { invoke } from '@tauri-apps/api/core'; + +export interface BtmsgAgent { + id: string; + name: string; + role: string; + group_id: string; + tier: number; + model: string | null; + status: string; + unread_count: number; +} + +export interface BtmsgMessage { + id: string; + from_agent: string; + to_agent: string; + content: string; + read: boolean; + reply_to: string | null; + created_at: string; + sender_name?: string; + sender_role?: string; +} + +export interface BtmsgFeedMessage { + id: string; + fromAgent: string; + toAgent: string; + content: string; + createdAt: string; + replyTo: string | null; + senderName: string; + senderRole: string; + recipientName: string; + recipientRole: string; +} + +export interface BtmsgChannel { + id: string; + name: string; + groupId: string; + createdBy: string; + memberCount: number; + createdAt: string; +} + +export interface BtmsgChannelMessage { + id: string; + channelId: string; + fromAgent: string; + content: string; + createdAt: string; + senderName: string; + senderRole: string; +} + +/** + * Get all agents in a group with their unread counts. + */ +export async function getGroupAgents(groupId: string): Promise { + return invoke('btmsg_get_agents', { groupId }); +} + +/** + * Get unread message count for an agent. + */ +export async function getUnreadCount(agentId: string): Promise { + return invoke('btmsg_unread_count', { agentId }); +} + +/** + * Get unread messages for an agent. + */ +export async function getUnreadMessages(agentId: string): Promise { + return invoke('btmsg_unread_messages', { agentId }); +} + +/** + * Get conversation history between two agents. + */ +export async function getHistory(agentId: string, otherId: string, limit: number = 20): Promise { + return invoke('btmsg_history', { agentId, otherId, limit }); +} + +/** + * Send a message from one agent to another. + */ +export async function sendMessage(fromAgent: string, toAgent: string, content: string): Promise { + return invoke('btmsg_send', { fromAgent, toAgent, content }); +} + +/** + * Update agent status (active/sleeping/stopped). + */ +export async function setAgentStatus(agentId: string, status: string): Promise { + return invoke('btmsg_set_status', { agentId, status }); +} + +/** + * Ensure admin agent exists with contacts to all agents. + */ +export async function ensureAdmin(groupId: string): Promise { + return invoke('btmsg_ensure_admin', { groupId }); +} + +/** + * Get all messages in group (admin global feed). + */ +export async function getAllFeed(groupId: string, limit: number = 100): Promise { + return invoke('btmsg_all_feed', { groupId, limit }); +} + +/** + * Mark all messages from sender to reader as read. + */ +export async function markRead(readerId: string, senderId: string): Promise { + return invoke('btmsg_mark_read', { readerId, senderId }); +} + +/** + * Get channels in a group. + */ +export async function getChannels(groupId: string): Promise { + return invoke('btmsg_get_channels', { groupId }); +} + +/** + * Get messages in a channel. + */ +export async function getChannelMessages(channelId: string, limit: number = 100): Promise { + return invoke('btmsg_channel_messages', { channelId, limit }); +} + +/** + * Send a message to a channel. + */ +export async function sendChannelMessage(channelId: string, fromAgent: string, content: string): Promise { + return invoke('btmsg_channel_send', { channelId, fromAgent, content }); +} + +/** + * Create a new channel. + */ +export async function createChannel(name: string, groupId: string, createdBy: string): Promise { + return invoke('btmsg_create_channel', { name, groupId, createdBy }); +} + +/** + * Add a member to a channel. + */ +export async function addChannelMember(channelId: string, agentId: string): Promise { + return invoke('btmsg_add_channel_member', { channelId, agentId }); +} diff --git a/v2/src/lib/adapters/bttask-bridge.ts b/v2/src/lib/adapters/bttask-bridge.ts new file mode 100644 index 0000000..7146b8f --- /dev/null +++ b/v2/src/lib/adapters/bttask-bridge.ts @@ -0,0 +1,57 @@ +// bttask Bridge — Tauri IPC adapter for task board + +import { invoke } from '@tauri-apps/api/core'; + +export interface Task { + id: string; + title: string; + description: string; + status: 'todo' | 'progress' | 'review' | 'done' | 'blocked'; + priority: 'low' | 'medium' | 'high' | 'critical'; + assignedTo: string | null; + createdBy: string; + groupId: string; + parentTaskId: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface TaskComment { + id: string; + taskId: string; + agentId: string; + content: string; + createdAt: string; +} + +export async function listTasks(groupId: string): Promise { + return invoke('bttask_list', { groupId }); +} + +export async function getTaskComments(taskId: string): Promise { + return invoke('bttask_comments', { taskId }); +} + +export async function updateTaskStatus(taskId: string, status: string): Promise { + return invoke('bttask_update_status', { taskId, status }); +} + +export async function addTaskComment(taskId: string, agentId: string, content: string): Promise { + return invoke('bttask_add_comment', { taskId, agentId, content }); +} + +export async function createTask( + title: string, + description: string, + priority: string, + groupId: string, + createdBy: string, + assignedTo?: string, +): Promise { + return invoke('bttask_create', { title, description, priority, groupId, createdBy, assignedTo }); +} + +export async function deleteTask(taskId: string): Promise { + return invoke('bttask_delete', { taskId }); +} diff --git a/v2/src/lib/adapters/claude-bridge.ts b/v2/src/lib/adapters/claude-bridge.ts new file mode 100644 index 0000000..a03c318 --- /dev/null +++ b/v2/src/lib/adapters/claude-bridge.ts @@ -0,0 +1,28 @@ +// Claude Bridge — Tauri IPC adapter for Claude profiles and skills +import { invoke } from '@tauri-apps/api/core'; + +export interface ClaudeProfile { + name: string; + email: string | null; + subscription_type: string | null; + display_name: string | null; + config_dir: string; +} + +export interface ClaudeSkill { + name: string; + description: string; + source_path: string; +} + +export async function listProfiles(): Promise { + return invoke('claude_list_profiles'); +} + +export async function listSkills(): Promise { + return invoke('claude_list_skills'); +} + +export async function readSkill(path: string): Promise { + return invoke('claude_read_skill', { path }); +} diff --git a/v2/src/lib/adapters/claude-messages.test.ts b/v2/src/lib/adapters/claude-messages.test.ts new file mode 100644 index 0000000..752f0ad --- /dev/null +++ b/v2/src/lib/adapters/claude-messages.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { adaptSDKMessage } from './claude-messages'; +import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './claude-messages'; + +// Mock crypto.randomUUID for deterministic IDs when uuid is missing +beforeEach(() => { + vi.stubGlobal('crypto', { + randomUUID: () => 'fallback-uuid', + }); +}); + +describe('adaptSDKMessage', () => { + describe('system/init messages', () => { + it('adapts a system init message', () => { + const raw = { + type: 'system', + subtype: 'init', + uuid: 'sys-001', + session_id: 'sess-abc', + model: 'claude-sonnet-4-20250514', + cwd: '/home/user/project', + tools: ['Read', 'Write', 'Bash'], + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('sys-001'); + expect(result[0].type).toBe('init'); + const content = result[0].content as InitContent; + expect(content.sessionId).toBe('sess-abc'); + expect(content.model).toBe('claude-sonnet-4-20250514'); + expect(content.cwd).toBe('/home/user/project'); + expect(content.tools).toEqual(['Read', 'Write', 'Bash']); + }); + + it('defaults tools to empty array when missing', () => { + const raw = { + type: 'system', + subtype: 'init', + uuid: 'sys-002', + session_id: 'sess-abc', + model: 'claude-sonnet-4-20250514', + cwd: '/tmp', + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as InitContent; + expect(content.tools).toEqual([]); + }); + }); + + describe('system/status messages (non-init subtypes)', () => { + it('adapts a system status message', () => { + const raw = { + type: 'system', + subtype: 'api_key_check', + uuid: 'sys-003', + status: 'API key is valid', + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('status'); + const content = result[0].content as StatusContent; + expect(content.subtype).toBe('api_key_check'); + expect(content.message).toBe('API key is valid'); + }); + + it('handles missing status field', () => { + const raw = { + type: 'system', + subtype: 'some_event', + uuid: 'sys-004', + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as StatusContent; + expect(content.subtype).toBe('some_event'); + expect(content.message).toBeUndefined(); + }); + }); + + describe('assistant/text messages', () => { + it('adapts a single text block', () => { + const raw = { + type: 'assistant', + uuid: 'asst-001', + message: { + content: [{ type: 'text', text: 'Hello, world!' }], + }, + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + expect(result[0].id).toBe('asst-001-text-0'); + const content = result[0].content as TextContent; + expect(content.text).toBe('Hello, world!'); + }); + + it('preserves parentId on assistant messages', () => { + const raw = { + type: 'assistant', + uuid: 'asst-002', + parent_tool_use_id: 'tool-parent-123', + message: { + content: [{ type: 'text', text: 'subagent response' }], + }, + }; + + const result = adaptSDKMessage(raw); + expect(result[0].parentId).toBe('tool-parent-123'); + }); + }); + + describe('assistant/thinking messages', () => { + it('adapts a thinking block with thinking field', () => { + const raw = { + type: 'assistant', + uuid: 'asst-003', + message: { + content: [{ type: 'thinking', thinking: 'Let me consider...', text: 'fallback' }], + }, + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('thinking'); + expect(result[0].id).toBe('asst-003-think-0'); + const content = result[0].content as ThinkingContent; + expect(content.text).toBe('Let me consider...'); + }); + + it('falls back to text field when thinking is absent', () => { + const raw = { + type: 'assistant', + uuid: 'asst-004', + message: { + content: [{ type: 'thinking', text: 'Thinking via text field' }], + }, + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as ThinkingContent; + expect(content.text).toBe('Thinking via text field'); + }); + }); + + describe('assistant/tool_use messages', () => { + it('adapts a tool_use block', () => { + const raw = { + type: 'assistant', + uuid: 'asst-005', + message: { + content: [{ + type: 'tool_use', + id: 'toolu_abc123', + name: 'Read', + input: { file_path: '/src/main.ts' }, + }], + }, + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_call'); + expect(result[0].id).toBe('asst-005-tool-0'); + const content = result[0].content as ToolCallContent; + expect(content.toolUseId).toBe('toolu_abc123'); + expect(content.name).toBe('Read'); + expect(content.input).toEqual({ file_path: '/src/main.ts' }); + }); + }); + + describe('assistant messages with multiple content blocks', () => { + it('produces one AgentMessage per content block', () => { + const raw = { + type: 'assistant', + uuid: 'asst-multi', + message: { + content: [ + { type: 'thinking', thinking: 'Hmm...' }, + { type: 'text', text: 'Here is the answer.' }, + { type: 'tool_use', id: 'toolu_xyz', name: 'Bash', input: { command: 'ls' } }, + ], + }, + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('thinking'); + expect(result[0].id).toBe('asst-multi-think-0'); + expect(result[1].type).toBe('text'); + expect(result[1].id).toBe('asst-multi-text-1'); + expect(result[2].type).toBe('tool_call'); + expect(result[2].id).toBe('asst-multi-tool-2'); + }); + + it('skips unknown content block types silently', () => { + const raw = { + type: 'assistant', + uuid: 'asst-unk-block', + message: { + content: [ + { type: 'text', text: 'Hello' }, + { type: 'image', data: 'base64...' }, + ], + }, + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + }); + }); + + describe('user/tool_result messages', () => { + it('adapts a tool_result block', () => { + const raw = { + type: 'user', + uuid: 'user-001', + message: { + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_abc123', + content: 'file contents here', + }], + }, + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_result'); + expect(result[0].id).toBe('user-001-result-0'); + const content = result[0].content as ToolResultContent; + expect(content.toolUseId).toBe('toolu_abc123'); + expect(content.output).toBe('file contents here'); + }); + + it('falls back to tool_use_result when block content is missing', () => { + const raw = { + type: 'user', + uuid: 'user-002', + tool_use_result: { status: 'success', output: 'done' }, + message: { + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_def456', + // no content field + }], + }, + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as ToolResultContent; + expect(content.output).toEqual({ status: 'success', output: 'done' }); + }); + + it('preserves parentId on user messages', () => { + const raw = { + type: 'user', + uuid: 'user-003', + parent_tool_use_id: 'parent-tool-id', + message: { + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_ghi', + content: 'ok', + }], + }, + }; + + const result = adaptSDKMessage(raw); + expect(result[0].parentId).toBe('parent-tool-id'); + }); + }); + + describe('result/cost messages', () => { + it('adapts a full result message', () => { + const raw = { + type: 'result', + uuid: 'res-001', + total_cost_usd: 0.0125, + duration_ms: 4500, + usage: { input_tokens: 1000, output_tokens: 500 }, + num_turns: 3, + is_error: false, + result: 'Task completed successfully.', + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('cost'); + expect(result[0].id).toBe('res-001'); + const content = result[0].content as CostContent; + expect(content.totalCostUsd).toBe(0.0125); + expect(content.durationMs).toBe(4500); + expect(content.inputTokens).toBe(1000); + expect(content.outputTokens).toBe(500); + expect(content.numTurns).toBe(3); + expect(content.isError).toBe(false); + expect(content.result).toBe('Task completed successfully.'); + expect(content.errors).toBeUndefined(); + }); + + it('defaults numeric fields to 0 when missing', () => { + const raw = { + type: 'result', + uuid: 'res-002', + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as CostContent; + expect(content.totalCostUsd).toBe(0); + expect(content.durationMs).toBe(0); + expect(content.inputTokens).toBe(0); + expect(content.outputTokens).toBe(0); + expect(content.numTurns).toBe(0); + expect(content.isError).toBe(false); + }); + + it('includes errors array when present', () => { + const raw = { + type: 'result', + uuid: 'res-003', + is_error: true, + errors: ['Rate limit exceeded', 'Retry failed'], + }; + + const result = adaptSDKMessage(raw); + const content = result[0].content as CostContent; + expect(content.isError).toBe(true); + expect(content.errors).toEqual(['Rate limit exceeded', 'Retry failed']); + }); + }); + + describe('edge cases', () => { + it('returns unknown type for unrecognized message types', () => { + const raw = { + type: 'something_new', + uuid: 'unk-001', + data: 'arbitrary', + }; + + const result = adaptSDKMessage(raw); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('unknown'); + expect(result[0].id).toBe('unk-001'); + expect(result[0].content).toBe(raw); + }); + + it('uses crypto.randomUUID when uuid is missing', () => { + const raw = { + type: 'result', + total_cost_usd: 0.001, + }; + + const result = adaptSDKMessage(raw); + expect(result[0].id).toBe('fallback-uuid'); + }); + + it('returns empty array when assistant message has no message field', () => { + const raw = { + type: 'assistant', + uuid: 'asst-empty', + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(0); + }); + + it('returns empty array when assistant message.content is not an array', () => { + const raw = { + type: 'assistant', + uuid: 'asst-bad-content', + message: { content: 'not-an-array' }, + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(0); + }); + + it('returns empty array when user message has no message field', () => { + const raw = { + type: 'user', + uuid: 'user-empty', + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(0); + }); + + it('returns empty array when user message.content is not an array', () => { + const raw = { + type: 'user', + uuid: 'user-bad', + message: { content: 'string' }, + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(0); + }); + + it('ignores non-tool_result blocks in user messages', () => { + const raw = { + type: 'user', + uuid: 'user-text', + message: { + content: [ + { type: 'text', text: 'User typed something' }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }, + ], + }, + }; + + const result = adaptSDKMessage(raw); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_result'); + }); + + it('sets timestamp on every message', () => { + const before = Date.now(); + const result = adaptSDKMessage({ + type: 'system', + subtype: 'init', + uuid: 'ts-test', + session_id: 's', + model: 'm', + cwd: '/', + }); + const after = Date.now(); + + expect(result[0].timestamp).toBeGreaterThanOrEqual(before); + expect(result[0].timestamp).toBeLessThanOrEqual(after); + }); + }); +}); diff --git a/v2/src/lib/adapters/claude-messages.ts b/v2/src/lib/adapters/claude-messages.ts new file mode 100644 index 0000000..2d75d07 --- /dev/null +++ b/v2/src/lib/adapters/claude-messages.ts @@ -0,0 +1,257 @@ +// Claude Message Adapter — transforms Claude Agent SDK wire format to internal AgentMessage format +// This is the ONLY place that knows Claude SDK internals. + +export type AgentMessageType = + | 'init' + | 'text' + | 'thinking' + | 'tool_call' + | 'tool_result' + | 'status' + | 'compaction' + | 'cost' + | 'error' + | 'unknown'; + +export interface AgentMessage { + id: string; + type: AgentMessageType; + parentId?: string; + content: unknown; + timestamp: number; +} + +export interface InitContent { + sessionId: string; + model: string; + cwd: string; + tools: string[]; +} + +export interface TextContent { + text: string; +} + +export interface ThinkingContent { + text: string; +} + +export interface ToolCallContent { + toolUseId: string; + name: string; + input: unknown; +} + +export interface ToolResultContent { + toolUseId: string; + output: unknown; +} + +export interface StatusContent { + subtype: string; + message?: string; +} + +export interface CostContent { + totalCostUsd: number; + durationMs: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + isError: boolean; + result?: string; + errors?: string[]; +} + +export interface CompactionContent { + trigger: 'manual' | 'auto'; + preTokens: number; +} + +export interface ErrorContent { + message: string; +} + +import { str, num } from '../utils/type-guards'; + +/** + * Adapt a raw SDK stream-json message to our internal format. + * When SDK changes wire format, only this function needs updating. + */ +export function adaptSDKMessage(raw: Record): AgentMessage[] { + const uuid = str(raw.uuid) || crypto.randomUUID(); + const timestamp = Date.now(); + const parentId = typeof raw.parent_tool_use_id === 'string' ? raw.parent_tool_use_id : undefined; + + switch (raw.type) { + case 'system': + return adaptSystemMessage(raw, uuid, timestamp); + case 'assistant': + return adaptAssistantMessage(raw, uuid, timestamp, parentId); + case 'user': + return adaptUserMessage(raw, uuid, timestamp, parentId); + case 'result': + return adaptResultMessage(raw, uuid, timestamp); + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptSystemMessage( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const subtype = str(raw.subtype); + + if (subtype === 'init') { + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: Array.isArray(raw.tools) ? raw.tools.filter((t): t is string => typeof t === 'string') : [], + } satisfies InitContent, + timestamp, + }]; + } + + if (subtype === 'compact_boundary') { + const meta = typeof raw.compact_metadata === 'object' && raw.compact_metadata !== null + ? raw.compact_metadata as Record + : {}; + return [{ + id: uuid, + type: 'compaction', + content: { + trigger: str(meta.trigger, 'auto') as 'manual' | 'auto', + preTokens: num(meta.pre_tokens), + } satisfies CompactionContent, + timestamp, + }]; + } + + return [{ + id: uuid, + type: 'status', + content: { + subtype, + message: typeof raw.status === 'string' ? raw.status : undefined, + } satisfies StatusContent, + timestamp, + }]; +} + +function adaptAssistantMessage( + raw: Record, + uuid: string, + timestamp: number, + parentId?: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record : undefined; + if (!msg) return messages; + + const content = Array.isArray(msg.content) ? msg.content as Array> : undefined; + if (!content) return messages; + + for (const block of content) { + switch (block.type) { + case 'text': + messages.push({ + id: `${uuid}-text-${messages.length}`, + type: 'text', + parentId, + content: { text: str(block.text) } satisfies TextContent, + timestamp, + }); + break; + case 'thinking': + messages.push({ + id: `${uuid}-think-${messages.length}`, + type: 'thinking', + parentId, + content: { text: str(block.thinking ?? block.text) } satisfies ThinkingContent, + timestamp, + }); + break; + case 'tool_use': + messages.push({ + id: `${uuid}-tool-${messages.length}`, + type: 'tool_call', + parentId, + content: { + toolUseId: str(block.id), + name: str(block.name), + input: block.input, + } satisfies ToolCallContent, + timestamp, + }); + break; + } + } + + return messages; +} + +function adaptUserMessage( + raw: Record, + uuid: string, + timestamp: number, + parentId?: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record : undefined; + if (!msg) return messages; + + const content = Array.isArray(msg.content) ? msg.content as Array> : undefined; + if (!content) return messages; + + for (const block of content) { + if (block.type === 'tool_result') { + messages.push({ + id: `${uuid}-result-${messages.length}`, + type: 'tool_result', + parentId, + content: { + toolUseId: str(block.tool_use_id), + output: block.content ?? raw.tool_use_result, + } satisfies ToolResultContent, + timestamp, + }); + } + } + + return messages; +} + +function adaptResultMessage( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const usage = typeof raw.usage === 'object' && raw.usage !== null ? raw.usage as Record : undefined; + + return [{ + id: uuid, + type: 'cost', + content: { + totalCostUsd: num(raw.total_cost_usd), + durationMs: num(raw.duration_ms), + inputTokens: num(usage?.input_tokens), + outputTokens: num(usage?.output_tokens), + numTurns: num(raw.num_turns), + isError: raw.is_error === true, + result: typeof raw.result === 'string' ? raw.result : undefined, + errors: Array.isArray(raw.errors) ? raw.errors.filter((e): e is string => typeof e === 'string') : undefined, + } satisfies CostContent, + timestamp, + }]; +} diff --git a/v2/src/lib/adapters/codex-messages.test.ts b/v2/src/lib/adapters/codex-messages.test.ts new file mode 100644 index 0000000..e3ac559 --- /dev/null +++ b/v2/src/lib/adapters/codex-messages.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { adaptCodexMessage } from './codex-messages'; + +describe('adaptCodexMessage', () => { + describe('thread.started', () => { + it('maps to init message with thread_id as sessionId', () => { + const result = adaptCodexMessage({ + type: 'thread.started', + thread_id: 'thread-abc-123', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('init'); + expect((result[0].content as any).sessionId).toBe('thread-abc-123'); + }); + }); + + describe('turn.started', () => { + it('maps to status message', () => { + const result = adaptCodexMessage({ type: 'turn.started' }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('status'); + expect((result[0].content as any).subtype).toBe('turn_started'); + }); + }); + + describe('turn.completed', () => { + it('maps to cost message with token usage', () => { + const result = adaptCodexMessage({ + type: 'turn.completed', + usage: { input_tokens: 1000, output_tokens: 200, cached_input_tokens: 800 }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('cost'); + const content = result[0].content as any; + expect(content.inputTokens).toBe(1000); + expect(content.outputTokens).toBe(200); + expect(content.totalCostUsd).toBe(0); + }); + }); + + describe('turn.failed', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'turn.failed', + error: { message: 'Rate limit exceeded' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Rate limit exceeded'); + }); + }); + + describe('item.completed — agent_message', () => { + it('maps to text message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { id: 'item_3', type: 'agent_message', text: 'Done. I updated foo.ts.' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + expect((result[0].content as any).text).toBe('Done. I updated foo.ts.'); + }); + + it('ignores item.started for agent_message', () => { + const result = adaptCodexMessage({ + type: 'item.started', + item: { type: 'agent_message', text: '' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — reasoning', () => { + it('maps to thinking message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'reasoning', text: 'Let me think about this...' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('thinking'); + expect((result[0].content as any).text).toBe('Let me think about this...'); + }); + }); + + describe('item — command_execution', () => { + it('maps item.started to tool_call', () => { + const result = adaptCodexMessage({ + type: 'item.started', + item: { id: 'item_1', type: 'command_execution', command: 'ls -la', status: 'in_progress' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('Bash'); + expect((result[0].content as any).input.command).toBe('ls -la'); + }); + + it('maps item.completed to tool_call + tool_result pair', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + command: 'ls -la', + aggregated_output: 'total 48\ndrwxr-xr-x', + exit_code: 0, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect(result[0].type).toBe('tool_call'); + expect(result[1].type).toBe('tool_result'); + expect((result[1].content as any).output).toBe('total 48\ndrwxr-xr-x'); + }); + + it('ignores item.updated for command_execution', () => { + const result = adaptCodexMessage({ + type: 'item.updated', + item: { type: 'command_execution', command: 'ls', status: 'in_progress' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — file_change', () => { + it('maps file changes to tool_call + tool_result pairs', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + type: 'file_change', + changes: [ + { path: 'src/foo.ts', kind: 'update' }, + { path: 'src/bar.ts', kind: 'add' }, + ], + status: 'completed', + }, + }); + expect(result).toHaveLength(4); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('Edit'); + expect(result[1].type).toBe('tool_result'); + expect(result[2].type).toBe('tool_call'); + expect((result[2].content as any).name).toBe('Write'); + }); + + it('maps delete to Bash tool name', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + type: 'file_change', + changes: [{ path: 'old.ts', kind: 'delete' }], + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[0].content as any).name).toBe('Bash'); + }); + + it('returns empty for no changes', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'file_change', changes: [], status: 'completed' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — mcp_tool_call', () => { + it('maps to tool_call + tool_result with server:tool name', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'mcp_1', + type: 'mcp_tool_call', + server: 'filesystem', + tool: 'read_file', + arguments: { path: '/tmp/test.txt' }, + result: { content: 'file contents' }, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[0].content as any).name).toBe('filesystem:read_file'); + expect((result[0].content as any).input.path).toBe('/tmp/test.txt'); + }); + + it('maps error result to error message in tool_result', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'mcp_2', + type: 'mcp_tool_call', + server: 'fs', + tool: 'write', + arguments: {}, + error: { message: 'Permission denied' }, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[1].content as any).output).toBe('Permission denied'); + }); + }); + + describe('item.completed — web_search', () => { + it('maps to WebSearch tool_call', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { id: 'ws_1', type: 'web_search', query: 'ollama api docs' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('WebSearch'); + expect((result[0].content as any).input.query).toBe('ollama api docs'); + }); + }); + + describe('item — error', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'error', message: 'Sandbox violation' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Sandbox violation'); + }); + }); + + describe('top-level error', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'error', + message: 'Connection lost', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Connection lost'); + }); + }); + + describe('unknown event type', () => { + it('maps to unknown message preserving raw data', () => { + const result = adaptCodexMessage({ type: 'custom.event', data: 42 }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('unknown'); + expect((result[0].content as any).data).toBe(42); + }); + }); +}); diff --git a/v2/src/lib/adapters/codex-messages.ts b/v2/src/lib/adapters/codex-messages.ts new file mode 100644 index 0000000..9290335 --- /dev/null +++ b/v2/src/lib/adapters/codex-messages.ts @@ -0,0 +1,291 @@ +// Codex Message Adapter — transforms Codex CLI NDJSON events to internal AgentMessage format +// Codex events: thread.started, turn.started, item.started/updated/completed, turn.completed/failed + +import type { + AgentMessage, + InitContent, + TextContent, + ThinkingContent, + ToolCallContent, + ToolResultContent, + StatusContent, + CostContent, + ErrorContent, +} from './claude-messages'; + +import { str, num } from '../utils/type-guards'; + +export function adaptCodexMessage(raw: Record): AgentMessage[] { + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case 'thread.started': + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.thread_id), + model: '', + cwd: '', + tools: [], + } satisfies InitContent, + timestamp, + }]; + + case 'turn.started': + return [{ + id: uuid, + type: 'status', + content: { subtype: 'turn_started' } satisfies StatusContent, + timestamp, + }]; + + case 'turn.completed': + return adaptTurnCompleted(raw, uuid, timestamp); + + case 'turn.failed': + return [{ + id: uuid, + type: 'error', + content: { + message: str((raw.error as Record)?.message, 'Turn failed'), + } satisfies ErrorContent, + timestamp, + }]; + + case 'item.started': + case 'item.updated': + case 'item.completed': + return adaptItem(raw, uuid, timestamp); + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(raw.message, 'Unknown error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptTurnCompleted( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const usage = typeof raw.usage === 'object' && raw.usage !== null + ? raw.usage as Record + : {}; + + return [{ + id: uuid, + type: 'cost', + content: { + totalCostUsd: 0, + durationMs: 0, + inputTokens: num(usage.input_tokens), + outputTokens: num(usage.output_tokens), + numTurns: 1, + isError: false, + } satisfies CostContent, + timestamp, + }]; +} + +function adaptItem( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const item = typeof raw.item === 'object' && raw.item !== null + ? raw.item as Record + : {}; + const itemType = str(item.type); + const eventType = str(raw.type); + + switch (itemType) { + case 'agent_message': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'text', + content: { text: str(item.text) } satisfies TextContent, + timestamp, + }]; + + case 'reasoning': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'thinking', + content: { text: str(item.text) } satisfies ThinkingContent, + timestamp, + }]; + + case 'command_execution': + return adaptCommandExecution(item, uuid, timestamp, eventType); + + case 'file_change': + return adaptFileChange(item, uuid, timestamp, eventType); + + case 'mcp_tool_call': + return adaptMcpToolCall(item, uuid, timestamp, eventType); + + case 'web_search': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'tool_call', + content: { + toolUseId: str(item.id, uuid), + name: 'WebSearch', + input: { query: str(item.query) }, + } satisfies ToolCallContent, + timestamp, + }]; + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(item.message, 'Item error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return []; + } +} + +function adaptCommandExecution( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const toolUseId = str(item.id, uuid); + + if (eventType === 'item.started' || eventType === 'item.completed') { + messages.push({ + id: `${uuid}-call`, + type: 'tool_call', + content: { + toolUseId, + name: 'Bash', + input: { command: str(item.command) }, + } satisfies ToolCallContent, + timestamp, + }); + } + + if (eventType === 'item.completed') { + messages.push({ + id: `${uuid}-result`, + type: 'tool_result', + content: { + toolUseId, + output: str(item.aggregated_output), + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} + +function adaptFileChange( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + if (eventType !== 'item.completed') return []; + + const changes = Array.isArray(item.changes) ? item.changes as Array> : []; + if (changes.length === 0) return []; + + const messages: AgentMessage[] = []; + for (const change of changes) { + const kind = str(change.kind); + const toolName = kind === 'delete' ? 'Bash' : kind === 'add' ? 'Write' : 'Edit'; + const toolUseId = `${uuid}-${str(change.path)}`; + + messages.push({ + id: `${toolUseId}-call`, + type: 'tool_call', + content: { + toolUseId, + name: toolName, + input: { file_path: str(change.path) }, + } satisfies ToolCallContent, + timestamp, + }); + + messages.push({ + id: `${toolUseId}-result`, + type: 'tool_result', + content: { + toolUseId, + output: `File ${kind}: ${str(change.path)}`, + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} + +function adaptMcpToolCall( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const toolUseId = str(item.id, uuid); + const toolName = `${str(item.server)}:${str(item.tool)}`; + + if (eventType === 'item.started' || eventType === 'item.completed') { + messages.push({ + id: `${uuid}-call`, + type: 'tool_call', + content: { + toolUseId, + name: toolName, + input: item.arguments, + } satisfies ToolCallContent, + timestamp, + }); + } + + if (eventType === 'item.completed') { + const result = typeof item.result === 'object' && item.result !== null + ? item.result as Record + : undefined; + const error = typeof item.error === 'object' && item.error !== null + ? item.error as Record + : undefined; + + messages.push({ + id: `${uuid}-result`, + type: 'tool_result', + content: { + toolUseId, + output: error ? str(error.message, 'MCP tool error') : (result?.content ?? result?.structured_content ?? 'OK'), + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} diff --git a/v2/src/lib/adapters/ctx-bridge.ts b/v2/src/lib/adapters/ctx-bridge.ts new file mode 100644 index 0000000..4956282 --- /dev/null +++ b/v2/src/lib/adapters/ctx-bridge.ts @@ -0,0 +1,38 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface CtxEntry { + project: string; + key: string; + value: string; + updated_at: string; +} + +export interface CtxSummary { + project: string; + summary: string; + created_at: string; +} + +export async function ctxInitDb(): Promise { + return invoke('ctx_init_db'); +} + +export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise { + return invoke('ctx_register_project', { name, description, workDir: workDir ?? null }); +} + +export async function ctxGetContext(project: string): Promise { + return invoke('ctx_get_context', { project }); +} + +export async function ctxGetShared(): Promise { + return invoke('ctx_get_shared'); +} + +export async function ctxGetSummaries(project: string, limit: number = 5): Promise { + return invoke('ctx_get_summaries', { project, limit }); +} + +export async function ctxSearch(query: string): Promise { + return invoke('ctx_search', { query }); +} diff --git a/v2/src/lib/adapters/file-bridge.ts b/v2/src/lib/adapters/file-bridge.ts new file mode 100644 index 0000000..7937488 --- /dev/null +++ b/v2/src/lib/adapters/file-bridge.ts @@ -0,0 +1,29 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface FileChangedPayload { + pane_id: string; + path: string; + content: string; +} + +/** Start watching a file; returns initial content */ +export async function watchFile(paneId: string, path: string): Promise { + return invoke('file_watch', { paneId, path }); +} + +export async function unwatchFile(paneId: string): Promise { + return invoke('file_unwatch', { paneId }); +} + +export async function readFile(path: string): Promise { + return invoke('file_read', { path }); +} + +export async function onFileChanged( + callback: (payload: FileChangedPayload) => void +): Promise { + return listen('file-changed', (event) => { + callback(event.payload); + }); +} diff --git a/v2/src/lib/adapters/files-bridge.ts b/v2/src/lib/adapters/files-bridge.ts new file mode 100644 index 0000000..a46f4f2 --- /dev/null +++ b/v2/src/lib/adapters/files-bridge.ts @@ -0,0 +1,26 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface DirEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + ext: string; +} + +export type FileContent = + | { type: 'Text'; content: string; lang: string } + | { type: 'Binary'; message: string } + | { type: 'TooLarge'; size: number }; + +export function listDirectoryChildren(path: string): Promise { + return invoke('list_directory_children', { path }); +} + +export function readFileContent(path: string): Promise { + return invoke('read_file_content', { path }); +} + +export function writeFileContent(path: string, content: string): Promise { + return invoke('write_file_content', { path, content }); +} diff --git a/v2/src/lib/adapters/fs-watcher-bridge.ts b/v2/src/lib/adapters/fs-watcher-bridge.ts new file mode 100644 index 0000000..17d4e6b --- /dev/null +++ b/v2/src/lib/adapters/fs-watcher-bridge.ts @@ -0,0 +1,41 @@ +// Filesystem watcher bridge — listens for inotify-based write events from Rust +// Part of S-1 Phase 2: real-time filesystem write detection + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface FsWriteEvent { + project_id: string; + file_path: string; + timestamp_ms: number; +} + +/** Start watching a project's CWD for filesystem writes */ +export function fsWatchProject(projectId: string, cwd: string): Promise { + return invoke('fs_watch_project', { projectId, cwd }); +} + +/** Stop watching a project's CWD */ +export function fsUnwatchProject(projectId: string): Promise { + return invoke('fs_unwatch_project', { projectId }); +} + +/** Listen for filesystem write events from all watched projects */ +export function onFsWriteDetected( + callback: (event: FsWriteEvent) => void, +): Promise { + return listen('fs-write-detected', (e) => callback(e.payload)); +} + +export interface FsWatcherStatus { + max_watches: number; + estimated_watches: number; + usage_ratio: number; + active_projects: number; + warning: string | null; +} + +/** Get inotify watcher status including kernel limit check */ +export function fsWatcherStatus(): Promise { + return invoke('fs_watcher_status'); +} diff --git a/v2/src/lib/adapters/groups-bridge.ts b/v2/src/lib/adapters/groups-bridge.ts new file mode 100644 index 0000000..1782563 --- /dev/null +++ b/v2/src/lib/adapters/groups-bridge.ts @@ -0,0 +1,110 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups'; + +export type { GroupsFile, ProjectConfig, GroupConfig }; + +export interface MdFileEntry { + name: string; + path: string; + priority: boolean; +} + +export interface AgentMessageRecord { + id: number; + session_id: string; + project_id: string; + sdk_session_id: string | null; + message_type: string; + content: string; + parent_id: string | null; + created_at: number; +} + +export interface ProjectAgentState { + project_id: string; + last_session_id: string; + sdk_session_id: string | null; + status: string; + cost_usd: number; + input_tokens: number; + output_tokens: number; + last_prompt: string | null; + updated_at: number; +} + +// --- Group config --- + +export async function loadGroups(): Promise { + return invoke('groups_load'); +} + +export async function saveGroups(config: GroupsFile): Promise { + return invoke('groups_save', { config }); +} + +// --- Markdown discovery --- + +export async function discoverMarkdownFiles(cwd: string): Promise { + return invoke('discover_markdown_files', { cwd }); +} + +// --- Agent message persistence --- + +export async function saveAgentMessages( + sessionId: string, + projectId: string, + sdkSessionId: string | undefined, + messages: AgentMessageRecord[], +): Promise { + return invoke('agent_messages_save', { + sessionId, + projectId, + sdkSessionId: sdkSessionId ?? null, + messages, + }); +} + +export async function loadAgentMessages(projectId: string): Promise { + return invoke('agent_messages_load', { projectId }); +} + +// --- Project agent state --- + +export async function saveProjectAgentState(state: ProjectAgentState): Promise { + return invoke('project_agent_state_save', { state }); +} + +export async function loadProjectAgentState(projectId: string): Promise { + return invoke('project_agent_state_load', { projectId }); +} + +// --- Session metrics --- + +export interface SessionMetric { + id: number; + project_id: string; + session_id: string; + start_time: number; + end_time: number; + peak_tokens: number; + turn_count: number; + tool_call_count: number; + cost_usd: number; + model: string | null; + status: string; + error_message: string | null; +} + +export async function saveSessionMetric(metric: Omit): Promise { + return invoke('session_metric_save', { metric: { id: 0, ...metric } }); +} + +export async function loadSessionMetrics(projectId: string, limit = 20): Promise { + return invoke('session_metrics_load', { projectId, limit }); +} + +// --- CLI arguments --- + +export async function getCliGroup(): Promise { + return invoke('cli_get_group'); +} diff --git a/v2/src/lib/adapters/memora-bridge.test.ts b/v2/src/lib/adapters/memora-bridge.test.ts new file mode 100644 index 0000000..206bcaf --- /dev/null +++ b/v2/src/lib/adapters/memora-bridge.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockInvoke } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +import { + memoraAvailable, + memoraList, + memoraSearch, + memoraGet, + MemoraAdapter, +} from './memora-bridge'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('memora IPC wrappers', () => { + it('memoraAvailable invokes memora_available', async () => { + mockInvoke.mockResolvedValue(true); + const result = await memoraAvailable(); + expect(result).toBe(true); + expect(mockInvoke).toHaveBeenCalledWith('memora_available'); + }); + + it('memoraList invokes memora_list with defaults', async () => { + mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); + await memoraList(); + expect(mockInvoke).toHaveBeenCalledWith('memora_list', { + tags: null, + limit: 50, + offset: 0, + }); + }); + + it('memoraList passes tags and pagination', async () => { + mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); + await memoraList({ tags: ['bterminal'], limit: 10, offset: 5 }); + expect(mockInvoke).toHaveBeenCalledWith('memora_list', { + tags: ['bterminal'], + limit: 10, + offset: 5, + }); + }); + + it('memoraSearch invokes memora_search', async () => { + mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); + await memoraSearch('test query', { tags: ['foo'], limit: 20 }); + expect(mockInvoke).toHaveBeenCalledWith('memora_search', { + query: 'test query', + tags: ['foo'], + limit: 20, + }); + }); + + it('memoraSearch uses defaults when no options', async () => { + mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); + await memoraSearch('hello'); + expect(mockInvoke).toHaveBeenCalledWith('memora_search', { + query: 'hello', + tags: null, + limit: 50, + }); + }); + + it('memoraGet invokes memora_get', async () => { + const node = { id: 42, content: 'test', tags: ['a'], metadata: null, created_at: null, updated_at: null }; + mockInvoke.mockResolvedValue(node); + const result = await memoraGet(42); + expect(result).toEqual(node); + expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 42 }); + }); + + it('memoraGet returns null for missing', async () => { + mockInvoke.mockResolvedValue(null); + const result = await memoraGet(999); + expect(result).toBeNull(); + }); +}); + +describe('MemoraAdapter', () => { + it('has name "memora"', () => { + const adapter = new MemoraAdapter(); + expect(adapter.name).toBe('memora'); + }); + + it('available is true by default (optimistic)', () => { + const adapter = new MemoraAdapter(); + expect(adapter.available).toBe(true); + }); + + it('checkAvailability updates available state', async () => { + mockInvoke.mockResolvedValue(false); + const adapter = new MemoraAdapter(); + const result = await adapter.checkAvailability(); + expect(result).toBe(false); + expect(adapter.available).toBe(false); + }); + + it('list returns mapped MemorySearchResult', async () => { + mockInvoke.mockResolvedValue({ + nodes: [ + { id: 1, content: 'hello', tags: ['a', 'b'], metadata: { key: 'val' }, created_at: '2026-01-01', updated_at: null }, + ], + total: 1, + }); + + const adapter = new MemoraAdapter(); + const result = await adapter.list({ limit: 10 }); + expect(result.total).toBe(1); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe(1); + expect(result.nodes[0].content).toBe('hello'); + expect(result.nodes[0].tags).toEqual(['a', 'b']); + expect(result.nodes[0].metadata).toEqual({ key: 'val' }); + }); + + it('search returns mapped results', async () => { + mockInvoke.mockResolvedValue({ + nodes: [{ id: 5, content: 'found', tags: ['x'], metadata: null, created_at: null, updated_at: null }], + total: 1, + }); + + const adapter = new MemoraAdapter(); + const result = await adapter.search('found', { limit: 5 }); + expect(result.nodes[0].content).toBe('found'); + expect(adapter.available).toBe(true); + }); + + it('get returns mapped node', async () => { + mockInvoke.mockResolvedValue({ + id: 10, content: 'node', tags: ['t'], metadata: null, created_at: '2026-01-01', updated_at: '2026-01-02', + }); + + const adapter = new MemoraAdapter(); + const node = await adapter.get(10); + expect(node).not.toBeNull(); + expect(node!.id).toBe(10); + expect(node!.updated_at).toBe('2026-01-02'); + }); + + it('get returns null for missing node', async () => { + mockInvoke.mockResolvedValue(null); + const adapter = new MemoraAdapter(); + const node = await adapter.get(999); + expect(node).toBeNull(); + }); + + it('get handles string id', async () => { + mockInvoke.mockResolvedValue({ + id: 7, content: 'x', tags: [], metadata: null, created_at: null, updated_at: null, + }); + + const adapter = new MemoraAdapter(); + const node = await adapter.get('7'); + expect(node).not.toBeNull(); + expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 7 }); + }); + + it('get returns null for non-numeric string id', async () => { + const adapter = new MemoraAdapter(); + const node = await adapter.get('abc'); + expect(node).toBeNull(); + expect(mockInvoke).not.toHaveBeenCalled(); + }); +}); diff --git a/v2/src/lib/adapters/memora-bridge.ts b/v2/src/lib/adapters/memora-bridge.ts new file mode 100644 index 0000000..c206c73 --- /dev/null +++ b/v2/src/lib/adapters/memora-bridge.ts @@ -0,0 +1,122 @@ +/** + * Memora IPC bridge — read-only access to the Memora memory database. + * Wraps Tauri commands and provides a MemoryAdapter implementation. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter'; + +// --- Raw IPC types (match Rust structs) --- + +interface MemoraNode { + id: number; + content: string; + tags: string[]; + metadata?: Record; + created_at?: string; + updated_at?: string; +} + +interface MemoraSearchResult { + nodes: MemoraNode[]; + total: number; +} + +// --- IPC wrappers --- + +export async function memoraAvailable(): Promise { + return invoke('memora_available'); +} + +export async function memoraList(options?: { + tags?: string[]; + limit?: number; + offset?: number; +}): Promise { + return invoke('memora_list', { + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + offset: options?.offset ?? 0, + }); +} + +export async function memoraSearch( + query: string, + options?: { tags?: string[]; limit?: number }, +): Promise { + return invoke('memora_search', { + query, + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + }); +} + +export async function memoraGet(id: number): Promise { + return invoke('memora_get', { id }); +} + +// --- MemoryAdapter implementation --- + +function toMemoryNode(n: MemoraNode): MemoryNode { + return { + id: n.id, + content: n.content, + tags: n.tags, + metadata: n.metadata, + created_at: n.created_at, + updated_at: n.updated_at, + }; +} + +function toSearchResult(r: MemoraSearchResult): MemorySearchResult { + return { + nodes: r.nodes.map(toMemoryNode), + total: r.total, + }; +} + +export class MemoraAdapter implements MemoryAdapter { + readonly name = 'memora'; + private _available: boolean | null = null; + + get available(): boolean { + // Optimistic: assume available until first check proves otherwise. + // Actual availability is checked lazily on first operation. + return this._available ?? true; + } + + async checkAvailability(): Promise { + this._available = await memoraAvailable(); + return this._available; + } + + async list(options?: { + tags?: string[]; + limit?: number; + offset?: number; + }): Promise { + const result = await memoraList(options); + this._available = true; + return toSearchResult(result); + } + + async search( + query: string, + options?: { tags?: string[]; limit?: number }, + ): Promise { + const result = await memoraSearch(query, options); + this._available = true; + return toSearchResult(result); + } + + async get(id: string | number): Promise { + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + if (isNaN(numId)) return null; + const node = await memoraGet(numId); + if (node) { + this._available = true; + return toMemoryNode(node); + } + return null; + } +} diff --git a/v2/src/lib/adapters/memory-adapter.ts b/v2/src/lib/adapters/memory-adapter.ts new file mode 100644 index 0000000..69d0e14 --- /dev/null +++ b/v2/src/lib/adapters/memory-adapter.ts @@ -0,0 +1,52 @@ +/** + * Pluggable memory adapter interface. + * Memora is the default implementation, but others can be swapped in. + */ + +export interface MemoryNode { + id: string | number; + content: string; + tags: string[]; + metadata?: Record; + created_at?: string; + updated_at?: string; +} + +export interface MemorySearchResult { + nodes: MemoryNode[]; + total: number; +} + +export interface MemoryAdapter { + readonly name: string; + readonly available: boolean; + + /** List memories, optionally filtered by tags */ + list(options?: { tags?: string[]; limit?: number; offset?: number }): Promise; + + /** Semantic search across memories */ + search(query: string, options?: { tags?: string[]; limit?: number }): Promise; + + /** Get a single memory by ID */ + get(id: string | number): Promise; +} + +/** Registry of available memory adapters */ +const adapters = new Map(); + +export function registerMemoryAdapter(adapter: MemoryAdapter): void { + adapters.set(adapter.name, adapter); +} + +export function getMemoryAdapter(name: string): MemoryAdapter | undefined { + return adapters.get(name); +} + +export function getAvailableAdapters(): MemoryAdapter[] { + return Array.from(adapters.values()).filter(a => a.available); +} + +export function getDefaultAdapter(): MemoryAdapter | undefined { + // Prefer Memora if available, otherwise first available + return adapters.get('memora') ?? getAvailableAdapters()[0]; +} diff --git a/v2/src/lib/adapters/message-adapters.ts b/v2/src/lib/adapters/message-adapters.ts new file mode 100644 index 0000000..b816b91 --- /dev/null +++ b/v2/src/lib/adapters/message-adapters.ts @@ -0,0 +1,33 @@ +// Message Adapter Registry — routes raw provider messages to the correct parser +// Each provider registers its own adapter; the dispatcher calls adaptMessage() + +import type { AgentMessage } from './claude-messages'; +import type { ProviderId } from '../providers/types'; +import { adaptSDKMessage } from './claude-messages'; +import { adaptCodexMessage } from './codex-messages'; +import { adaptOllamaMessage } from './ollama-messages'; + +/** Function signature for a provider message adapter */ +export type MessageAdapter = (raw: Record) => AgentMessage[]; + +const adapters = new Map(); + +/** Register a message adapter for a provider */ +export function registerMessageAdapter(providerId: ProviderId, adapter: MessageAdapter): void { + adapters.set(providerId, adapter); +} + +/** Adapt a raw message using the appropriate provider adapter */ +export function adaptMessage(providerId: ProviderId, raw: Record): AgentMessage[] { + const adapter = adapters.get(providerId); + if (!adapter) { + console.warn(`No message adapter for provider: ${providerId}, falling back to claude`); + return adaptSDKMessage(raw); + } + return adapter(raw); +} + +// Register all provider adapters +registerMessageAdapter('claude', adaptSDKMessage); +registerMessageAdapter('codex', adaptCodexMessage); +registerMessageAdapter('ollama', adaptOllamaMessage); diff --git a/v2/src/lib/adapters/ollama-messages.test.ts b/v2/src/lib/adapters/ollama-messages.test.ts new file mode 100644 index 0000000..65d4719 --- /dev/null +++ b/v2/src/lib/adapters/ollama-messages.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { adaptOllamaMessage } from './ollama-messages'; + +describe('adaptOllamaMessage', () => { + describe('system init', () => { + it('maps to init message', () => { + const result = adaptOllamaMessage({ + type: 'system', + subtype: 'init', + session_id: 'sess-123', + model: 'qwen3:8b', + cwd: '/home/user/project', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('init'); + const content = result[0].content as any; + expect(content.sessionId).toBe('sess-123'); + expect(content.model).toBe('qwen3:8b'); + expect(content.cwd).toBe('/home/user/project'); + }); + }); + + describe('system status', () => { + it('maps non-init subtypes to status message', () => { + const result = adaptOllamaMessage({ + type: 'system', + subtype: 'model_loaded', + status: 'Model loaded successfully', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('status'); + expect((result[0].content as any).subtype).toBe('model_loaded'); + expect((result[0].content as any).message).toBe('Model loaded successfully'); + }); + }); + + describe('chunk — text content', () => { + it('maps streaming text to text message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: 'Hello world' }, + done: false, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + expect((result[0].content as any).text).toBe('Hello world'); + }); + + it('ignores empty content', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: false, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('chunk — thinking content', () => { + it('maps thinking field to thinking message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '', thinking: 'Let me reason about this...' }, + done: false, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('thinking'); + expect((result[0].content as any).text).toBe('Let me reason about this...'); + }); + + it('emits both thinking and text when both present', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: 'Answer', thinking: 'Reasoning' }, + done: false, + }); + expect(result).toHaveLength(2); + expect(result[0].type).toBe('thinking'); + expect(result[1].type).toBe('text'); + }); + }); + + describe('chunk — done with token counts', () => { + it('maps final chunk to cost message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 500, + eval_count: 120, + eval_duration: 2_000_000_000, + total_duration: 3_000_000_000, + }); + // Should have cost message (no text since content is empty) + const costMsg = result.find(m => m.type === 'cost'); + expect(costMsg).toBeDefined(); + const content = costMsg!.content as any; + expect(content.inputTokens).toBe(500); + expect(content.outputTokens).toBe(120); + expect(content.durationMs).toBe(2000); + expect(content.totalCostUsd).toBe(0); + expect(content.isError).toBe(false); + }); + + it('marks error done_reason as isError', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: true, + done_reason: 'error', + prompt_eval_count: 0, + eval_count: 0, + }); + const costMsg = result.find(m => m.type === 'cost'); + expect(costMsg).toBeDefined(); + expect((costMsg!.content as any).isError).toBe(true); + }); + + it('includes text + cost when final chunk has content', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '.' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 10, + eval_count: 5, + }); + expect(result.some(m => m.type === 'text')).toBe(true); + expect(result.some(m => m.type === 'cost')).toBe(true); + }); + }); + + describe('error event', () => { + it('maps to error message', () => { + const result = adaptOllamaMessage({ + type: 'error', + message: 'model not found', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('model not found'); + }); + }); + + describe('unknown event type', () => { + it('maps to unknown message preserving raw data', () => { + const result = adaptOllamaMessage({ type: 'something_else', data: 'test' }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('unknown'); + }); + }); +}); diff --git a/v2/src/lib/adapters/ollama-messages.ts b/v2/src/lib/adapters/ollama-messages.ts new file mode 100644 index 0000000..f5e6e48 --- /dev/null +++ b/v2/src/lib/adapters/ollama-messages.ts @@ -0,0 +1,141 @@ +// Ollama Message Adapter — transforms Ollama chat streaming events to internal AgentMessage format +// Ollama runner emits synthesized events wrapping /api/chat NDJSON chunks + +import type { + AgentMessage, + InitContent, + TextContent, + ThinkingContent, + StatusContent, + CostContent, + ErrorContent, +} from './claude-messages'; + +import { str, num } from '../utils/type-guards'; + +/** + * Adapt a raw Ollama runner event to AgentMessage[]. + * + * The Ollama runner emits events in this format: + * - {type:'system', subtype:'init', model, ...} + * - {type:'chunk', message:{role,content,thinking}, done:false} + * - {type:'chunk', message:{role,content}, done:true, done_reason, prompt_eval_count, eval_count, ...} + * - {type:'error', message:'...'} + */ +export function adaptOllamaMessage(raw: Record): AgentMessage[] { + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case 'system': + return adaptSystemEvent(raw, uuid, timestamp); + + case 'chunk': + return adaptChunk(raw, uuid, timestamp); + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(raw.message, 'Ollama error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptSystemEvent( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const subtype = str(raw.subtype); + + if (subtype === 'init') { + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: [], + } satisfies InitContent, + timestamp, + }]; + } + + return [{ + id: uuid, + type: 'status', + content: { + subtype, + message: typeof raw.status === 'string' ? raw.status : undefined, + } satisfies StatusContent, + timestamp, + }]; +} + +function adaptChunk( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = typeof raw.message === 'object' && raw.message !== null + ? raw.message as Record + : {}; + const done = raw.done === true; + + // Thinking content (extended thinking from Qwen3 etc.) + const thinking = str(msg.thinking); + if (thinking) { + messages.push({ + id: `${uuid}-think`, + type: 'thinking', + content: { text: thinking } satisfies ThinkingContent, + timestamp, + }); + } + + // Text content + const text = str(msg.content); + if (text) { + messages.push({ + id: `${uuid}-text`, + type: 'text', + content: { text } satisfies TextContent, + timestamp, + }); + } + + // Final chunk with token counts + if (done) { + const doneReason = str(raw.done_reason); + const evalDuration = num(raw.eval_duration); + const durationMs = evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0; + + messages.push({ + id: `${uuid}-cost`, + type: 'cost', + content: { + totalCostUsd: 0, + durationMs, + inputTokens: num(raw.prompt_eval_count), + outputTokens: num(raw.eval_count), + numTurns: 1, + isError: doneReason === 'error', + } satisfies CostContent, + timestamp, + }); + } + + return messages; +} diff --git a/v2/src/lib/adapters/provider-bridge.ts b/v2/src/lib/adapters/provider-bridge.ts new file mode 100644 index 0000000..e5fb5db --- /dev/null +++ b/v2/src/lib/adapters/provider-bridge.ts @@ -0,0 +1,26 @@ +// Provider Bridge — generic adapter that delegates to provider-specific bridges +// Currently only Claude is implemented; future providers add their own bridge files + +import type { ProviderId } from '../providers/types'; +import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge'; + +// Re-export types for consumers +export type { ClaudeProfile, ClaudeSkill }; + +/** List profiles for a given provider (only Claude supports this) */ +export async function listProviderProfiles(provider: ProviderId): Promise { + if (provider === 'claude') return claudeListProfiles(); + return []; +} + +/** List skills for a given provider (only Claude supports this) */ +export async function listProviderSkills(provider: ProviderId): Promise { + if (provider === 'claude') return claudeListSkills(); + return []; +} + +/** Read a skill file (only Claude supports this) */ +export async function readProviderSkill(provider: ProviderId, path: string): Promise { + if (provider === 'claude') return claudeReadSkill(path); + return ''; +} diff --git a/v2/src/lib/adapters/pty-bridge.ts b/v2/src/lib/adapters/pty-bridge.ts new file mode 100644 index 0000000..1018c37 --- /dev/null +++ b/v2/src/lib/adapters/pty-bridge.ts @@ -0,0 +1,52 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface PtyOptions { + shell?: string; + cwd?: string; + args?: string[]; + cols?: number; + rows?: number; + remote_machine_id?: string; +} + +export async function spawnPty(options: PtyOptions): Promise { + if (options.remote_machine_id) { + const { remote_machine_id: machineId, ...ptyOptions } = options; + return invoke('remote_pty_spawn', { machineId, options: ptyOptions }); + } + return invoke('pty_spawn', { options }); +} + +export async function writePty(id: string, data: string, remoteMachineId?: string): Promise { + if (remoteMachineId) { + return invoke('remote_pty_write', { machineId: remoteMachineId, id, data }); + } + return invoke('pty_write', { id, data }); +} + +export async function resizePty(id: string, cols: number, rows: number, remoteMachineId?: string): Promise { + if (remoteMachineId) { + return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows }); + } + return invoke('pty_resize', { id, cols, rows }); +} + +export async function killPty(id: string, remoteMachineId?: string): Promise { + if (remoteMachineId) { + return invoke('remote_pty_kill', { machineId: remoteMachineId, id }); + } + return invoke('pty_kill', { id }); +} + +export async function onPtyData(id: string, callback: (data: string) => void): Promise { + return listen(`pty-data-${id}`, (event) => { + callback(event.payload); + }); +} + +export async function onPtyExit(id: string, callback: () => void): Promise { + return listen(`pty-exit-${id}`, () => { + callback(); + }); +} diff --git a/v2/src/lib/adapters/remote-bridge.ts b/v2/src/lib/adapters/remote-bridge.ts new file mode 100644 index 0000000..51a66a9 --- /dev/null +++ b/v2/src/lib/adapters/remote-bridge.ts @@ -0,0 +1,143 @@ +// Remote Machine Bridge — Tauri IPC adapter for multi-machine management + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface RemoteMachineConfig { + label: string; + url: string; + token: string; + auto_connect: boolean; +} + +export interface RemoteMachineInfo { + id: string; + label: string; + url: string; + status: string; + auto_connect: boolean; +} + +// --- Machine management --- + +export async function listRemoteMachines(): Promise { + return invoke('remote_list'); +} + +export async function addRemoteMachine(config: RemoteMachineConfig): Promise { + return invoke('remote_add', { config }); +} + +export async function removeRemoteMachine(machineId: string): Promise { + return invoke('remote_remove', { machineId }); +} + +export async function connectRemoteMachine(machineId: string): Promise { + return invoke('remote_connect', { machineId }); +} + +export async function disconnectRemoteMachine(machineId: string): Promise { + return invoke('remote_disconnect', { machineId }); +} + +// --- Remote event listeners --- + +export interface RemoteSidecarMessage { + machineId: string; + sessionId?: string; + event?: Record; +} + +export interface RemotePtyData { + machineId: string; + sessionId?: string; + data?: string; +} + +export interface RemotePtyExit { + machineId: string; + sessionId?: string; +} + +export interface RemoteMachineEvent { + machineId: string; + payload?: unknown; + error?: unknown; +} + +export async function onRemoteSidecarMessage( + callback: (msg: RemoteSidecarMessage) => void, +): Promise { + return listen('remote-sidecar-message', (event) => { + callback(event.payload); + }); +} + +export async function onRemotePtyData( + callback: (msg: RemotePtyData) => void, +): Promise { + return listen('remote-pty-data', (event) => { + callback(event.payload); + }); +} + +export async function onRemotePtyExit( + callback: (msg: RemotePtyExit) => void, +): Promise { + return listen('remote-pty-exit', (event) => { + callback(event.payload); + }); +} + +export async function onRemoteMachineReady( + callback: (msg: RemoteMachineEvent) => void, +): Promise { + return listen('remote-machine-ready', (event) => { + callback(event.payload); + }); +} + +export async function onRemoteMachineDisconnected( + callback: (msg: RemoteMachineEvent) => void, +): Promise { + return listen('remote-machine-disconnected', (event) => { + callback(event.payload); + }); +} + +export async function onRemoteStateSync( + callback: (msg: RemoteMachineEvent) => void, +): Promise { + return listen('remote-state-sync', (event) => { + callback(event.payload); + }); +} + +export async function onRemoteError( + callback: (msg: RemoteMachineEvent) => void, +): Promise { + return listen('remote-error', (event) => { + callback(event.payload); + }); +} + +export interface RemoteReconnectingEvent { + machineId: string; + backoffSecs: number; +} + +export async function onRemoteMachineReconnecting( + callback: (msg: RemoteReconnectingEvent) => void, +): Promise { + return listen('remote-machine-reconnecting', (event) => { + callback(event.payload); + }); +} + +export async function onRemoteMachineReconnectReady( + callback: (msg: RemoteMachineEvent) => void, +): Promise { + return listen('remote-machine-reconnect-ready', (event) => { + callback(event.payload); + }); +} diff --git a/v2/src/lib/adapters/session-bridge.ts b/v2/src/lib/adapters/session-bridge.ts new file mode 100644 index 0000000..4815ca7 --- /dev/null +++ b/v2/src/lib/adapters/session-bridge.ts @@ -0,0 +1,50 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface PersistedSession { + id: string; + type: string; + title: string; + shell?: string; + cwd?: string; + args?: string[]; + group_name?: string; + created_at: number; + last_used_at: number; +} + +export interface PersistedLayout { + preset: string; + pane_ids: string[]; +} + +export async function listSessions(): Promise { + return invoke('session_list'); +} + +export async function saveSession(session: PersistedSession): Promise { + return invoke('session_save', { session }); +} + +export async function deleteSession(id: string): Promise { + return invoke('session_delete', { id }); +} + +export async function updateSessionTitle(id: string, title: string): Promise { + return invoke('session_update_title', { id, title }); +} + +export async function touchSession(id: string): Promise { + return invoke('session_touch', { id }); +} + +export async function updateSessionGroup(id: string, groupName: string): Promise { + return invoke('session_update_group', { id, group_name: groupName }); +} + +export async function saveLayout(layout: PersistedLayout): Promise { + return invoke('layout_save', { layout }); +} + +export async function loadLayout(): Promise { + return invoke('layout_load'); +} diff --git a/v2/src/lib/adapters/settings-bridge.ts b/v2/src/lib/adapters/settings-bridge.ts new file mode 100644 index 0000000..0dfb346 --- /dev/null +++ b/v2/src/lib/adapters/settings-bridge.ts @@ -0,0 +1,13 @@ +import { invoke } from '@tauri-apps/api/core'; + +export async function getSetting(key: string): Promise { + return invoke('settings_get', { key }); +} + +export async function setSetting(key: string, value: string): Promise { + return invoke('settings_set', { key, value }); +} + +export async function listSettings(): Promise<[string, string][]> { + return invoke('settings_list'); +} diff --git a/v2/src/lib/adapters/ssh-bridge.ts b/v2/src/lib/adapters/ssh-bridge.ts new file mode 100644 index 0000000..a01c40c --- /dev/null +++ b/v2/src/lib/adapters/ssh-bridge.ts @@ -0,0 +1,26 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface SshSession { + id: string; + name: string; + host: string; + port: number; + username: string; + key_file: string; + folder: string; + color: string; + created_at: number; + last_used_at: number; +} + +export async function listSshSessions(): Promise { + return invoke('ssh_session_list'); +} + +export async function saveSshSession(session: SshSession): Promise { + return invoke('ssh_session_save', { session }); +} + +export async function deleteSshSession(id: string): Promise { + return invoke('ssh_session_delete', { id }); +} diff --git a/v2/src/lib/adapters/telemetry-bridge.ts b/v2/src/lib/adapters/telemetry-bridge.ts new file mode 100644 index 0000000..394c596 --- /dev/null +++ b/v2/src/lib/adapters/telemetry-bridge.ts @@ -0,0 +1,26 @@ +// Telemetry bridge — routes frontend events to Rust tracing via IPC +// No browser OTEL SDK needed (WebKit2GTK incompatible) + +import { invoke } from '@tauri-apps/api/core'; + +type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +/** Emit a structured log event to the Rust tracing layer */ +export function telemetryLog( + level: LogLevel, + message: string, + context?: Record, +): void { + invoke('frontend_log', { level, message, context: context ?? null }).catch(() => { + // Swallow IPC errors — telemetry must never break the app + }); +} + +/** Convenience wrappers */ +export const tel = { + error: (msg: string, ctx?: Record) => telemetryLog('error', msg, ctx), + warn: (msg: string, ctx?: Record) => telemetryLog('warn', msg, ctx), + info: (msg: string, ctx?: Record) => telemetryLog('info', msg, ctx), + debug: (msg: string, ctx?: Record) => telemetryLog('debug', msg, ctx), + trace: (msg: string, ctx?: Record) => telemetryLog('trace', msg, ctx), +}; diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts new file mode 100644 index 0000000..c1e4ce5 --- /dev/null +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -0,0 +1,666 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Hoisted mocks --- + +const { + capturedCallbacks, + mockUnlistenMsg, + mockUnlistenExit, + mockRestartAgent, + mockUpdateAgentStatus, + mockSetAgentSdkSessionId, + mockSetAgentModel, + mockAppendAgentMessages, + mockUpdateAgentCost, + mockGetAgentSessions, + mockCreateAgentSession, + mockFindChildByToolUseId, + mockAddPane, + mockGetPanes, + mockNotify, +} = vi.hoisted(() => ({ + capturedCallbacks: { + msg: null as ((msg: any) => void) | null, + exit: null as (() => void) | null, + }, + mockUnlistenMsg: vi.fn(), + mockUnlistenExit: vi.fn(), + mockRestartAgent: vi.fn(), + mockUpdateAgentStatus: vi.fn(), + mockSetAgentSdkSessionId: vi.fn(), + mockSetAgentModel: vi.fn(), + mockAppendAgentMessages: vi.fn(), + mockUpdateAgentCost: vi.fn(), + mockGetAgentSessions: vi.fn().mockReturnValue([]), + mockCreateAgentSession: vi.fn(), + mockFindChildByToolUseId: vi.fn().mockReturnValue(undefined), + mockAddPane: vi.fn(), + mockGetPanes: vi.fn().mockReturnValue([]), + mockNotify: vi.fn(), +})); + +vi.mock('./adapters/agent-bridge', () => ({ + onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => { + capturedCallbacks.msg = cb; + return mockUnlistenMsg; + }), + onSidecarExited: vi.fn(async (cb: () => void) => { + capturedCallbacks.exit = cb; + return mockUnlistenExit; + }), + restartAgent: (...args: unknown[]) => mockRestartAgent(...args), +})); + +vi.mock('./providers/types', () => ({})); + +vi.mock('./adapters/message-adapters', () => ({ + adaptMessage: vi.fn((_provider: string, raw: Record) => { + if (raw.type === 'system' && raw.subtype === 'init') { + return [{ + id: 'msg-1', + type: 'init', + content: { sessionId: 'sdk-sess', model: 'claude-sonnet-4-20250514', cwd: '/tmp', tools: [] }, + timestamp: Date.now(), + }]; + } + if (raw.type === 'result') { + return [{ + id: 'msg-2', + type: 'cost', + content: { + totalCostUsd: 0.05, + durationMs: 5000, + inputTokens: 500, + outputTokens: 200, + numTurns: 2, + isError: false, + }, + timestamp: Date.now(), + }]; + } + if (raw.type === 'assistant') { + return [{ + id: 'msg-3', + type: 'text', + content: { text: 'Hello' }, + timestamp: Date.now(), + }]; + } + // Subagent tool_call (Agent/Task) + if (raw.type === 'tool_call_agent') { + return [{ + id: 'msg-tc-agent', + type: 'tool_call', + content: { + toolUseId: raw.toolUseId ?? 'tu-123', + name: raw.toolName ?? 'Agent', + input: raw.toolInput ?? { prompt: 'Do something', name: 'researcher' }, + }, + timestamp: Date.now(), + }]; + } + // Non-subagent tool_call + if (raw.type === 'tool_call_normal') { + return [{ + id: 'msg-tc-normal', + type: 'tool_call', + content: { + toolUseId: 'tu-normal', + name: 'Read', + input: { file: 'test.ts' }, + }, + timestamp: Date.now(), + }]; + } + // Message with parentId (routed to child) + if (raw.type === 'child_message') { + return [{ + id: 'msg-child', + type: 'text', + parentId: raw.parentId as string, + content: { text: 'Child output' }, + timestamp: Date.now(), + }]; + } + // Child init message + if (raw.type === 'child_init') { + return [{ + id: 'msg-child-init', + type: 'init', + parentId: raw.parentId as string, + content: { sessionId: 'child-sdk-sess', model: 'claude-sonnet-4-20250514', cwd: '/tmp', tools: [] }, + timestamp: Date.now(), + }]; + } + // Child cost message + if (raw.type === 'child_cost') { + return [{ + id: 'msg-child-cost', + type: 'cost', + parentId: raw.parentId as string, + content: { + totalCostUsd: 0.02, + durationMs: 2000, + inputTokens: 200, + outputTokens: 100, + numTurns: 1, + isError: false, + }, + timestamp: Date.now(), + }]; + } + return []; + }), +})); + +vi.mock('./stores/agents.svelte', () => ({ + updateAgentStatus: (...args: unknown[]) => mockUpdateAgentStatus(...args), + setAgentSdkSessionId: (...args: unknown[]) => mockSetAgentSdkSessionId(...args), + setAgentModel: (...args: unknown[]) => mockSetAgentModel(...args), + appendAgentMessages: (...args: unknown[]) => mockAppendAgentMessages(...args), + updateAgentCost: (...args: unknown[]) => mockUpdateAgentCost(...args), + getAgentSessions: () => mockGetAgentSessions(), + createAgentSession: (...args: unknown[]) => mockCreateAgentSession(...args), + findChildByToolUseId: (...args: unknown[]) => mockFindChildByToolUseId(...args), +})); + +vi.mock('./stores/layout.svelte', () => ({ + addPane: (...args: unknown[]) => mockAddPane(...args), + getPanes: () => mockGetPanes(), +})); + +vi.mock('./stores/notifications.svelte', () => ({ + notify: (...args: unknown[]) => mockNotify(...args), +})); + +vi.mock('./stores/conflicts.svelte', () => ({ + recordFileWrite: vi.fn().mockReturnValue(false), + clearSessionWrites: vi.fn(), + setSessionWorktree: vi.fn(), +})); + +vi.mock('./utils/tool-files', () => ({ + extractWritePaths: vi.fn().mockReturnValue([]), + extractWorktreePath: vi.fn().mockReturnValue(null), +})); + +// Use fake timers to control setTimeout in sidecar crash recovery +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + capturedCallbacks.msg = null; + capturedCallbacks.exit = null; + mockRestartAgent.mockResolvedValue(undefined); + mockGetAgentSessions.mockReturnValue([]); +}); + +// We need to dynamically import the dispatcher in each test to get fresh module state. +// However, vi.mock is module-scoped so the mocks persist. The module-level restartAttempts +// and sidecarAlive variables persist across tests since they share the same module instance. +// We work around this by resetting via the exported setSidecarAlive and stopAgentDispatcher. + +import { + startAgentDispatcher, + stopAgentDispatcher, + isSidecarAlive, + setSidecarAlive, + waitForPendingPersistence, +} from './agent-dispatcher'; + +// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works +beforeEach(() => { + stopAgentDispatcher(); +}); + +afterEach(async () => { + vi.useRealTimers(); +}); + +// Need afterEach import +import { afterEach } from 'vitest'; + +describe('agent-dispatcher', () => { + describe('startAgentDispatcher', () => { + it('registers sidecar message and exit listeners', async () => { + await startAgentDispatcher(); + + expect(capturedCallbacks.msg).toBeTypeOf('function'); + expect(capturedCallbacks.exit).toBeTypeOf('function'); + }); + + it('does not register duplicate listeners on repeated calls', async () => { + await startAgentDispatcher(); + await startAgentDispatcher(); // second call should be no-op + + const { onSidecarMessage } = await import('./adapters/agent-bridge'); + expect(onSidecarMessage).toHaveBeenCalledTimes(1); + }); + + it('sets sidecarAlive to true on start', async () => { + setSidecarAlive(false); + await startAgentDispatcher(); + expect(isSidecarAlive()).toBe(true); + }); + }); + + describe('message routing', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('routes agent_started to updateAgentStatus(running)', () => { + capturedCallbacks.msg!({ + type: 'agent_started', + sessionId: 'sess-1', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'running'); + }); + + it('routes agent_stopped to updateAgentStatus(done) and notifies', () => { + capturedCallbacks.msg!({ + type: 'agent_stopped', + sessionId: 'sess-1', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); + expect(mockNotify).toHaveBeenCalledWith('success', expect.stringContaining('completed')); + }); + + it('routes agent_error to updateAgentStatus(error) with message', () => { + capturedCallbacks.msg!({ + type: 'agent_error', + sessionId: 'sess-1', + message: 'Process crashed', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Process crashed'); + expect(mockNotify).toHaveBeenCalledWith('error', expect.stringContaining('Process crashed')); + }); + + it('ignores messages without sessionId', () => { + capturedCallbacks.msg!({ + type: 'agent_started', + }); + + expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); + }); + + it('handles agent_log silently (no-op)', () => { + capturedCallbacks.msg!({ + type: 'agent_log', + sessionId: 'sess-1', + message: 'Debug info', + }); + + expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); + expect(mockNotify).not.toHaveBeenCalled(); + }); + }); + + describe('agent_event routing via SDK adapter', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('routes init event to setAgentSdkSessionId and setAgentModel', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }); + + expect(mockSetAgentSdkSessionId).toHaveBeenCalledWith('sess-1', 'sdk-sess'); + expect(mockSetAgentModel).toHaveBeenCalledWith('sess-1', 'claude-sonnet-4-20250514'); + expect(mockAppendAgentMessages).toHaveBeenCalled(); + }); + + it('routes cost event to updateAgentCost and updateAgentStatus', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'result' }, + }); + + expect(mockUpdateAgentCost).toHaveBeenCalledWith('sess-1', { + costUsd: 0.05, + inputTokens: 500, + outputTokens: 200, + numTurns: 2, + durationMs: 5000, + }); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); + }); + + it('appends messages to agent session', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'assistant' }, + }); + + expect(mockAppendAgentMessages).toHaveBeenCalledWith('sess-1', [ + expect.objectContaining({ type: 'text', content: { text: 'Hello' } }), + ]); + }); + + it('does not append when adapter returns empty array', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'unknown_event' }, + }); + + expect(mockAppendAgentMessages).not.toHaveBeenCalled(); + }); + }); + + describe('sidecar exit handling', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('marks running sessions as errored on exit', async () => { + mockGetAgentSessions.mockReturnValue([ + { id: 'sess-1', status: 'running' }, + { id: 'sess-2', status: 'done' }, + { id: 'sess-3', status: 'starting' }, + ]); + + // Trigger exit -- don't await, since it has internal setTimeout + const exitPromise = capturedCallbacks.exit!(); + // Advance past the backoff delay (up to 4s) + await vi.advanceTimersByTimeAsync(5000); + await exitPromise; + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Sidecar crashed'); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-3', 'error', 'Sidecar crashed'); + // sess-2 (done) should not be updated with 'error'/'Sidecar crashed' + const calls = mockUpdateAgentStatus.mock.calls; + const sess2Calls = calls.filter((c: unknown[]) => c[0] === 'sess-2'); + expect(sess2Calls).toHaveLength(0); + }); + + it('attempts auto-restart and notifies with warning', async () => { + const exitPromise = capturedCallbacks.exit!(); + await vi.advanceTimersByTimeAsync(5000); + await exitPromise; + + expect(mockRestartAgent).toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith('warning', expect.stringContaining('restarting')); + }); + }); + + describe('stopAgentDispatcher', () => { + it('calls unlisten functions', async () => { + await startAgentDispatcher(); + stopAgentDispatcher(); + + expect(mockUnlistenMsg).toHaveBeenCalled(); + expect(mockUnlistenExit).toHaveBeenCalled(); + }); + + it('allows re-registering after stop', async () => { + await startAgentDispatcher(); + stopAgentDispatcher(); + await startAgentDispatcher(); + + const { onSidecarMessage } = await import('./adapters/agent-bridge'); + expect(onSidecarMessage).toHaveBeenCalledTimes(2); + }); + }); + + describe('isSidecarAlive / setSidecarAlive', () => { + it('defaults to true after start', async () => { + await startAgentDispatcher(); + expect(isSidecarAlive()).toBe(true); + }); + + it('can be set manually', () => { + setSidecarAlive(false); + expect(isSidecarAlive()).toBe(false); + setSidecarAlive(true); + expect(isSidecarAlive()).toBe(true); + }); + }); + + describe('subagent routing', () => { + beforeEach(async () => { + await startAgentDispatcher(); + mockGetPanes.mockReturnValue([ + { id: 'sess-1', type: 'agent', title: 'Agent 1', focused: false }, + ]); + mockFindChildByToolUseId.mockReturnValue(undefined); + }); + + it('spawns a subagent pane when Agent tool_call is detected', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-agent-1', toolName: 'Agent', toolInput: { prompt: 'Research X', name: 'researcher' } }, + }); + + expect(mockCreateAgentSession).toHaveBeenCalledWith( + expect.any(String), + 'Research X', + { sessionId: 'sess-1', toolUseId: 'tu-agent-1' }, + ); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith(expect.any(String), 'running'); + expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ + type: 'agent', + title: 'Sub: researcher', + group: 'Agent 1', + })); + }); + + it('spawns a subagent pane for Task tool_call', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-task-1', toolName: 'Task', toolInput: { prompt: 'Build it', name: 'builder' } }, + }); + + expect(mockCreateAgentSession).toHaveBeenCalled(); + expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Sub: builder', + })); + }); + + it('does not spawn pane for non-subagent tool_calls', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_normal' }, + }); + + expect(mockCreateAgentSession).not.toHaveBeenCalled(); + expect(mockAddPane).not.toHaveBeenCalled(); + }); + + it('does not spawn duplicate pane for same toolUseId', () => { + // First call — spawns pane + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-dup', toolName: 'Agent', toolInput: { prompt: 'test', name: 'dup' } }, + }); + + // Second call with same toolUseId — should not create another + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-dup', toolName: 'Agent', toolInput: { prompt: 'test', name: 'dup' } }, + }); + + expect(mockCreateAgentSession).toHaveBeenCalledTimes(1); + expect(mockAddPane).toHaveBeenCalledTimes(1); + }); + + it('reuses existing child session from findChildByToolUseId', () => { + mockFindChildByToolUseId.mockReturnValue({ id: 'existing-child' }); + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-existing', toolName: 'Agent', toolInput: { prompt: 'test' } }, + }); + + // Should not create a new session or pane + expect(mockCreateAgentSession).not.toHaveBeenCalled(); + expect(mockAddPane).not.toHaveBeenCalled(); + }); + + it('routes messages with parentId to the child pane', () => { + // First spawn a subagent + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-route', toolName: 'Agent', toolInput: { prompt: 'test', name: 'worker' } }, + }); + + const childId = mockCreateAgentSession.mock.calls[0][0]; + + // Now send a message with parentId matching the toolUseId + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'child_message', parentId: 'tu-route' }, + }); + + // The child message should go to child pane, not main session + expect(mockAppendAgentMessages).toHaveBeenCalledWith( + childId, + [expect.objectContaining({ type: 'text', content: { text: 'Child output' } })], + ); + }); + + it('routes child init message and updates child session', () => { + // Spawn subagent + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-cinit', toolName: 'Agent', toolInput: { prompt: 'test', name: 'init-test' } }, + }); + + const childId = mockCreateAgentSession.mock.calls[0][0]; + mockUpdateAgentStatus.mockClear(); + + // Send child init + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'child_init', parentId: 'tu-cinit' }, + }); + + expect(mockSetAgentSdkSessionId).toHaveBeenCalledWith(childId, 'child-sdk-sess'); + expect(mockSetAgentModel).toHaveBeenCalledWith(childId, 'claude-sonnet-4-20250514'); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith(childId, 'running'); + }); + + it('routes child cost message and marks child done', () => { + // Spawn subagent + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-ccost', toolName: 'Agent', toolInput: { prompt: 'test', name: 'cost-test' } }, + }); + + const childId = mockCreateAgentSession.mock.calls[0][0]; + + // Send child cost + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'child_cost', parentId: 'tu-ccost' }, + }); + + expect(mockUpdateAgentCost).toHaveBeenCalledWith(childId, { + costUsd: 0.02, + inputTokens: 200, + outputTokens: 100, + numTurns: 1, + durationMs: 2000, + }); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith(childId, 'done'); + }); + + it('uses tool name as fallback when input has no prompt/name', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-fallback', toolName: 'dispatch_agent', toolInput: 'raw string input' }, + }); + + expect(mockCreateAgentSession).toHaveBeenCalledWith( + expect.any(String), + 'dispatch_agent', + expect.any(Object), + ); + expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Sub: dispatch_agent', + })); + }); + + it('uses parent fallback title when parent pane not found', () => { + mockGetPanes.mockReturnValue([]); // no panes found + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'tool_call_agent', toolUseId: 'tu-noparent', toolName: 'Agent', toolInput: { prompt: 'test', name: 'orphan' } }, + }); + + expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ + group: 'Agent sess-1', + })); + }); + }); + + describe('waitForPendingPersistence', () => { + it('resolves immediately when no persistence is in-flight', async () => { + vi.useRealTimers(); + await expect(waitForPendingPersistence()).resolves.toBeUndefined(); + }); + }); + + describe('init event CWD worktree detection', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('calls setSessionWorktree when init CWD contains worktree path', async () => { + const { setSessionWorktree } = await import('./stores/conflicts.svelte'); + + // Override the mock adapter to return init with worktree CWD + const { adaptMessage } = await import('./adapters/message-adapters'); + (adaptMessage as ReturnType).mockReturnValueOnce([{ + id: 'msg-wt', + type: 'init', + content: { sessionId: 'sdk-wt', model: 'claude-sonnet-4-20250514', cwd: '/home/user/repo/.claude/worktrees/my-session', tools: [] }, + timestamp: Date.now(), + }]); + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-wt', + event: { type: 'system', subtype: 'init' }, + }); + + expect(setSessionWorktree).toHaveBeenCalledWith('sess-wt', '/.claude/worktrees/my-session'); + }); + + it('does not call setSessionWorktree for non-worktree CWD', async () => { + const { setSessionWorktree } = await import('./stores/conflicts.svelte'); + (setSessionWorktree as ReturnType).mockClear(); + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-normal', + event: { type: 'system', subtype: 'init' }, + }); + + // The default mock returns cwd: '/tmp' which is not a worktree + expect(setSessionWorktree).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts new file mode 100644 index 0000000..fcd9beb --- /dev/null +++ b/v2/src/lib/agent-dispatcher.ts @@ -0,0 +1,305 @@ +// Agent Dispatcher — connects sidecar bridge events to agent store +// Thin coordinator that routes sidecar messages to specialized modules + +import { SessionId, type SessionId as SessionIdType } from './types/ids'; +import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; +import { adaptMessage } from './adapters/message-adapters'; +import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; +import { + updateAgentStatus, + setAgentSdkSessionId, + setAgentModel, + appendAgentMessages, + updateAgentCost, + getAgentSessions, + getAgentSession, +} from './stores/agents.svelte'; +import { notify } from './stores/notifications.svelte'; +import { tel } from './adapters/telemetry-bridge'; +import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; +import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; +import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; +import { hasAutoAnchored, markAutoAnchored } from './stores/anchors.svelte'; +import { detectWorktreeFromCwd } from './utils/worktree-detection'; +import { + getSessionProjectId, + getSessionProvider, + recordSessionStart, + persistSessionForProject, + clearSessionMaps, +} from './utils/session-persistence'; +import { triggerAutoAnchor } from './utils/auto-anchoring'; +import { + isSubagentToolCall, + getChildPaneId, + spawnSubagentPane, + clearSubagentRoutes, +} from './utils/subagent-router'; + +// Re-export public API consumed by other modules +export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence'; + +let unlistenMsg: (() => void) | null = null; +let unlistenExit: (() => void) | null = null; + +// Sidecar liveness — checked by UI components +let sidecarAlive = true; + +// Sidecar crash recovery state +const MAX_RESTART_ATTEMPTS = 3; +let restartAttempts = 0; +let restarting = false; +export function isSidecarAlive(): boolean { + return sidecarAlive; +} +export function setSidecarAlive(alive: boolean): void { + sidecarAlive = alive; +} + +export async function startAgentDispatcher(): Promise { + if (unlistenMsg) return; + + sidecarAlive = true; + + unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => { + sidecarAlive = true; + // Reset restart counter on any successful message — sidecar recovered + if (restartAttempts > 0) { + notify('success', 'Sidecar recovered'); + restartAttempts = 0; + } + + if (!msg.sessionId) return; + const sessionId = SessionId(msg.sessionId); + + switch (msg.type) { + case 'agent_started': + updateAgentStatus(sessionId, 'running'); + recordSessionStart(sessionId); + tel.info('agent_started', { sessionId }); + break; + + case 'agent_event': + if (msg.event) handleAgentEvent(sessionId, msg.event); + break; + + case 'agent_stopped': + updateAgentStatus(sessionId, 'done'); + tel.info('agent_stopped', { sessionId }); + notify('success', `Agent ${sessionId.slice(0, 8)} completed`); + break; + + case 'agent_error': + updateAgentStatus(sessionId, 'error', msg.message); + tel.error('agent_error', { sessionId, error: msg.message }); + notify('error', `Agent error: ${msg.message ?? 'Unknown'}`); + break; + + case 'agent_log': + break; + } + }); + + unlistenExit = await onSidecarExited(async () => { + sidecarAlive = false; + tel.error('sidecar_crashed', { restartAttempts }); + + // Guard against re-entrant exit handler (double-restart race) + if (restarting) return; + restarting = true; + + // Mark all running sessions as errored + for (const session of getAgentSessions()) { + if (session.status === 'running' || session.status === 'starting') { + updateAgentStatus(session.id, 'error', 'Sidecar crashed'); + } + } + + // Attempt auto-restart with exponential backoff + try { + if (restartAttempts < MAX_RESTART_ATTEMPTS) { + restartAttempts++; + const delayMs = 1000 * Math.pow(2, restartAttempts - 1); // 1s, 2s, 4s + notify('warning', `Sidecar crashed, restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + try { + await restartAgent(); + sidecarAlive = true; + // Note: restartAttempts is reset when next sidecar message arrives + } catch { + if (restartAttempts >= MAX_RESTART_ATTEMPTS) { + notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`); + } + } + } else { + notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`); + } + } finally { + restarting = false; + } + }); +} + +function handleAgentEvent(sessionId: SessionIdType, event: Record): void { + const provider = getSessionProvider(sessionId); + const messages = adaptMessage(provider, event); + + // Route messages with parentId to the appropriate child pane + const mainMessages: typeof messages = []; + const childBuckets = new Map(); + + for (const msg of messages) { + const childPaneId = msg.parentId ? getChildPaneId(msg.parentId) : undefined; + if (childPaneId) { + if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []); + childBuckets.get(childPaneId)!.push(msg); + } else { + mainMessages.push(msg); + } + } + + // Process main session messages + for (const msg of mainMessages) { + switch (msg.type) { + case 'init': { + const init = msg.content as InitContent; + setAgentSdkSessionId(sessionId, init.sessionId); + setAgentModel(sessionId, init.model); + // CWD-based worktree detection for conflict suppression + if (init.cwd) { + const wtPath = detectWorktreeFromCwd(init.cwd); + if (wtPath) { + setSessionWorktree(sessionId, wtPath); + } + } + break; + } + + case 'tool_call': { + const tc = msg.content as ToolCallContent; + if (isSubagentToolCall(tc.name)) { + spawnSubagentPane(sessionId, tc); + } + // Health: record tool start + const projId = getSessionProjectId(sessionId); + if (projId) { + recordActivity(projId, tc.name); + // Worktree tracking + const wtPath = extractWorktreePath(tc); + if (wtPath) { + setSessionWorktree(sessionId, wtPath); + } + // Conflict detection: track file writes + const writePaths = extractWritePaths(tc); + for (const filePath of writePaths) { + const isNewConflict = recordFileWrite(projId, sessionId, filePath); + if (isNewConflict) { + const shortName = filePath.split('/').pop() ?? filePath; + notify('warning', `File conflict: ${shortName} — multiple agents writing`); + } + } + } + break; + } + + case 'compaction': { + // Auto-anchor on first compaction for this project + const compactProjId = getSessionProjectId(sessionId); + if (compactProjId && !hasAutoAnchored(compactProjId)) { + markAutoAnchored(compactProjId); + const session = getAgentSession(sessionId); + if (session) { + triggerAutoAnchor(compactProjId, session.messages, session.prompt); + } + } + break; + } + + case 'cost': { + const cost = msg.content as CostContent; + updateAgentCost(sessionId, { + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + }); + tel.info('agent_cost', { + sessionId, + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + isError: cost.isError, + }); + if (cost.isError) { + updateAgentStatus(sessionId, 'error', cost.errors?.join('; ')); + notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`); + } else { + updateAgentStatus(sessionId, 'done'); + notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); + } + // Health: record token snapshot + tool done + const costProjId = getSessionProjectId(sessionId); + if (costProjId) { + recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); + recordToolDone(costProjId); + // Conflict tracking: clear session writes on completion + clearSessionWrites(costProjId, sessionId); + } + // Persist session state for project-scoped sessions + persistSessionForProject(sessionId); + break; + } + } + } + + // Health: record general activity for non-tool messages (text, thinking) + if (mainMessages.length > 0) { + const actProjId = getSessionProjectId(sessionId); + if (actProjId) { + const hasToolResult = mainMessages.some(m => m.type === 'tool_result'); + if (hasToolResult) recordToolDone(actProjId); + else recordActivity(actProjId); + } + appendAgentMessages(sessionId, mainMessages); + } + + // Append messages to child panes and update their status + for (const [childPaneId, childMsgs] of childBuckets) { + for (const msg of childMsgs) { + if (msg.type === 'init') { + const init = msg.content as InitContent; + setAgentSdkSessionId(childPaneId, init.sessionId); + setAgentModel(childPaneId, init.model); + updateAgentStatus(childPaneId, 'running'); + } else if (msg.type === 'cost') { + const cost = msg.content as CostContent; + updateAgentCost(childPaneId, { + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + }); + updateAgentStatus(childPaneId, cost.isError ? 'error' : 'done'); + } + } + appendAgentMessages(childPaneId, childMsgs); + } +} + +export function stopAgentDispatcher(): void { + if (unlistenMsg) { + unlistenMsg(); + unlistenMsg = null; + } + if (unlistenExit) { + unlistenExit(); + unlistenExit = null; + } + // Clear routing maps to prevent unbounded memory growth + clearSubagentRoutes(); + clearSessionMaps(); +} diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte new file mode 100644 index 0000000..c470411 --- /dev/null +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -0,0 +1,1500 @@ + + +
+ {#if parentSession} + + {/if} + {#if childSessions.length > 0} +
+ {childSessions.length} subagent{childSessions.length > 1 ? 's' : ''} + {#each childSessions as child (child.id)} + + {/each} +
+ {/if} + {#if hasToolCalls} +
+ +
+ {#if showTree && session} + + {/if} + {/if} + +
+ {#if !session || session.messages.length === 0} +
+
+ + + +
+ Ask Claude anything + Type / for skills • Shift+Enter for newline +
+ {:else} + {#each session.messages as msg (msg.id)} +
+ {#if msg.type === 'init'} +
+ Session started + {(msg.content as import('../../adapters/claude-messages').InitContent).model} +
+ {:else if msg.type === 'text'} + {@const textContent = (msg.content as TextContent).text} + {@const firstLine = textContent.split('\n')[0].slice(0, 120)} +
+ + + {firstLine}{firstLine.length >= 120 ? '...' : ''} + {#if projectId} + + {/if} + +
{@html renderMarkdown(textContent)}
+
+ {:else if msg.type === 'thinking'} +
+ Thinking... +
{(msg.content as ThinkingContent).text}
+
+ {:else if msg.type === 'tool_call'} + {@const tc = msg.content as ToolCallContent} + {@const pairedResult = toolResultMap[tc.toolUseId]} +
+ + + {tc.name} + {#if pairedResult} + + {:else if isRunning} + + {/if} + +
+
+ +
{formatToolInput(tc.input)}
+
+ {#if pairedResult} + {@const outputStr = formatToolInput(pairedResult.output)} + {@const limit = getTruncationLimit(tc.name)} + {@const truncated = truncateByLines(outputStr, limit)} +
+ + {#if truncated.truncated && !expandedTools.has(tc.toolUseId)} +
{truncated.text}
+ + {:else} +
{outputStr}
+ {/if} +
+ {:else if isRunning} +
+ + Awaiting tool result +
+ {/if} +
+
+ {:else if msg.type === 'tool_result'} + + {:else if msg.type === 'cost'} + {@const cost = msg.content as CostContent} +
+ ${cost.totalCostUsd.toFixed(4)} + {cost.inputTokens + cost.outputTokens} tokens + {cost.numTurns} turns + {(cost.durationMs / 1000).toFixed(1)}s +
+ {#if cost.result} +
+ + + {cost.result.split('\n')[0].slice(0, 80)}{cost.result.length > 80 ? '...' : ''} + +
{cost.result}
+
+ {/if} + {:else if msg.type === 'error'} +
{(msg.content as ErrorContent).message}
+ {:else if msg.type === 'status'} + {@const statusContent = msg.content as StatusContent} + {#if isHookMessage(statusContent)} +
+ {hookDisplayName(statusContent.subtype)} +
{statusContent.message || JSON.stringify(msg.content, null, 2)}
+
+ {:else} +
{statusContent.message || statusContent.subtype}
+ {/if} + {/if} +
+ {/each} +
+ {/if} +
+ + + {#if session} +
+ {#if session.status === 'running' || session.status === 'starting'} +
+ + Running... + {#if contextPercent > 0} + + + {contextPercent}% + + {/if} + {#if !autoScroll} + + {/if} + +
+ {:else if session.status === 'done'} +
+ ${session.costUsd.toFixed(4)} + {#if totalCost && totalCost.costUsd > session.costUsd} + (total: ${totalCost.costUsd.toFixed(4)}) + {/if} + {session.inputTokens + session.outputTokens} tok + {(session.durationMs / 1000).toFixed(1)}s + {#if contextPercent > 0} + + + {contextPercent}% + + {/if} + {#if !autoScroll} + + {/if} +
+ {:else if session.status === 'error'} +
+ Error: {session.error ?? 'Unknown'} + {#if session.error?.includes('Sidecar') || session.error?.includes('crashed')} + + {/if} +
+ {/if} +
+ {/if} + + + {#if session && (session.status === 'done' || session.status === 'error') && session.sdkSessionId} +
+ + {#if capabilities.supportsResume} + + {/if} +
+ {/if} + + +
+
+ {#if capabilities.hasSkills && showSkillMenu && filteredSkills.length > 0} +
+ {#each filteredSkills as skill, i (skill.name)} + + {/each} +
+ {/if} + + +
+
+
+ + diff --git a/v2/src/lib/components/Agent/AgentTree.svelte b/v2/src/lib/components/Agent/AgentTree.svelte new file mode 100644 index 0000000..8b3e8bb --- /dev/null +++ b/v2/src/lib/components/Agent/AgentTree.svelte @@ -0,0 +1,173 @@ + + +
+ + {#snippet renderNode(layout: LayoutNode)} + + {#each layout.children as child} + + {/each} + + + + + onNodeClick?.(layout.node.id)} + style="cursor: {onNodeClick ? 'pointer' : 'default'}" + > + + + + + {truncateLabel(layout.node.label, 10)} + + {#if subtreeCost(layout.node) > 0} + ${subtreeCost(layout.node).toFixed(4)} + {/if} + + + + {#each layout.children as child} + {@render renderNode(child)} + {/each} + {/snippet} + + {@render renderNode(layoutResult.layout)} + +
+ + diff --git a/v2/src/lib/components/Context/ContextPane.svelte b/v2/src/lib/components/Context/ContextPane.svelte new file mode 100644 index 0000000..d7d2834 --- /dev/null +++ b/v2/src/lib/components/Context/ContextPane.svelte @@ -0,0 +1,396 @@ + + +
+
+

{projectName}

+ { if (e.key === 'Enter') handleSearch(); }} + /> +
+ + {#if error} +
+
+ + + + + +
+ {#if dbMissing} +
Context database not found
+
+ Create the database at ~/.claude-context/context.db to get started. +
+ + {:else} +
{error}
+ {/if} +
+ {/if} + + {#if !error} +
+ {#if loading} +
Loading...
+ {:else if searchResults.length > 0} +
+

Search Results

+ {#each searchResults as result} +
+
+ {result.project} + {result.key} +
+
{result.value}
+
+ {/each} + +
+ {:else} + {#if entries.length > 0} +
+

Project Context

+ {#each entries as entry} +
+
+ {entry.key} + +
+
{entry.value}
+
+ {/each} +
+ {/if} + + {#if sharedEntries.length > 0} +
+

Shared Context

+ {#each sharedEntries as entry} +
+
+ {entry.key} +
+
{entry.value}
+
+ {/each} +
+ {/if} + + {#if summaries.length > 0} +
+

Recent Sessions

+ {#each summaries as summary} +
+
+ +
+
{summary.summary}
+
+ {/each} +
+ {/if} + + {#if entries.length === 0 && sharedEntries.length === 0 && summaries.length === 0} +
+

No context stored yet.

+

Use ctx set {projectName} <key> <value> to add context entries.

+
+ {/if} + {/if} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Markdown/MarkdownPane.svelte b/v2/src/lib/components/Markdown/MarkdownPane.svelte new file mode 100644 index 0000000..0d03a03 --- /dev/null +++ b/v2/src/lib/components/Markdown/MarkdownPane.svelte @@ -0,0 +1,428 @@ + + +
+ {#if error} +
{error}
+ {:else} + + +
+
+ {@html renderedHtml} +
+
+ {/if} +
{filePath}
+
+ + diff --git a/v2/src/lib/components/Notifications/ToastContainer.svelte b/v2/src/lib/components/Notifications/ToastContainer.svelte new file mode 100644 index 0000000..2602b16 --- /dev/null +++ b/v2/src/lib/components/Notifications/ToastContainer.svelte @@ -0,0 +1,94 @@ + + +{#if toasts.length > 0} +
+ {#each toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte new file mode 100644 index 0000000..4cd036e --- /dev/null +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -0,0 +1,299 @@ + + +
+
+ {#if activeGroup} + {activeGroup.name} + + {/if} + {projectCount} projects + + + + {#if health.running > 0} + + + {health.running} running + + + {/if} + {#if health.idle > 0} + {health.idle} idle + + {/if} + {#if health.stalled > 0} + + {health.stalled} stalled + + + {/if} + {#if totalConflicts > 0} + + ⚠ {totalConflicts} conflict{totalConflicts > 1 ? 's' : ''} + + + {/if} + + + {#if attentionQueue.length > 0} + + {/if} +
+ +
+ {#if health.totalBurnRatePerHour > 0} + + {formatRate(health.totalBurnRatePerHour)} + + + {/if} + {#if totalTokens > 0} + {totalTokens.toLocaleString()} tok + + {/if} + {#if totalCost > 0} + ${totalCost.toFixed(4)} + + {/if} + BTerminal v3 +
+
+ + +{#if showAttention && attentionQueue.length > 0} +
+ {#each attentionQueue as item (item.projectId)} + + {/each} +
+{/if} + + diff --git a/v2/src/lib/components/Terminal/AgentPreviewPane.svelte b/v2/src/lib/components/Terminal/AgentPreviewPane.svelte new file mode 100644 index 0000000..f65bb90 --- /dev/null +++ b/v2/src/lib/components/Terminal/AgentPreviewPane.svelte @@ -0,0 +1,197 @@ + + +
+ + diff --git a/v2/src/lib/components/Terminal/TerminalPane.svelte b/v2/src/lib/components/Terminal/TerminalPane.svelte new file mode 100644 index 0000000..258c352 --- /dev/null +++ b/v2/src/lib/components/Terminal/TerminalPane.svelte @@ -0,0 +1,134 @@ + + +
+ + diff --git a/v2/src/lib/components/Workspace/AgentCard.svelte b/v2/src/lib/components/Workspace/AgentCard.svelte new file mode 100644 index 0000000..e511f5e --- /dev/null +++ b/v2/src/lib/components/Workspace/AgentCard.svelte @@ -0,0 +1,100 @@ + + +
e.key === 'Enter' && onclick?.()}> +
+ + {session.status} + {#if session.costUsd > 0} + ${session.costUsd.toFixed(4)} + {/if} +
+
{truncatedPrompt}
+ {#if session.status === 'running'} +
+ {session.numTurns} turns +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte new file mode 100644 index 0000000..9c26677 --- /dev/null +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -0,0 +1,220 @@ + + +
+ {#if loading} +
Loading session...
+ {:else} + + {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/ArchitectureTab.svelte b/v2/src/lib/components/Workspace/ArchitectureTab.svelte new file mode 100644 index 0000000..1e04907 --- /dev/null +++ b/v2/src/lib/components/Workspace/ArchitectureTab.svelte @@ -0,0 +1,493 @@ + + +
+
+ + + {#if showNewForm} +
+ +
+ {#each Object.entries(DIAGRAM_TEMPLATES) as [name, template]} + + {/each} +
+
+ {/if} + +
+ {#each diagrams as file (file.path)} + + {/each} + {#if diagrams.length === 0 && !showNewForm} +
+ No diagrams yet. The Architect agent creates .puml files in {ARCH_DIR}/ +
+ {/if} +
+
+ +
+ {#if !selectedFile} +
+ Select a diagram or create a new one +
+ {:else if loading} +
Loading...
+ {:else if error} +
{error}
+ {:else} +
+ {selectedFile?.split('/').pop()} + + {#if editing} + + {/if} +
+ + {#if editing} + + {:else if svgUrl} +
+ PlantUML diagram +
+ {/if} + {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/CodeEditor.svelte b/v2/src/lib/components/Workspace/CodeEditor.svelte new file mode 100644 index 0000000..5a124ee --- /dev/null +++ b/v2/src/lib/components/Workspace/CodeEditor.svelte @@ -0,0 +1,332 @@ + + +
+ + diff --git a/v2/src/lib/components/Workspace/CommandPalette.svelte b/v2/src/lib/components/Workspace/CommandPalette.svelte new file mode 100644 index 0000000..2b25d4e --- /dev/null +++ b/v2/src/lib/components/Workspace/CommandPalette.svelte @@ -0,0 +1,159 @@ + + +{#if open} + +
+ +
e.stopPropagation()} onkeydown={handleKeydown}> + +
    + {#each filtered as group} +
  • + +
  • + {/each} + {#if filtered.length === 0} +
  • No groups match "{query}"
  • + {/if} +
+
+
+{/if} + + diff --git a/v2/src/lib/components/Workspace/CommsTab.svelte b/v2/src/lib/components/Workspace/CommsTab.svelte new file mode 100644 index 0000000..b007d05 --- /dev/null +++ b/v2/src/lib/components/Workspace/CommsTab.svelte @@ -0,0 +1,676 @@ + + +
+ +
+
+ Messages +
+ + + + + + {#if channels.length > 0 || showNewChannel} +
+ Channels + +
+ {#each channels as channel (channel.id)} + + {/each} + {#if showNewChannel} +
+ { if (e.key === 'Enter') handleCreateChannel(); }} + /> + +
+ {/if} + {:else} +
+ Channels + +
+ {#if showNewChannel} +
+ { if (e.key === 'Enter') handleCreateChannel(); }} + /> + +
+ {/if} + {/if} + + +
+ Direct Messages +
+ {#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)} + {@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'} + + {/each} +
+ + +
+
+ {#if currentView.type === 'feed'} + 📡 Activity Feed + All agent communication + {:else if currentView.type === 'dm'} + DM with {currentView.agentName} + {:else if currentView.type === 'channel'} + # {currentView.channelName} + {/if} +
+ +
+ {#if currentView.type === 'feed'} + {#if feedMessages.length === 0} +
No messages yet. Agents haven't started communicating.
+ {:else} + {#each [...feedMessages].reverse() as msg (msg.id)} +
+
+ {getAgentIcon(msg.senderRole)} + {msg.senderName} + + {msg.recipientName} + {formatTime(msg.createdAt)} +
+
{msg.content}
+
+ {/each} + {/if} + + {:else if currentView.type === 'dm'} + {#if dmMessages.length === 0} +
No messages yet. Start the conversation!
+ {:else} + {#each dmMessages as msg (msg.id)} + {@const isMe = msg.from_agent === ADMIN_ID} +
+
+ {isMe ? 'You' : (msg.sender_name ?? msg.from_agent)} + {formatTime(msg.created_at)} +
+
{msg.content}
+
+ {/each} + {/if} + + {:else if currentView.type === 'channel'} + {#if channelMessages.length === 0} +
No messages in this channel yet.
+ {:else} + {#each channelMessages as msg (msg.id)} + {@const isMe = msg.fromAgent === ADMIN_ID} +
+
+ {getAgentIcon(msg.senderRole)} + {isMe ? 'You' : msg.senderName} + {formatTime(msg.createdAt)} +
+
{msg.content}
+
+ {/each} + {/if} + {/if} +
+ + {#if currentView.type !== 'feed'} +
+ + +
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte new file mode 100644 index 0000000..3f48de6 --- /dev/null +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -0,0 +1,1703 @@ + + +
+ {#if !session} +
+
+ + + + +
+

No active session

+

Start an agent session to see context window analysis

+
+ {:else} + +
+ + + +
+ + +
+ +
+
+ {formatTokens(totalCost.inputTokens)} + input +
+
+ {formatTokens(totalCost.outputTokens)} + output +
+
+ {session.numTurns} + turns +
+
+ {formatCost(totalCost.costUsd)} + cost +
+
+ {formatDuration(session.durationMs)} + time +
+
+ {session.status} +
+ {#if compactions.length > 0} +
+ {compactions.length}× + compacted +
+ {/if} +
+ + +
+
+ Context Window + {formatTokens(totalCost.inputTokens)} / {formatTokens(CONTEXT_WINDOW)} +
+
+ {#each categories as cat} +
+ {/each} +
+
+
+ {#each categories as cat} +
+ + {cat.label} + {formatTokens(cat.tokens)} +
+ {/each} +
+
+ + + {#if anchors.length > 0} +
+
+ Session Anchors + {anchors.length} + {#if injectableAnchors.length > 0} + + {injectableAnchors.length} injectable + + {/if} +
+ + +
+
+ Anchor Budget + {formatTokens(anchorTokens)} / {formatTokens(anchorBudget)} +
+
+
75} + class:full={anchorBudgetPct >= 100} + style="width: {anchorBudgetPct}%" + >
+
+
+ + +
+ {#each anchors as anchor (anchor.id)} +
+ + {anchorTypeLabel(anchor.anchorType)} + {anchor.content.split('\n')[0].slice(0, 60)}{anchor.content.length > 60 ? '...' : ''} + {formatTokens(anchor.estimatedTokens)} +
+ {#if anchor.anchorType === 'pinned'} + + {:else if anchor.anchorType === 'promoted'} + + {/if} + +
+
+ {/each} +
+
+ {/if} + + + {#if fileRefs.length > 0} +
+
+ Files Touched + {fileRefs.length} +
+
+ {#each fileRefs.slice(0, 30) as ref (ref.path)} +
+
+ {#each Array.from(ref.ops) as op} + {op[0].toUpperCase()} + {/each} +
+ {ref.shortName} + {ref.count}× +
+ {/each} + {#if fileRefs.length > 30} +
+{fileRefs.length - 30} more
+ {/if} +
+
+ {/if} + + +
+
+ Turns + {turns.length} +
+
+ {#each turns as turn (turn.index)} +
+ + + {#if expandedTurns.has(turn.index)} +
+ {#each turn.messages as msg} + {#if msg.type !== 'cost' && msg.type !== 'status' && msg.type !== 'init'} +
+ {msgTypeLabel(msg.type)} + ~{formatTokens(estimateTokens(msg))} + {#if msg.type === 'tool_call'} + {@const tc = msg.content as ToolCallContent} + {tc.name} + {/if} +
+ {/if} + {/each} +
+ {/if} +
+ {/each} + {#if turns.length === 0} +
No turns yet
+ {/if} +
+
+
+ + +
+ {#if astTree.length === 0} +
No conversation data yet
+ {:else} +
+ {#each astTree as turnNode (turnNode.id)} + {@const result = layoutAst(turnNode, 8, 8)} + {@const svgW = astSvgWidth(result.layout)} + {@const svgH = Math.max(50, result.height + 20)} +
+
+ {turnNode.label} + {formatTokens(turnNode.tokens)} +
+
+ + {#snippet renderAstNode(layout: AstLayout)} + + {#each layout.children as child} + + {/each} + + + + + + + {truncateText(layout.node.label, 12)} + + {#if layout.node.tokens > 0} + {formatTokens(layout.node.tokens)} + {/if} + + + {#if layout.node.detail} + {layout.node.detail} + {/if} + + {#each layout.children as child} + {@render renderAstNode(child)} + {/each} + {/snippet} + + {@render renderAstNode(result.layout)} + +
+
+ {/each} +
+ {/if} +
+ + +
+ {#if toolGraph.nodes.length === 0} +
No tool calls yet
+ {:else} + {@const maxY = Math.max(...toolGraph.nodes.map(n => n.y)) + 40} +
+ + + {#each toolGraph.edges as edge} + {@const fromNode = toolGraph.nodes.find(n => n.id === edge.from)} + {@const toNode = toolGraph.nodes.find(n => n.id === edge.to)} + {#if fromNode && toNode} + + {/if} + {/each} + + + {#each toolGraph.nodes as node (node.id)} + {#if node.type === 'tool'} + + + {node.label} + {node.count}× + {:else} + + + {truncateText(node.label, 16)} + {node.count}× + {/if} + {/each} + + + Tools + Files + +
+ {/if} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/CsvTable.svelte b/v2/src/lib/components/Workspace/CsvTable.svelte new file mode 100644 index 0000000..5b0a12d --- /dev/null +++ b/v2/src/lib/components/Workspace/CsvTable.svelte @@ -0,0 +1,253 @@ + + +
+
+ + {totalRows} row{totalRows !== 1 ? 's' : ''} × {colCount} col{colCount !== 1 ? 's' : ''} + + {filename} +
+ +
+ + + + + {#each headers as header, i} + + {/each} + + + + {#each sortedRows as row, rowIdx (rowIdx)} + + + {#each { length: colCount } as _, colIdx} + + {/each} + + {/each} + +
# toggleSort(i)} class="sortable"> + {header}{sortIndicator(i)} +
{rowIdx + 1}{row[colIdx] ?? ''}
+
+
+ + diff --git a/v2/src/lib/components/Workspace/DocsTab.svelte b/v2/src/lib/components/Workspace/DocsTab.svelte new file mode 100644 index 0000000..299adf9 --- /dev/null +++ b/v2/src/lib/components/Workspace/DocsTab.svelte @@ -0,0 +1,160 @@ + + +
+ + +
+ {#if selectedPath} + + {:else} +
Select a document from the sidebar
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/FilesTab.svelte b/v2/src/lib/components/Workspace/FilesTab.svelte new file mode 100644 index 0000000..0cc4f37 --- /dev/null +++ b/v2/src/lib/components/Workspace/FilesTab.svelte @@ -0,0 +1,700 @@ + + +
+ {#if !sidebarCollapsed} + + +
+ {:else} + + {/if} + +
+ + {#if fileTabs.length > 0} +
+ {#each fileTabs as tab (tab.path)} +
activeTabPath = tab.path} + ondblclick={() => { tab.pinned = true; }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activeTabPath = tab.path; } }} + role="tab" + tabindex="0" + > + + {tab.name}{#if tab.dirty}{/if} + + +
+ {/each} +
+ {/if} + + + {#if fileLoading && activeTabPath && !activeTab?.content} +
Loading…
+ {:else if !activeTab} +
Select a file to view
+ {:else if activeTab.content?.type === 'TooLarge'} +
+ File too large + {formatSize(activeTab.content.size)} +
+ {:else if activeTab.content?.type === 'Binary'} + {#if isPdfExt(activeTab.path)} + {#key activeTabPath} + + {/key} + {:else if isImageExt(activeTab.path)} +
+ {activeTab.name} +
+ {:else} +
{activeTab.content.message}
+ {/if} + {:else if activeTab.content?.type === 'Text'} + {#if isCsvLang(activeTab.content.lang)} + {#key activeTabPath} + + {/key} + {:else} + {#key activeTabPath} + handleEditorChange(activeTab!.path, c)} + onsave={saveActiveTab} + onblur={() => handleEditorBlur(activeTab!.path)} + /> + {/key} + {/if} + {/if} + + {#if activeTab} +
+ {activeTab.path} + {#if activeTab.dirty} + (unsaved) + {/if} +
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/GlobalTabBar.svelte b/v2/src/lib/components/Workspace/GlobalTabBar.svelte new file mode 100644 index 0000000..36c555b --- /dev/null +++ b/v2/src/lib/components/Workspace/GlobalTabBar.svelte @@ -0,0 +1,102 @@ + + + + + diff --git a/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte b/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte new file mode 100644 index 0000000..67a2ffb --- /dev/null +++ b/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte @@ -0,0 +1,427 @@ + + +{#if hasAgents} +
+ + + {#if !collapsed} + {#if agents.length > 0} +
+ Tier 1 — Management +
+
+ {#each agents as agent (agent.id)} + {@const status = getStatus(agent.id)} +
setActiveProject(agent.id)} + role="button" + tabindex="0" + > +
+ {ROLE_ICONS[agent.role] ?? '🤖'} + {agent.name} + +
+
+ {ROLE_LABELS[agent.role] ?? agent.role} + {#if agent.model} + {agent.model} + {/if} + {#if getUnread(agent.id) > 0} + {getUnread(agent.id)} + {/if} +
+
+ +
+
+ {/each} +
+ {/if} + + {#if projects.length > 0} +
+
+ Tier 2 — Execution +
+
+ {#each projects as project (project.id)} + {@const status = getStatus(project.id)} +
setActiveProject(project.id)} + role="button" + tabindex="0" + > +
+ {project.icon} + {project.name} + +
+
+ Project + {#if getUnread(project.id) > 0} + {getUnread(project.id)} + {/if} +
+
+ {/each} +
+ {/if} + {/if} +
+{/if} + + diff --git a/v2/src/lib/components/Workspace/MemoriesTab.svelte b/v2/src/lib/components/Workspace/MemoriesTab.svelte new file mode 100644 index 0000000..1b7bab2 --- /dev/null +++ b/v2/src/lib/components/Workspace/MemoriesTab.svelte @@ -0,0 +1,375 @@ + + +
+ {#if !adapter} +
+
+ + + + +
+

No memory adapter configured

+

Register a memory adapter (e.g. Memora) to browse knowledge here.

+
+ {:else} +
+

{adapterName}

+ {total} memories +
+ {#each getAvailableAdapters() as a (a.name)} + + {/each} +
+
+ + + + {#if error} +
{error}
+ {/if} + +
+ {#if loading} +
Loading…
+ {:else if nodes.length === 0} +
No memories found
+ {:else} + {#each nodes as node (node.id)} + + {/each} + {/if} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/PdfViewer.svelte b/v2/src/lib/components/Workspace/PdfViewer.svelte new file mode 100644 index 0000000..b63159d --- /dev/null +++ b/v2/src/lib/components/Workspace/PdfViewer.svelte @@ -0,0 +1,292 @@ + + +
+
+ + {#if loading} + Loading… + {:else if error} + Error + {:else} + {pageCount} page{pageCount !== 1 ? 's' : ''} + {/if} + +
+ + + +
+
+ + {#if error} +
{error}
+ {:else} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte new file mode 100644 index 0000000..c3a0813 --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -0,0 +1,384 @@ + + +
+ + +
+ + + + + + + {#if isAgent && agentRole === 'manager'} + + {/if} + {#if isAgent && agentRole === 'architect'} + + {/if} + {#if isAgent && agentRole === 'tester'} + + + {/if} +
+ +
+ +
+ mainSessionId = id} /> + {#if mainSessionId} + + {/if} +
+
+ +
+
+ +
+ + + {#if everActivated['files']} +
+ +
+ {/if} + {#if everActivated['ssh']} +
+ +
+ {/if} + {#if everActivated['memories']} +
+ +
+ {/if} + {#if everActivated['tasks'] && activeGroup} +
+ +
+ {/if} + {#if everActivated['architecture']} +
+ +
+ {/if} + {#if everActivated['selenium']} +
+ +
+ {/if} + {#if everActivated['tests']} +
+ +
+ {/if} +
+ +
+ + + {#if terminalExpanded} +
+ +
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/ProjectFiles.svelte b/v2/src/lib/components/Workspace/ProjectFiles.svelte new file mode 100644 index 0000000..1d3d806 --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectFiles.svelte @@ -0,0 +1,152 @@ + + +
+ + +
+ {#if selectedPath} + + {:else} +
Select a file
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/ProjectGrid.svelte b/v2/src/lib/components/Workspace/ProjectGrid.svelte new file mode 100644 index 0000000..e8b6da2 --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectGrid.svelte @@ -0,0 +1,86 @@ + + +
+ {#each projects as project, i (project.id)} +
+ setActiveProject(project.id)} + /> +
+ {/each} + + {#if projects.length === 0} +
+ No enabled projects in this group. Go to Settings to add projects. +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/ProjectHeader.svelte b/v2/src/lib/components/Workspace/ProjectHeader.svelte new file mode 100644 index 0000000..481c5fd --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectHeader.svelte @@ -0,0 +1,288 @@ + + + + · + {/if} + {#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0} + + · + {/if} + {#if contextPct !== null && contextPct > 0} + ctx {contextPct}% + · + {/if} + {#if health && health.burnRatePerHour > 0.01} + + ${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr + + · + {/if} + {displayCwd()} + {#if project.profile} + · + {project.profile} + {/if} + + + + diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte new file mode 100644 index 0000000..4fc67a5 --- /dev/null +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -0,0 +1,1971 @@ + + + + +
+
+

Appearance

+
+
+ Theme +
+ + {#if themeDropdownOpen} + + {/if} +
+
+ +
+ UI Font +
+ +
+ + handleUiFontSizeChange((e.target as HTMLInputElement).value)} + /> + px + +
+
+
+ +
+ Terminal Font +
+ +
+ + handleTermFontSizeChange((e.target as HTMLInputElement).value)} + /> + px + +
+
+
+
+ Project max aspect ratio +
+ + handleAspectChange((e.target as HTMLInputElement).value)} + /> + w:h + +
+
+
+
+ +
+

Defaults

+
+
+ + { defaultShell = (e.target as HTMLInputElement).value; saveGlobalSetting('default_shell', defaultShell); }} + /> +
+
+ +
+ { defaultCwd = (e.target as HTMLInputElement).value; saveGlobalSetting('default_cwd', defaultCwd); }} + /> + +
+
+
+ +

Editor

+
+
+ + Auto-save files when the editor loses focus +
+
+
+ +
+

Providers

+
+ {#each registeredProviders as provider} +
+ + {#if expandedProvider === provider.id} +
+
+ +
+ {#if provider.capabilities.hasModelSelection} +
+ Default model + setProviderModel(provider.id, (e.target as HTMLInputElement).value)} + /> +
+ {/if} +
+ Capabilities +
+ {#if provider.capabilities.hasProfiles}Profiles{/if} + {#if provider.capabilities.hasSkills}Skills{/if} + {#if provider.capabilities.supportsSubagents}Subagents{/if} + {#if provider.capabilities.supportsCost}Cost tracking{/if} + {#if provider.capabilities.supportsResume}Resume{/if} + {#if provider.capabilities.hasSandbox}Sandbox{/if} +
+
+
+ {/if} +
+ {/each} +
+
+ +
+

Groups

+
+ {#each groups as group} +
+ + {group.projects.length} projects + {#if groups.length > 1} + + {/if} +
+ {/each} +
+ +
+ + +
+
+ + {#if activeGroup && (activeGroup.agents?.length ?? 0) > 0} +
+

Agents in "{activeGroup.name}"

+ +
+ {#each activeGroup.agents ?? [] as agent (agent.id)} +
+
+ {AGENT_ROLE_ICONS[agent.role] ?? '🤖'} + updateAgent(activeGroupId, agent.id, { name: (e.target as HTMLInputElement).value })} + /> + {agent.role} + +
+ +
+ + + Working Directory + +
+ updateAgent(activeGroupId, agent.id, { cwd: (e.target as HTMLInputElement).value || undefined })} + /> + +
+
+ +
+ + + Model + + updateAgent(activeGroupId, agent.id, { model: (e.target as HTMLInputElement).value || undefined })} + /> +
+ + {#if agent.role === 'manager'} +
+ + + Wake Interval + +
+ updateAgent(activeGroupId, agent.id, { wakeIntervalMin: parseInt((e.target as HTMLInputElement).value) })} + /> + {agent.wakeIntervalMin ?? 3} min +
+
+ {/if} + +
+ + + Custom Context + + +
+ +
+ + + Preview full introductory prompt + +
{generateAgentPrompt({
+                role: agent.role as GroupAgentRole,
+                agentId: agent.id,
+                agentName: agent.name,
+                group: activeGroup,
+                customPrompt: agent.systemPrompt,
+              })}
+
+
+ {/each} +
+
+ {/if} + + {#if activeGroup} +
+

Projects in "{activeGroup.name}"

+ +
+ {#each activeGroup.projects as project} +
+
+
+ + {#if iconPickerOpenFor === project.id} +
+ {#each PROJECT_ICONS as emoji} + + {/each} +
+ {/if} +
+
+ updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })} + /> +
+ +
+ +
+ + + Path + +
+ updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })} + /> + +
+
+ +
+ + + Account + + {#if profiles.length > 1} +
+ + {#if profileDropdownOpenFor === project.id} + + {/if} +
+ {:else} + {getProfileLabel(project.profile)} + {/if} +
+ + {#if registeredProviders.length > 1} +
+ + + Provider + +
+ + {#if providerDropdownOpenFor === project.id} + + {/if} +
+
+ {/if} + +
+ + + Anchor Budget + +
+ { + const idx = parseInt((e.target as HTMLInputElement).value); + updateProject(activeGroupId, project.id, { anchorBudgetScale: ANCHOR_BUDGET_SCALES[idx] }); + }} + /> + {ANCHOR_BUDGET_SCALE_LABELS[project.anchorBudgetScale ?? 'medium']} +
+
+ +
+ + + Worktree Isolation + + +
+ +
+ + + Stall Threshold + +
+ { + updateProject(activeGroupId, project.id, { stallThresholdMin: parseInt((e.target as HTMLInputElement).value) }); + }} + /> + {project.stallThresholdMin ?? 15} min +
+
+ +
+ + + Custom Context + + +
+ + +
+ {/each} +
+ + {#if activeGroup.projects.length < 5} +
+
+ +
+ + +
+ +
+
+ {:else} +

Maximum 5 projects per group reached.

+ {/if} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/SshTab.svelte b/v2/src/lib/components/Workspace/SshTab.svelte new file mode 100644 index 0000000..91c698a --- /dev/null +++ b/v2/src/lib/components/Workspace/SshTab.svelte @@ -0,0 +1,425 @@ + + +
+
+

SSH Connections

+ +
+ + {#if showForm} +
+
{editing ? 'Edit Connection' : 'New Connection'}
+
+ + + + + + +
+
+ + +
+
+ {/if} + +
+ {#if loading} +
Loading…
+ {:else if sessions.length === 0 && !showForm} +
+

No SSH connections configured.

+

Add a connection to launch it as a terminal in the Model tab.

+
+ {:else} + {#each sessions as session (session.id)} +
+
+ {session.name} + {session.username}@{session.host}:{session.port} + {#if session.folder} + {session.folder} + {/if} +
+
+ + + +
+
+ {/each} + {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/TaskBoardTab.svelte b/v2/src/lib/components/Workspace/TaskBoardTab.svelte new file mode 100644 index 0000000..9ca1299 --- /dev/null +++ b/v2/src/lib/components/Workspace/TaskBoardTab.svelte @@ -0,0 +1,572 @@ + + +
+
+ Task Board + + {pendingCount === 0 ? 'All done' : `${pendingCount} pending`} + + +
+ + {#if showAddForm} +
+ { if (e.key === 'Enter') handleAddTask(); }} + /> + +
+ + +
+
+ {/if} + + {#if loading} +
Loading tasks...
+ {:else if error} +
{error}
+ {:else} +
+ {#each STATUSES as status} +
+
+ {STATUS_ICONS[status]} + {STATUS_LABELS[status]} + {tasksByStatus[status].length} +
+
+ {#each tasksByStatus[status] as task (task.id)} +
+ + + {#if expandedTaskId === task.id} +
+ {#if task.description} +

{task.description}

+ {/if} + +
+ {#each STATUSES as s} + + {/each} +
+ + {#if taskComments.length > 0} +
+ {#each taskComments as comment} +
+ {comment.agentId} + {comment.content} +
+ {/each} +
+ {/if} + +
+ { if (e.key === 'Enter') handleAddComment(); }} + /> +
+ + +
+ {/if} +
+ {/each} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte b/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte new file mode 100644 index 0000000..5707693 --- /dev/null +++ b/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte @@ -0,0 +1,91 @@ + + +{#if hasAgents} +
+ + + {#if expanded} +
+ {#each childSessions as child (child.id)} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/v2/src/lib/components/Workspace/TerminalTabs.svelte b/v2/src/lib/components/Workspace/TerminalTabs.svelte new file mode 100644 index 0000000..d372359 --- /dev/null +++ b/v2/src/lib/components/Workspace/TerminalTabs.svelte @@ -0,0 +1,275 @@ + + +
+
+ {#each tabs as tab (tab.id)} + + {/each} + + {#if agentSessionId} + + {/if} +
+ +
+ {#each tabs as tab (tab.id)} +
+ {#if tab.type === 'agent-preview' && tab.agentSessionId} + {#if activeTabId === tab.id} + + {/if} + {:else if tab.type === 'ssh' && sshArgsCache[tab.id]} + handleTabExit(tab.id)} + /> + {:else if tab.type === 'shell'} + handleTabExit(tab.id)} + /> + {/if} +
+ {/each} + + {#if tabs.length === 0} +
+ +
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/TestingTab.svelte b/v2/src/lib/components/Workspace/TestingTab.svelte new file mode 100644 index 0000000..52ea06b --- /dev/null +++ b/v2/src/lib/components/Workspace/TestingTab.svelte @@ -0,0 +1,427 @@ + + +
+ {#if mode === 'selenium'} + +
+
+ +
+ {#each screenshots as path} + + {/each} + {#if screenshots.length === 0} +
+ No screenshots yet. The Tester agent saves screenshots to {SCREENSHOTS_DIR}/ +
+ {/if} +
+ +
+ +
+ {#each seleniumLog as line} +
{line}
+ {/each} + {#if seleniumLog.length === 0} +
No log entries
+ {/if} +
+
+
+ +
+ {#if selectedScreenshot} +
+ Selenium screenshot +
+ {:else} +
+ Selenium screenshots will appear here during testing. +
+ The Tester agent uses Selenium WebDriver for UI testing. +
+ {/if} +
+
+ + {:else} + +
+
+ +
+ {#each testFiles as file (file.path)} + + {/each} + {#if testFiles.length === 0} +
+ No test files found. The Tester agent creates tests in standard directories (tests/, test/, spec/). +
+ {/if} +
+
+ +
+ {#if selectedTestFile} +
+ {selectedTestFile.split('/').pop()} +
+
{testOutput}
+ {:else} +
+ Select a test file to view its contents. +
+ The Tester agent runs tests via the terminal. +
+ {/if} +
+
+ {/if} +
+ + diff --git a/v2/src/lib/providers/claude.ts b/v2/src/lib/providers/claude.ts new file mode 100644 index 0000000..c458651 --- /dev/null +++ b/v2/src/lib/providers/claude.ts @@ -0,0 +1,20 @@ +// Claude Provider — metadata and capabilities for Claude Code + +import type { ProviderMeta } from './types'; + +export const CLAUDE_PROVIDER: ProviderMeta = { + id: 'claude', + name: 'Claude Code', + description: 'Anthropic Claude Code agent via SDK', + capabilities: { + hasProfiles: true, + hasSkills: true, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: true, + supportsCost: true, + supportsResume: true, + }, + sidecarRunner: 'claude-runner.mjs', + defaultModel: 'claude-opus-4-6', +}; diff --git a/v2/src/lib/providers/codex.ts b/v2/src/lib/providers/codex.ts new file mode 100644 index 0000000..f5b6d8b --- /dev/null +++ b/v2/src/lib/providers/codex.ts @@ -0,0 +1,20 @@ +// Codex Provider — metadata and capabilities for OpenAI Codex CLI + +import type { ProviderMeta } from './types'; + +export const CODEX_PROVIDER: ProviderMeta = { + id: 'codex', + name: 'Codex CLI', + description: 'OpenAI Codex CLI agent via SDK', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: true, + supportsSubagents: false, + supportsCost: false, + supportsResume: true, + }, + sidecarRunner: 'codex-runner.mjs', + defaultModel: 'gpt-5.4', +}; diff --git a/v2/src/lib/providers/ollama.ts b/v2/src/lib/providers/ollama.ts new file mode 100644 index 0000000..9d58419 --- /dev/null +++ b/v2/src/lib/providers/ollama.ts @@ -0,0 +1,20 @@ +// Ollama Provider — metadata and capabilities for local Ollama models + +import type { ProviderMeta } from './types'; + +export const OLLAMA_PROVIDER: ProviderMeta = { + id: 'ollama', + name: 'Ollama', + description: 'Local Ollama models via REST API', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: false, + supportsCost: false, + supportsResume: false, + }, + sidecarRunner: 'ollama-runner.mjs', + defaultModel: 'qwen3:8b', +}; diff --git a/v2/src/lib/providers/registry.svelte.ts b/v2/src/lib/providers/registry.svelte.ts new file mode 100644 index 0000000..90e80ac --- /dev/null +++ b/v2/src/lib/providers/registry.svelte.ts @@ -0,0 +1,26 @@ +// Provider Registry — singleton registry of available providers (Svelte 5 runes) + +import type { ProviderId, ProviderMeta } from './types'; + +const providers = $state(new Map()); + +export function registerProvider(meta: ProviderMeta): void { + providers.set(meta.id, meta); +} + +export function getProvider(id: ProviderId): ProviderMeta | undefined { + return providers.get(id); +} + +export function getProviders(): ProviderMeta[] { + return Array.from(providers.values()); +} + +export function getDefaultProviderId(): ProviderId { + return 'claude'; +} + +/** Check if a specific provider is registered */ +export function hasProvider(id: ProviderId): boolean { + return providers.has(id); +} diff --git a/v2/src/lib/providers/types.ts b/v2/src/lib/providers/types.ts new file mode 100644 index 0000000..63401eb --- /dev/null +++ b/v2/src/lib/providers/types.ts @@ -0,0 +1,34 @@ +// Provider abstraction types — defines the interface for multi-provider agent support + +export type ProviderId = 'claude' | 'codex' | 'ollama'; + +/** What a provider can do — UI gates features on these flags */ +export interface ProviderCapabilities { + hasProfiles: boolean; + hasSkills: boolean; + hasModelSelection: boolean; + hasSandbox: boolean; + supportsSubagents: boolean; + supportsCost: boolean; + supportsResume: boolean; +} + +/** Static metadata about a provider */ +export interface ProviderMeta { + id: ProviderId; + name: string; + description: string; + capabilities: ProviderCapabilities; + /** Name of the sidecar runner file (e.g. 'claude-runner.mjs') */ + sidecarRunner: string; + /** Default model identifier, if applicable */ + defaultModel?: string; +} + +/** Per-provider configuration (stored in settings) */ +export interface ProviderSettings { + enabled: boolean; + defaultModel?: string; + /** Provider-specific config blob */ + config: Record; +} diff --git a/v2/src/lib/stores/agents.svelte.ts b/v2/src/lib/stores/agents.svelte.ts new file mode 100644 index 0000000..b301f6a --- /dev/null +++ b/v2/src/lib/stores/agents.svelte.ts @@ -0,0 +1,148 @@ +// Agent tracking state — Svelte 5 runes +// Manages agent session lifecycle and message history + +import type { AgentMessage } from '../adapters/claude-messages'; + +export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error'; + +export interface AgentSession { + id: string; + sdkSessionId?: string; + status: AgentStatus; + model?: string; + prompt: string; + messages: AgentMessage[]; + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + durationMs: number; + error?: string; + // Agent Teams: parent/child hierarchy + parentSessionId?: string; + parentToolUseId?: string; + childSessionIds: string[]; +} + +let sessions = $state([]); + +export function getAgentSessions(): AgentSession[] { + return sessions; +} + +export function getAgentSession(id: string): AgentSession | undefined { + return sessions.find(s => s.id === id); +} + +export function createAgentSession(id: string, prompt: string, parent?: { sessionId: string; toolUseId: string }): void { + sessions.push({ + id, + status: 'starting', + prompt, + messages: [], + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + numTurns: 0, + durationMs: 0, + parentSessionId: parent?.sessionId, + parentToolUseId: parent?.toolUseId, + childSessionIds: [], + }); + + // Register as child of parent + if (parent) { + const parentSession = sessions.find(s => s.id === parent.sessionId); + if (parentSession) { + parentSession.childSessionIds.push(id); + } + } +} + +export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.status = status; + if (error) session.error = error; +} + +export function setAgentSdkSessionId(id: string, sdkSessionId: string): void { + const session = sessions.find(s => s.id === id); + if (session) session.sdkSessionId = sdkSessionId; +} + +export function setAgentModel(id: string, model: string): void { + const session = sessions.find(s => s.id === id); + if (session) session.model = model; +} + +export function appendAgentMessage(id: string, message: AgentMessage): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.messages.push(message); +} + +export function appendAgentMessages(id: string, messages: AgentMessage[]): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.messages.push(...messages); +} + +export function updateAgentCost( + id: string, + cost: { costUsd: number; inputTokens: number; outputTokens: number; numTurns: number; durationMs: number }, +): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + // Accumulate across query invocations (each resume produces its own cost event) + session.costUsd += cost.costUsd; + session.inputTokens += cost.inputTokens; + session.outputTokens += cost.outputTokens; + session.numTurns += cost.numTurns; + session.durationMs += cost.durationMs; +} + +/** Find a child session that was spawned by a specific tool_use */ +export function findChildByToolUseId(parentId: string, toolUseId: string): AgentSession | undefined { + return sessions.find(s => s.parentSessionId === parentId && s.parentToolUseId === toolUseId); +} + +/** Get all child sessions for a given parent */ +export function getChildSessions(parentId: string): AgentSession[] { + return sessions.filter(s => s.parentSessionId === parentId); +} + +/** Aggregate cost of a session plus all its children (recursive) */ +export function getTotalCost(id: string): { costUsd: number; inputTokens: number; outputTokens: number } { + const session = sessions.find(s => s.id === id); + if (!session) return { costUsd: 0, inputTokens: 0, outputTokens: 0 }; + + let costUsd = session.costUsd; + let inputTokens = session.inputTokens; + let outputTokens = session.outputTokens; + + for (const childId of session.childSessionIds) { + const childCost = getTotalCost(childId); + costUsd += childCost.costUsd; + inputTokens += childCost.inputTokens; + outputTokens += childCost.outputTokens; + } + + return { costUsd, inputTokens, outputTokens }; +} + +export function clearAllAgentSessions(): void { + sessions = []; +} + +export function removeAgentSession(id: string): void { + // Also remove from parent's childSessionIds + const session = sessions.find(s => s.id === id); + if (session?.parentSessionId) { + const parent = sessions.find(s => s.id === session.parentSessionId); + if (parent) { + parent.childSessionIds = parent.childSessionIds.filter(cid => cid !== id); + } + } + sessions = sessions.filter(s => s.id !== id); +} diff --git a/v2/src/lib/stores/anchors.svelte.ts b/v2/src/lib/stores/anchors.svelte.ts new file mode 100644 index 0000000..4b4144a --- /dev/null +++ b/v2/src/lib/stores/anchors.svelte.ts @@ -0,0 +1,129 @@ +// Session Anchors store — Svelte 5 runes +// Per-project anchor management with re-injection support + +import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors'; +import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors'; +import { + saveSessionAnchors, + loadSessionAnchors, + deleteSessionAnchor, + updateAnchorType as updateAnchorTypeBridge, +} from '../adapters/anchors-bridge'; + +// Per-project anchor state +const projectAnchors = $state>(new Map()); + +// Track which projects have had auto-anchoring triggered (prevents re-anchoring on subsequent compactions) +const autoAnchoredProjects = $state>(new Set()); + +export function getProjectAnchors(projectId: string): SessionAnchor[] { + return projectAnchors.get(projectId) ?? []; +} + +/** Get only re-injectable anchors (auto + promoted, not pinned-only) */ +export function getInjectableAnchors(projectId: string): SessionAnchor[] { + const anchors = projectAnchors.get(projectId) ?? []; + return anchors.filter(a => a.anchorType === 'auto' || a.anchorType === 'promoted'); +} + +/** Total estimated tokens for re-injectable anchors */ +export function getInjectableTokenCount(projectId: string): number { + return getInjectableAnchors(projectId).reduce((sum, a) => sum + a.estimatedTokens, 0); +} + +/** Check if auto-anchoring has already run for this project */ +export function hasAutoAnchored(projectId: string): boolean { + return autoAnchoredProjects.has(projectId); +} + +/** Mark project as having been auto-anchored */ +export function markAutoAnchored(projectId: string): void { + autoAnchoredProjects.add(projectId); +} + +/** Add anchors to a project (in-memory + persist) */ +export async function addAnchors(projectId: string, anchors: SessionAnchor[]): Promise { + const existing = projectAnchors.get(projectId) ?? []; + const updated = [...existing, ...anchors]; + projectAnchors.set(projectId, updated); + + // Persist to SQLite + const records: SessionAnchorRecord[] = anchors.map(a => ({ + id: a.id, + project_id: a.projectId, + message_id: a.messageId, + anchor_type: a.anchorType, + content: a.content, + estimated_tokens: a.estimatedTokens, + turn_index: a.turnIndex, + created_at: a.createdAt, + })); + + try { + await saveSessionAnchors(records); + } catch (e) { + console.warn('Failed to persist anchors:', e); + } +} + +/** Remove a single anchor */ +export async function removeAnchor(projectId: string, anchorId: string): Promise { + const existing = projectAnchors.get(projectId) ?? []; + projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId)); + + try { + await deleteSessionAnchor(anchorId); + } catch (e) { + console.warn('Failed to delete anchor:', e); + } +} + +/** Change anchor type (pinned <-> promoted) */ +export async function changeAnchorType(projectId: string, anchorId: string, newType: AnchorType): Promise { + const existing = projectAnchors.get(projectId) ?? []; + const anchor = existing.find(a => a.id === anchorId); + if (!anchor) return; + + anchor.anchorType = newType; + // Trigger reactivity + projectAnchors.set(projectId, [...existing]); + + try { + await updateAnchorTypeBridge(anchorId, newType); + } catch (e) { + console.warn('Failed to update anchor type:', e); + } +} + +/** Load anchors from SQLite for a project */ +export async function loadAnchorsForProject(projectId: string): Promise { + try { + const records = await loadSessionAnchors(projectId); + const anchors: SessionAnchor[] = records.map(r => ({ + id: r.id, + projectId: r.project_id, + messageId: r.message_id, + anchorType: r.anchor_type as AnchorType, + content: r.content, + estimatedTokens: r.estimated_tokens, + turnIndex: r.turn_index, + createdAt: r.created_at, + })); + projectAnchors.set(projectId, anchors); + // If anchors exist, mark as already auto-anchored + if (anchors.some(a => a.anchorType === 'auto')) { + autoAnchoredProjects.add(projectId); + } + } catch (e) { + console.warn('Failed to load anchors for project:', e); + } +} + +/** Get anchor settings, resolving budget from per-project scale if provided */ +export function getAnchorSettings(budgetScale?: AnchorBudgetScale) { + if (!budgetScale) return DEFAULT_ANCHOR_SETTINGS; + return { + ...DEFAULT_ANCHOR_SETTINGS, + anchorTokenBudget: ANCHOR_BUDGET_SCALE_MAP[budgetScale], + }; +} diff --git a/v2/src/lib/stores/conflicts.svelte.ts b/v2/src/lib/stores/conflicts.svelte.ts new file mode 100644 index 0000000..e5427f7 --- /dev/null +++ b/v2/src/lib/stores/conflicts.svelte.ts @@ -0,0 +1,284 @@ +// File overlap conflict detection — Svelte 5 runes +// Tracks which files each agent session writes to per project. +// Detects when two or more sessions write to the same file (file overlap conflict). +// Also detects external filesystem writes (S-1 Phase 2) via inotify events. + +import { SessionId, ProjectId, type SessionId as SessionIdType, type ProjectId as ProjectIdType } from '../types/ids'; + +/** Sentinel session ID for external (non-agent) writes */ +export const EXTERNAL_SESSION_ID = SessionId('__external__'); + +export interface FileConflict { + /** Absolute file path */ + filePath: string; + /** Short display name (last path segment) */ + shortName: string; + /** Session IDs that have written to this file */ + sessionIds: SessionIdType[]; + /** Timestamp of most recent write */ + lastWriteTs: number; + /** True if this conflict involves an external (non-agent) writer */ + isExternal: boolean; +} + +export interface ProjectConflicts { + projectId: ProjectIdType; + /** Active file conflicts (2+ sessions writing same file) */ + conflicts: FileConflict[]; + /** Total conflicting files */ + conflictCount: number; + /** Number of files with external write conflicts */ + externalConflictCount: number; +} + +// --- State --- + +interface FileWriteEntry { + sessionIds: Set; + lastWriteTs: number; +} + +// projectId -> filePath -> FileWriteEntry +let projectFileWrites = $state>>(new Map()); + +// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file) +let acknowledgedFiles = $state>>(new Map()); + +// sessionId -> worktree path (null = main working tree) +let sessionWorktrees = $state>(new Map()); + +// projectId -> filePath -> timestamp of most recent agent write (for external write heuristic) +let agentWriteTimestamps = $state>>(new Map()); + +// Time window: if an fs event arrives within this window after an agent tool_call write, +// it's attributed to the agent (suppressed). Otherwise it's external. +const AGENT_WRITE_GRACE_MS = 2000; + +// --- Public API --- + +/** Register the worktree path for a session (null = main working tree) */ +export function setSessionWorktree(sessionId: SessionIdType, worktreePath: string | null): void { + sessionWorktrees.set(sessionId, worktreePath ?? null); +} + +/** Check if two sessions are in different worktrees (conflict suppression) */ +function areInDifferentWorktrees(sessionIdA: SessionIdType, sessionIdB: SessionIdType): boolean { + const wtA = sessionWorktrees.get(sessionIdA) ?? null; + const wtB = sessionWorktrees.get(sessionIdB) ?? null; + // Both null = same main tree, both same string = same worktree → not different + if (wtA === wtB) return false; + // One or both non-null and different → different worktrees + return true; +} + +/** Record that a session wrote to a file. Returns true if this creates a new conflict. */ +export function recordFileWrite(projectId: ProjectIdType, sessionId: SessionIdType, filePath: string): boolean { + let projectMap = projectFileWrites.get(projectId); + if (!projectMap) { + projectMap = new Map(); + projectFileWrites.set(projectId, projectMap); + } + + // Track agent write timestamp for external write heuristic + if (sessionId !== EXTERNAL_SESSION_ID) { + let tsMap = agentWriteTimestamps.get(projectId); + if (!tsMap) { + tsMap = new Map(); + agentWriteTimestamps.set(projectId, tsMap); + } + tsMap.set(filePath, Date.now()); + } + + let entry = projectMap.get(filePath); + const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false; + + if (!entry) { + entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() }; + projectMap.set(filePath, entry); + return false; + } + + const isNewSession = !entry.sessionIds.has(sessionId); + entry.sessionIds.add(sessionId); + entry.lastWriteTs = Date.now(); + + // Check if this is a real conflict (not suppressed by worktrees) + const realConflictCount = countRealConflictSessions(entry, sessionId); + const isNewConflict = !hadConflict && realConflictCount >= 2; + + // Clear acknowledgement when a new session writes to a previously-acknowledged file + if (isNewSession && realConflictCount >= 2) { + const ackSet = acknowledgedFiles.get(projectId); + if (ackSet) ackSet.delete(filePath); + } + + return isNewConflict; +} + +/** + * Record an external filesystem write detected via inotify. + * Uses timing heuristic: if an agent wrote this file within AGENT_WRITE_GRACE_MS, + * the write is attributed to the agent and suppressed. + * Returns true if this creates a new external write conflict. + */ +export function recordExternalWrite(projectId: ProjectIdType, filePath: string, timestampMs: number): boolean { + // Timing heuristic: check if any agent recently wrote this file + const tsMap = agentWriteTimestamps.get(projectId); + if (tsMap) { + const lastAgentWrite = tsMap.get(filePath); + if (lastAgentWrite && (timestampMs - lastAgentWrite) < AGENT_WRITE_GRACE_MS) { + // This is likely our agent's write — suppress + return false; + } + } + + // Check if any agent session has written this file (for conflict to be meaningful) + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return false; // No agent writes at all — not a conflict + const entry = projectMap.get(filePath); + if (!entry || entry.sessionIds.size === 0) return false; // No agent wrote this file + + // Record external write as a conflict + return recordFileWrite(projectId, EXTERNAL_SESSION_ID, filePath); +} + +/** Get the count of external write conflicts for a project */ +export function getExternalConflictCount(projectId: ProjectIdType): number { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return 0; + const ackSet = acknowledgedFiles.get(projectId); + let count = 0; + for (const [filePath, entry] of projectMap) { + if (entry.sessionIds.has(EXTERNAL_SESSION_ID) && !(ackSet?.has(filePath))) { + count++; + } + } + return count; +} + +/** + * Count sessions that are in a real conflict with the given session + * (same worktree or both in main tree). Returns total including the session itself. + */ +function countRealConflictSessions(entry: FileWriteEntry, forSessionId: SessionIdType): number { + let count = 0; + for (const sid of entry.sessionIds) { + if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) { + count++; + } + } + return count; +} + +/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */ +export function getProjectConflicts(projectId: ProjectIdType): ProjectConflicts { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return { projectId, conflicts: [], conflictCount: 0, externalConflictCount: 0 }; + + const ackSet = acknowledgedFiles.get(projectId); + const conflicts: FileConflict[] = []; + let externalConflictCount = 0; + for (const [filePath, entry] of projectMap) { + if (hasRealConflict(entry) && !(ackSet?.has(filePath))) { + const isExternal = entry.sessionIds.has(EXTERNAL_SESSION_ID); + if (isExternal) externalConflictCount++; + conflicts.push({ + filePath, + shortName: filePath.split('/').pop() ?? filePath, + sessionIds: Array.from(entry.sessionIds), + lastWriteTs: entry.lastWriteTs, + isExternal, + }); + } + } + + // Most recent conflicts first + conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs); + return { projectId, conflicts, conflictCount: conflicts.length, externalConflictCount }; +} + +/** Check if a project has any unacknowledged real conflicts */ +export function hasConflicts(projectId: ProjectIdType): boolean { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return false; + const ackSet = acknowledgedFiles.get(projectId); + for (const [filePath, entry] of projectMap) { + if (hasRealConflict(entry) && !(ackSet?.has(filePath))) return true; + } + return false; +} + +/** Get total unacknowledged conflict count across all projects */ +export function getTotalConflictCount(): number { + let total = 0; + for (const [projectId, projectMap] of projectFileWrites) { + const ackSet = acknowledgedFiles.get(projectId); + for (const [filePath, entry] of projectMap) { + if (hasRealConflict(entry) && !(ackSet?.has(filePath))) total++; + } + } + return total; +} + +/** Check if a file write entry has a real conflict (2+ sessions in same worktree) */ +function hasRealConflict(entry: FileWriteEntry): boolean { + if (entry.sessionIds.size < 2) return false; + // Check all pairs for same-worktree conflict + const sids = Array.from(entry.sessionIds); + for (let i = 0; i < sids.length; i++) { + for (let j = i + 1; j < sids.length; j++) { + if (!areInDifferentWorktrees(sids[i], sids[j])) return true; + } + } + return false; +} + +/** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */ +export function acknowledgeConflicts(projectId: ProjectIdType): void { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return; + + const ackSet = acknowledgedFiles.get(projectId) ?? new Set(); + for (const [filePath, entry] of projectMap) { + if (hasRealConflict(entry)) { + ackSet.add(filePath); + } + } + acknowledgedFiles.set(projectId, ackSet); +} + +/** Remove a session from all file write tracking (call on session end) */ +export function clearSessionWrites(projectId: ProjectIdType, sessionId: SessionIdType): void { + const projectMap = projectFileWrites.get(projectId); + if (!projectMap) return; + + for (const [filePath, entry] of projectMap) { + entry.sessionIds.delete(sessionId); + if (entry.sessionIds.size === 0) { + projectMap.delete(filePath); + } + } + + if (projectMap.size === 0) { + projectFileWrites.delete(projectId); + acknowledgedFiles.delete(projectId); + } + + // Clean up worktree tracking + sessionWorktrees.delete(sessionId); +} + +/** Clear all conflict tracking for a project */ +export function clearProjectConflicts(projectId: ProjectIdType): void { + projectFileWrites.delete(projectId); + acknowledgedFiles.delete(projectId); + agentWriteTimestamps.delete(projectId); +} + +/** Clear all conflict state */ +export function clearAllConflicts(): void { + projectFileWrites = new Map(); + acknowledgedFiles = new Map(); + sessionWorktrees = new Map(); + agentWriteTimestamps = new Map(); +} diff --git a/v2/src/lib/stores/conflicts.test.ts b/v2/src/lib/stores/conflicts.test.ts new file mode 100644 index 0000000..86a1991 --- /dev/null +++ b/v2/src/lib/stores/conflicts.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { SessionId, ProjectId } from '../types/ids'; +import { + recordFileWrite, + recordExternalWrite, + getProjectConflicts, + getExternalConflictCount, + hasConflicts, + getTotalConflictCount, + clearSessionWrites, + clearProjectConflicts, + clearAllConflicts, + acknowledgeConflicts, + setSessionWorktree, + EXTERNAL_SESSION_ID, +} from './conflicts.svelte'; + +// Test helpers — branded IDs +const P1 = ProjectId('proj-1'); +const P2 = ProjectId('proj-2'); +const SA = SessionId('sess-a'); +const SB = SessionId('sess-b'); +const SC = SessionId('sess-c'); +const SD = SessionId('sess-d'); + +beforeEach(() => { + clearAllConflicts(); +}); + +describe('conflicts store', () => { + describe('recordFileWrite', () => { + it('returns false for first write to a file', () => { + expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); + }); + + it('returns false for same session writing same file again', () => { + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); + }); + + it('returns true when a second session writes same file (new conflict)', () => { + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P1, SB, '/src/main.ts')).toBe(true); + }); + + it('returns false when third session writes already-conflicted file', () => { + recordFileWrite(P1, SA, '/src/main.ts'); + recordFileWrite(P1, SB, '/src/main.ts'); + expect(recordFileWrite(P1, SC, '/src/main.ts')).toBe(false); + }); + + it('tracks writes per project independently', () => { + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P2, SB, '/src/main.ts')).toBe(false); + }); + }); + + describe('getProjectConflicts', () => { + it('returns empty for unknown project', () => { + const result = getProjectConflicts(ProjectId('nonexistent')); + expect(result.conflicts).toEqual([]); + expect(result.conflictCount).toBe(0); + }); + + it('returns empty when no overlapping writes', () => { + recordFileWrite(P1, SA, '/src/a.ts'); + recordFileWrite(P1, SB, '/src/b.ts'); + const result = getProjectConflicts(P1); + expect(result.conflicts).toEqual([]); + expect(result.conflictCount).toBe(0); + }); + + it('returns conflict when two sessions write same file', () => { + recordFileWrite(P1, SA, '/src/main.ts'); + recordFileWrite(P1, SB, '/src/main.ts'); + const result = getProjectConflicts(P1); + expect(result.conflictCount).toBe(1); + expect(result.conflicts[0].filePath).toBe('/src/main.ts'); + expect(result.conflicts[0].shortName).toBe('main.ts'); + expect(result.conflicts[0].sessionIds).toContain(SA); + expect(result.conflicts[0].sessionIds).toContain(SB); + }); + + it('returns multiple conflicts sorted by recency', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/old.ts'); + recordFileWrite(P1, SB, '/src/old.ts'); + vi.setSystemTime(2000); + recordFileWrite(P1, SA, '/src/new.ts'); + recordFileWrite(P1, SB, '/src/new.ts'); + const result = getProjectConflicts(P1); + expect(result.conflictCount).toBe(2); + // Most recent first + expect(result.conflicts[0].filePath).toBe('/src/new.ts'); + vi.useRealTimers(); + }); + }); + + describe('hasConflicts', () => { + it('returns false for unknown project', () => { + expect(hasConflicts(ProjectId('nonexistent'))).toBe(false); + }); + + it('returns false with no overlapping writes', () => { + recordFileWrite(P1, SA, '/src/a.ts'); + expect(hasConflicts(P1)).toBe(false); + }); + + it('returns true with overlapping writes', () => { + recordFileWrite(P1, SA, '/src/a.ts'); + recordFileWrite(P1, SB, '/src/a.ts'); + expect(hasConflicts(P1)).toBe(true); + }); + }); + + describe('getTotalConflictCount', () => { + it('returns 0 with no conflicts', () => { + expect(getTotalConflictCount()).toBe(0); + }); + + it('counts conflicts across projects', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P2, SC, '/b.ts'); + recordFileWrite(P2, SD, '/b.ts'); + expect(getTotalConflictCount()).toBe(2); + }); + }); + + describe('clearSessionWrites', () => { + it('removes session from file write tracking', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + clearSessionWrites(P1, SB); + expect(hasConflicts(P1)).toBe(false); + }); + + it('cleans up empty entries', () => { + recordFileWrite(P1, SA, '/a.ts'); + clearSessionWrites(P1, SA); + expect(getProjectConflicts(P1).conflictCount).toBe(0); + }); + + it('no-ops for unknown project', () => { + clearSessionWrites(ProjectId('nonexistent'), SA); // Should not throw + }); + }); + + describe('clearProjectConflicts', () => { + it('clears all tracking for a project', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + clearProjectConflicts(P1); + expect(hasConflicts(P1)).toBe(false); + expect(getTotalConflictCount()).toBe(0); + }); + }); + + describe('clearAllConflicts', () => { + it('clears everything', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P2, SC, '/b.ts'); + recordFileWrite(P2, SD, '/b.ts'); + clearAllConflicts(); + expect(getTotalConflictCount()).toBe(0); + }); + }); + + describe('acknowledgeConflicts', () => { + it('suppresses conflict from counts after acknowledge', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); + expect(getTotalConflictCount()).toBe(0); + expect(getProjectConflicts(P1).conflictCount).toBe(0); + }); + + it('resurfaces conflict when new write arrives on acknowledged file', () => { + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); + // Third session writes same file — should resurface + recordFileWrite(P1, SC, '/a.ts'); + // recordFileWrite returns false for already-conflicted file, but the ack should be cleared + expect(hasConflicts(P1)).toBe(true); + }); + + it('no-ops for unknown project', () => { + acknowledgeConflicts(ProjectId('nonexistent')); // Should not throw + }); + }); + + describe('worktree suppression', () => { + it('suppresses conflict between sessions in different worktrees', () => { + setSessionWorktree(SA, null); // main tree + setSessionWorktree(SB, '/tmp/wt-1'); // worktree + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(false); + expect(getTotalConflictCount()).toBe(0); + }); + + it('detects conflict between sessions in same worktree', () => { + setSessionWorktree(SA, '/tmp/wt-1'); + setSessionWorktree(SB, '/tmp/wt-1'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + }); + + it('detects conflict between sessions both in main tree', () => { + setSessionWorktree(SA, null); + setSessionWorktree(SB, null); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + }); + + it('suppresses conflict when two worktrees differ', () => { + setSessionWorktree(SA, '/tmp/wt-1'); + setSessionWorktree(SB, '/tmp/wt-2'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(false); + }); + + it('sessions without worktree info conflict normally (backward compat)', () => { + // No setSessionWorktree calls — both default to null (main tree) + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + }); + + it('clearSessionWrites cleans up worktree tracking', () => { + setSessionWorktree(SA, '/tmp/wt-1'); + recordFileWrite(P1, SA, '/a.ts'); + clearSessionWrites(P1, SA); + // Subsequent session in main tree should not be compared against stale wt data + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P1, SC, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + }); + }); + + describe('external write detection (S-1 Phase 2)', () => { + it('suppresses external write within grace period after agent write', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/main.ts'); + // External write arrives 500ms later — within 2s grace period + vi.setSystemTime(1500); + const result = recordExternalWrite(P1, '/src/main.ts', 1500); + expect(result).toBe(false); + expect(getExternalConflictCount(P1)).toBe(0); + vi.useRealTimers(); + }); + + it('detects external write outside grace period', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/main.ts'); + // External write arrives 3s later — outside 2s grace period + vi.setSystemTime(4000); + const result = recordExternalWrite(P1, '/src/main.ts', 4000); + expect(result).toBe(true); + expect(getExternalConflictCount(P1)).toBe(1); + vi.useRealTimers(); + }); + + it('ignores external write to file no agent has written', () => { + recordFileWrite(P1, SA, '/src/other.ts'); + const result = recordExternalWrite(P1, '/src/unrelated.ts', Date.now()); + expect(result).toBe(false); + }); + + it('ignores external write for project with no agent writes', () => { + const result = recordExternalWrite(P1, '/src/main.ts', Date.now()); + expect(result).toBe(false); + }); + + it('marks conflict as external in getProjectConflicts', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/main.ts'); + vi.setSystemTime(4000); + recordExternalWrite(P1, '/src/main.ts', 4000); + const result = getProjectConflicts(P1); + expect(result.conflictCount).toBe(1); + expect(result.externalConflictCount).toBe(1); + expect(result.conflicts[0].isExternal).toBe(true); + expect(result.conflicts[0].sessionIds).toContain(EXTERNAL_SESSION_ID); + vi.useRealTimers(); + }); + + it('external conflicts can be acknowledged', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/main.ts'); + vi.setSystemTime(4000); + recordExternalWrite(P1, '/src/main.ts', 4000); + expect(hasConflicts(P1)).toBe(true); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); + expect(getExternalConflictCount(P1)).toBe(0); + vi.useRealTimers(); + }); + + it('clearAllConflicts clears external write timestamps', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/main.ts'); + clearAllConflicts(); + // After clearing, external writes should not create conflicts (no agent writes tracked) + vi.setSystemTime(4000); + const result = recordExternalWrite(P1, '/src/main.ts', 4000); + expect(result).toBe(false); + vi.useRealTimers(); + }); + + it('external conflict coexists with agent-agent conflict', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + recordFileWrite(P1, SA, '/src/agent.ts'); + recordFileWrite(P1, SB, '/src/agent.ts'); + recordFileWrite(P1, SA, '/src/ext.ts'); + vi.setSystemTime(4000); + recordExternalWrite(P1, '/src/ext.ts', 4000); + const result = getProjectConflicts(P1); + expect(result.conflictCount).toBe(2); + expect(result.externalConflictCount).toBe(1); + const extConflict = result.conflicts.find(c => c.isExternal); + const agentConflict = result.conflicts.find(c => !c.isExternal); + expect(extConflict?.filePath).toBe('/src/ext.ts'); + expect(agentConflict?.filePath).toBe('/src/agent.ts'); + vi.useRealTimers(); + }); + }); +}); diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts new file mode 100644 index 0000000..43ba9da --- /dev/null +++ b/v2/src/lib/stores/health.svelte.ts @@ -0,0 +1,319 @@ +// Project health tracking — Svelte 5 runes +// Tracks per-project activity state, burn rate, context pressure, and attention scoring + +import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; +import { getAgentSession, type AgentSession } from './agents.svelte'; +import { getProjectConflicts } from './conflicts.svelte'; +import { scoreAttention } from '../utils/attention-scorer'; + +// --- Types --- + +export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; + +export interface ProjectHealth { + projectId: ProjectIdType; + sessionId: SessionIdType | null; + /** Current activity state */ + activityState: ActivityState; + /** Name of currently running tool (if any) */ + activeTool: string | null; + /** Duration in ms since last activity (0 if running a tool) */ + idleDurationMs: number; + /** Burn rate in USD per hour (0 if no data) */ + burnRatePerHour: number; + /** Context pressure as fraction 0..1 (null if unknown) */ + contextPressure: number | null; + /** Number of file conflicts (2+ agents writing same file) */ + fileConflictCount: number; + /** Number of external write conflicts (filesystem writes by non-agent processes) */ + externalConflictCount: number; + /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ + attentionScore: number; + /** Human-readable attention reason */ + attentionReason: string | null; +} + +export type AttentionItem = ProjectHealth & { projectName: string; projectIcon: string }; + +// --- Configuration --- + +const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes +const TICK_INTERVAL_MS = 5_000; // Update derived state every 5s +const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for burn rate calc + +// Context limits by model (tokens) +const MODEL_CONTEXT_LIMITS: Record = { + 'claude-sonnet-4-20250514': 200_000, + 'claude-opus-4-20250514': 200_000, + 'claude-haiku-4-20250506': 200_000, + 'claude-3-5-sonnet-20241022': 200_000, + 'claude-3-5-haiku-20241022': 200_000, + 'claude-sonnet-4-6': 200_000, + 'claude-opus-4-6': 200_000, +}; +const DEFAULT_CONTEXT_LIMIT = 200_000; + + +// --- State --- + +interface ProjectTracker { + projectId: ProjectIdType; + sessionId: SessionIdType | null; + lastActivityTs: number; // epoch ms + lastToolName: string | null; + toolInFlight: boolean; + /** Token snapshots for burn rate calculation: [timestamp, totalTokens] */ + tokenSnapshots: Array<[number, number]>; + /** Cost snapshots for $/hr: [timestamp, costUsd] */ + costSnapshots: Array<[number, number]>; +} + +let trackers = $state>(new Map()); +let stallThresholds = $state>(new Map()); // projectId → ms +let tickTs = $state(Date.now()); +let tickInterval: ReturnType | null = null; + +// --- Public API --- + +/** Register a project for health tracking */ +export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType | null): void { + const existing = trackers.get(projectId); + if (existing) { + existing.sessionId = sessionId; + return; + } + trackers.set(projectId, { + projectId, + sessionId, + lastActivityTs: Date.now(), + lastToolName: null, + toolInFlight: false, + tokenSnapshots: [], + costSnapshots: [], + }); +} + +/** Remove a project from health tracking */ +export function untrackProject(projectId: ProjectIdType): void { + trackers.delete(projectId); +} + +/** Set per-project stall threshold in minutes (null to use default) */ +export function setStallThreshold(projectId: ProjectIdType, minutes: number | null): void { + if (minutes === null) { + stallThresholds.delete(projectId); + } else { + stallThresholds.set(projectId, minutes * 60 * 1000); + } +} + +/** Update session ID for a tracked project */ +export function updateProjectSession(projectId: ProjectIdType, sessionId: SessionIdType): void { + const t = trackers.get(projectId); + if (t) { + t.sessionId = sessionId; + } +} + +/** Record activity — call on every agent message. Auto-starts tick if stopped. */ +export function recordActivity(projectId: ProjectIdType, toolName?: string): void { + const t = trackers.get(projectId); + if (!t) return; + t.lastActivityTs = Date.now(); + if (toolName !== undefined) { + t.lastToolName = toolName; + t.toolInFlight = true; + } + // Auto-start tick when activity resumes + if (!tickInterval) startHealthTick(); +} + +/** Record tool completion */ +export function recordToolDone(projectId: ProjectIdType): void { + const t = trackers.get(projectId); + if (!t) return; + t.lastActivityTs = Date.now(); + t.toolInFlight = false; +} + +/** Record a token/cost snapshot for burn rate calculation */ +export function recordTokenSnapshot(projectId: ProjectIdType, totalTokens: number, costUsd: number): void { + const t = trackers.get(projectId); + if (!t) return; + const now = Date.now(); + t.tokenSnapshots.push([now, totalTokens]); + t.costSnapshots.push([now, costUsd]); + // Prune old snapshots beyond window + const cutoff = now - BURN_RATE_WINDOW_MS * 2; + t.tokenSnapshots = t.tokenSnapshots.filter(([ts]) => ts > cutoff); + t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff); +} + +/** Check if any tracked project has an active (running/starting) session */ +function hasActiveSession(): boolean { + for (const t of trackers.values()) { + if (!t.sessionId) continue; + const session = getAgentSession(t.sessionId); + if (session && (session.status === 'running' || session.status === 'starting')) return true; + } + return false; +} + +/** Start the health tick timer (auto-stops when no active sessions) */ +export function startHealthTick(): void { + if (tickInterval) return; + tickInterval = setInterval(() => { + if (!hasActiveSession()) { + stopHealthTick(); + return; + } + tickTs = Date.now(); + }, TICK_INTERVAL_MS); +} + +/** Stop the health tick timer */ +export function stopHealthTick(): void { + if (tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } +} + +/** Clear all tracked projects */ +export function clearHealthTracking(): void { + trackers = new Map(); + stallThresholds = new Map(); +} + +// --- Derived health per project --- + +function getContextLimit(model?: string): number { + if (!model) return DEFAULT_CONTEXT_LIMIT; + return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT; +} + +function computeBurnRate(snapshots: Array<[number, number]>): number { + if (snapshots.length < 2) return 0; + const windowStart = Date.now() - BURN_RATE_WINDOW_MS; + const recent = snapshots.filter(([ts]) => ts >= windowStart); + if (recent.length < 2) return 0; + const first = recent[0]; + const last = recent[recent.length - 1]; + const elapsedHours = (last[0] - first[0]) / 3_600_000; + if (elapsedHours < 0.001) return 0; // Less than ~4 seconds + const costDelta = last[1] - first[1]; + return Math.max(0, costDelta / elapsedHours); +} + +function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { + const session: AgentSession | undefined = tracker.sessionId + ? getAgentSession(tracker.sessionId) + : undefined; + + // Activity state + let activityState: ActivityState; + let idleDurationMs = 0; + let activeTool: string | null = null; + + if (!session || session.status === 'idle' || session.status === 'done' || session.status === 'error') { + activityState = session?.status === 'error' ? 'inactive' : 'inactive'; + } else if (tracker.toolInFlight) { + activityState = 'running'; + activeTool = tracker.lastToolName; + idleDurationMs = 0; + } else { + idleDurationMs = now - tracker.lastActivityTs; + const stallMs = stallThresholds.get(tracker.projectId) ?? DEFAULT_STALL_THRESHOLD_MS; + if (idleDurationMs >= stallMs) { + activityState = 'stalled'; + } else { + activityState = 'idle'; + } + } + + // Context pressure + let contextPressure: number | null = null; + if (session && (session.inputTokens + session.outputTokens) > 0) { + const limit = getContextLimit(session.model); + contextPressure = Math.min(1, (session.inputTokens + session.outputTokens) / limit); + } + + // Burn rate + const burnRatePerHour = computeBurnRate(tracker.costSnapshots); + + // File conflicts + const conflicts = getProjectConflicts(tracker.projectId); + const fileConflictCount = conflicts.conflictCount; + const externalConflictCount = conflicts.externalConflictCount; + + // Attention scoring — delegated to pure function + const attention = scoreAttention({ + sessionStatus: session?.status, + sessionError: session?.error, + activityState, + idleDurationMs, + contextPressure, + fileConflictCount, + externalConflictCount, + }); + + return { + projectId: tracker.projectId, + sessionId: tracker.sessionId, + activityState, + activeTool, + idleDurationMs, + burnRatePerHour, + contextPressure, + fileConflictCount, + externalConflictCount, + attentionScore: attention.score, + attentionReason: attention.reason, + }; +} + +/** Get health for a single project (reactive via tickTs) */ +export function getProjectHealth(projectId: ProjectIdType): ProjectHealth | null { + // Touch tickTs to make this reactive to the timer + const now = tickTs; + const t = trackers.get(projectId); + if (!t) return null; + return computeHealth(t, now); +} + +/** Get all project health sorted by attention score descending */ +export function getAllProjectHealth(): ProjectHealth[] { + const now = tickTs; + const results: ProjectHealth[] = []; + for (const t of trackers.values()) { + results.push(computeHealth(t, now)); + } + results.sort((a, b) => b.attentionScore - a.attentionScore); + return results; +} + +/** Get top N items needing attention */ +export function getAttentionQueue(limit = 5): ProjectHealth[] { + return getAllProjectHealth().filter(h => h.attentionScore > 0).slice(0, limit); +} + +/** Get aggregate stats across all tracked projects */ +export function getHealthAggregates(): { + running: number; + idle: number; + stalled: number; + totalBurnRatePerHour: number; +} { + const all = getAllProjectHealth(); + let running = 0; + let idle = 0; + let stalled = 0; + let totalBurnRatePerHour = 0; + for (const h of all) { + if (h.activityState === 'running') running++; + else if (h.activityState === 'idle') idle++; + else if (h.activityState === 'stalled') stalled++; + totalBurnRatePerHour += h.burnRatePerHour; + } + return { running, idle, stalled, totalBurnRatePerHour }; +} diff --git a/v2/src/lib/stores/layout.svelte.ts b/v2/src/lib/stores/layout.svelte.ts new file mode 100644 index 0000000..acfe905 --- /dev/null +++ b/v2/src/lib/stores/layout.svelte.ts @@ -0,0 +1,193 @@ +import { + listSessions, + saveSession, + deleteSession, + updateSessionTitle, + touchSession, + saveLayout, + loadLayout, + updateSessionGroup, + type PersistedSession, +} from '../adapters/session-bridge'; + +export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; + +export type PaneType = 'terminal' | 'agent' | 'markdown' | 'ssh' | 'context' | 'empty'; + +export interface Pane { + id: string; + type: PaneType; + title: string; + shell?: string; + cwd?: string; + args?: string[]; + group?: string; + focused: boolean; + remoteMachineId?: string; +} + +let panes = $state([]); +let activePreset = $state('1-col'); +let focusedPaneId = $state(null); +let initialized = false; + +// --- Persistence helpers (fire-and-forget with error logging) --- + +function persistSession(pane: Pane): void { + const now = Math.floor(Date.now() / 1000); + const session: PersistedSession = { + id: pane.id, + type: pane.type, + title: pane.title, + shell: pane.shell, + cwd: pane.cwd, + args: pane.args, + group_name: pane.group ?? '', + created_at: now, + last_used_at: now, + }; + saveSession(session).catch(e => console.warn('Failed to persist session:', e)); +} + +function persistLayout(): void { + saveLayout({ + preset: activePreset, + pane_ids: panes.map(p => p.id), + }).catch(e => console.warn('Failed to persist layout:', e)); +} + +// --- Public API --- + +export function getPanes(): Pane[] { + return panes; +} + +export function getActivePreset(): LayoutPreset { + return activePreset; +} + +export function getFocusedPaneId(): string | null { + return focusedPaneId; +} + +export function addPane(pane: Omit): void { + panes.push({ ...pane, focused: false }); + focusPane(pane.id); + autoPreset(); + persistSession({ ...pane, focused: false }); + persistLayout(); +} + +export function removePane(id: string): void { + panes = panes.filter(p => p.id !== id); + if (focusedPaneId === id) { + focusedPaneId = panes.length > 0 ? panes[0].id : null; + } + autoPreset(); + deleteSession(id).catch(e => console.warn('Failed to delete session:', e)); + persistLayout(); +} + +export function focusPane(id: string): void { + focusedPaneId = id; + panes = panes.map(p => ({ ...p, focused: p.id === id })); + touchSession(id).catch(e => console.warn('Failed to touch session:', e)); +} + +export function focusPaneByIndex(index: number): void { + if (index >= 0 && index < panes.length) { + focusPane(panes[index].id); + } +} + +export function setPreset(preset: LayoutPreset): void { + activePreset = preset; + persistLayout(); +} + +export function renamePaneTitle(id: string, title: string): void { + const pane = panes.find(p => p.id === id); + if (pane) { + pane.title = title; + updateSessionTitle(id, title).catch(e => console.warn('Failed to update title:', e)); + } +} + +export function setPaneGroup(id: string, group: string): void { + const pane = panes.find(p => p.id === id); + if (pane) { + pane.group = group || undefined; + updateSessionGroup(id, group).catch(e => console.warn('Failed to update group:', e)); + } +} + +/** Restore panes and layout from SQLite on app startup */ +export async function restoreFromDb(): Promise { + if (initialized) return; + initialized = true; + + try { + const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]); + + if (layout.preset) { + activePreset = layout.preset as LayoutPreset; + } + + // Restore panes in layout order, falling back to DB order + const sessionMap = new Map(sessions.map(s => [s.id, s])); + const orderedIds = layout.pane_ids.length > 0 ? layout.pane_ids : sessions.map(s => s.id); + + for (const id of orderedIds) { + const s = sessionMap.get(id); + if (!s) continue; + panes.push({ + id: s.id, + type: s.type as PaneType, + title: s.title, + shell: s.shell ?? undefined, + cwd: s.cwd ?? undefined, + args: s.args ?? undefined, + group: s.group_name || undefined, + focused: false, + }); + } + + if (panes.length > 0) { + focusPane(panes[0].id); + } + } catch (e) { + console.warn('Failed to restore sessions from DB:', e); + } +} + +function autoPreset(): void { + const count = panes.length; + if (count <= 1) activePreset = '1-col'; + else if (count === 2) activePreset = '2-col'; + else if (count === 3) activePreset = 'master-stack'; + else activePreset = '2x2'; +} + +/** CSS grid-template for current preset */ +export function getGridTemplate(): { columns: string; rows: string } { + switch (activePreset) { + case '1-col': + return { columns: '1fr', rows: '1fr' }; + case '2-col': + return { columns: '1fr 1fr', rows: '1fr' }; + case '3-col': + return { columns: '1fr 1fr 1fr', rows: '1fr' }; + case '2x2': + return { columns: '1fr 1fr', rows: '1fr 1fr' }; + case 'master-stack': + return { columns: '2fr 1fr', rows: '1fr 1fr' }; + } +} + +/** For master-stack: first pane spans full height */ +export function getPaneGridArea(index: number): string | undefined { + if (activePreset === 'master-stack' && index === 0) { + return '1 / 1 / 3 / 2'; + } + return undefined; +} diff --git a/v2/src/lib/stores/layout.test.ts b/v2/src/lib/stores/layout.test.ts new file mode 100644 index 0000000..ffd4b1b --- /dev/null +++ b/v2/src/lib/stores/layout.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock session-bridge before importing the layout store +vi.mock('../adapters/session-bridge', () => ({ + listSessions: vi.fn().mockResolvedValue([]), + saveSession: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + updateSessionTitle: vi.fn().mockResolvedValue(undefined), + touchSession: vi.fn().mockResolvedValue(undefined), + saveLayout: vi.fn().mockResolvedValue(undefined), + loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }), +})); + +import { + getPanes, + getActivePreset, + getFocusedPaneId, + addPane, + removePane, + focusPane, + focusPaneByIndex, + setPreset, + renamePaneTitle, + getGridTemplate, + getPaneGridArea, + type LayoutPreset, + type Pane, +} from './layout.svelte'; + +// Helper to reset module state between tests +// The layout store uses module-level $state, so we need to clean up +function clearAllPanes(): void { + const panes = getPanes(); + const ids = panes.map(p => p.id); + for (const id of ids) { + removePane(id); + } +} + +beforeEach(() => { + clearAllPanes(); + setPreset('1-col'); + vi.clearAllMocks(); +}); + +describe('layout store', () => { + describe('addPane', () => { + it('adds a pane to the list', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Terminal 1' }); + + const panes = getPanes(); + expect(panes).toHaveLength(1); + expect(panes[0].id).toBe('p1'); + expect(panes[0].type).toBe('terminal'); + expect(panes[0].title).toBe('Terminal 1'); + }); + + it('sets focused to false initially then focuses via focusPane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + // addPane calls focusPane internally, so the pane should be focused + expect(getFocusedPaneId()).toBe('p1'); + const panes = getPanes(); + expect(panes[0].focused).toBe(true); + }); + + it('focuses the newly added pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'agent', title: 'Agent 1' }); + + expect(getFocusedPaneId()).toBe('p2'); + }); + + it('calls autoPreset when adding panes', () => { + // 1 pane -> 1-col + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + expect(getActivePreset()).toBe('1-col'); + + // 2 panes -> 2-col + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + + // 3 panes -> master-stack + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + expect(getActivePreset()).toBe('master-stack'); + + // 4+ panes -> 2x2 + addPane({ id: 'p4', type: 'terminal', title: 'T4' }); + expect(getActivePreset()).toBe('2x2'); + }); + }); + + describe('removePane', () => { + it('removes a pane by id', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + removePane('p1'); + + const panes = getPanes(); + expect(panes).toHaveLength(1); + expect(panes[0].id).toBe('p2'); + }); + + it('focuses the first remaining pane when focused pane is removed', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + + // p3 is focused (last added) + expect(getFocusedPaneId()).toBe('p3'); + + removePane('p3'); + + // Should focus p1 (first remaining) + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('sets focusedPaneId to null when last pane is removed', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + removePane('p1'); + + expect(getFocusedPaneId()).toBeNull(); + }); + + it('adjusts preset via autoPreset after removal', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + + removePane('p2'); + expect(getActivePreset()).toBe('1-col'); + }); + + it('does not change focus if removed pane was not focused', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + // p2 is focused (last added). Remove p1 + focusPane('p2'); + removePane('p1'); + + expect(getFocusedPaneId()).toBe('p2'); + }); + }); + + describe('focusPane', () => { + it('sets focused flag on the target pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + focusPane('p1'); + + const panes = getPanes(); + expect(panes.find(p => p.id === 'p1')?.focused).toBe(true); + expect(panes.find(p => p.id === 'p2')?.focused).toBe(false); + expect(getFocusedPaneId()).toBe('p1'); + }); + }); + + describe('focusPaneByIndex', () => { + it('focuses pane at the given index', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + focusPaneByIndex(0); + + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('ignores out-of-bounds indices', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + focusPaneByIndex(5); + + // Should remain on p1 + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('ignores negative indices', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + focusPaneByIndex(-1); + + expect(getFocusedPaneId()).toBe('p1'); + }); + }); + + describe('setPreset', () => { + it('overrides the active preset', () => { + setPreset('3-col'); + expect(getActivePreset()).toBe('3-col'); + }); + + it('allows setting any valid preset', () => { + const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack']; + for (const preset of presets) { + setPreset(preset); + expect(getActivePreset()).toBe(preset); + } + }); + }); + + describe('renamePaneTitle', () => { + it('updates the title of a pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Old Title' }); + + renamePaneTitle('p1', 'New Title'); + + const panes = getPanes(); + expect(panes[0].title).toBe('New Title'); + }); + + it('does nothing for non-existent pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Title' }); + + renamePaneTitle('p-nonexistent', 'New Title'); + + expect(getPanes()[0].title).toBe('Title'); + }); + }); + + describe('getGridTemplate', () => { + it('returns 1fr / 1fr for 1-col', () => { + setPreset('1-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr / 1fr for 2-col', () => { + setPreset('2-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr 1fr / 1fr for 3-col', () => { + setPreset('3-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr 1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr / 1fr 1fr for 2x2', () => { + setPreset('2x2'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr 1fr' }); + }); + + it('returns 2fr 1fr / 1fr 1fr for master-stack', () => { + setPreset('master-stack'); + expect(getGridTemplate()).toEqual({ columns: '2fr 1fr', rows: '1fr 1fr' }); + }); + }); + + describe('getPaneGridArea', () => { + it('returns grid area for first pane in master-stack', () => { + setPreset('master-stack'); + expect(getPaneGridArea(0)).toBe('1 / 1 / 3 / 2'); + }); + + it('returns undefined for non-first panes in master-stack', () => { + setPreset('master-stack'); + expect(getPaneGridArea(1)).toBeUndefined(); + expect(getPaneGridArea(2)).toBeUndefined(); + }); + + it('returns undefined for all panes in non-master-stack presets', () => { + setPreset('2-col'); + expect(getPaneGridArea(0)).toBeUndefined(); + expect(getPaneGridArea(1)).toBeUndefined(); + }); + }); + + describe('autoPreset behavior', () => { + it('0 panes -> 1-col', () => { + expect(getActivePreset()).toBe('1-col'); + }); + + it('1 pane -> 1-col', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + expect(getActivePreset()).toBe('1-col'); + }); + + it('2 panes -> 2-col', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + }); + + it('3 panes -> master-stack', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + expect(getActivePreset()).toBe('master-stack'); + }); + + it('4+ panes -> 2x2', () => { + for (let i = 1; i <= 5; i++) { + addPane({ id: `p${i}`, type: 'terminal', title: `T${i}` }); + } + expect(getActivePreset()).toBe('2x2'); + }); + }); +}); diff --git a/v2/src/lib/stores/machines.svelte.ts b/v2/src/lib/stores/machines.svelte.ts new file mode 100644 index 0000000..c035ce9 --- /dev/null +++ b/v2/src/lib/stores/machines.svelte.ts @@ -0,0 +1,131 @@ +// Remote machines store — tracks connection state for multi-machine support + +import { + listRemoteMachines, + addRemoteMachine, + removeRemoteMachine, + connectRemoteMachine, + disconnectRemoteMachine, + onRemoteMachineReady, + onRemoteMachineDisconnected, + onRemoteError, + onRemoteMachineReconnecting, + onRemoteMachineReconnectReady, + type RemoteMachineConfig, + type RemoteMachineInfo, +} from '../adapters/remote-bridge'; +import { notify } from './notifications.svelte'; + +export interface Machine extends RemoteMachineInfo {} + +let machines = $state([]); + +export function getMachines(): Machine[] { + return machines; +} + +export function getMachine(id: string): Machine | undefined { + return machines.find(m => m.id === id); +} + +export async function loadMachines(): Promise { + try { + machines = await listRemoteMachines(); + } catch (e) { + console.warn('Failed to load remote machines:', e); + } +} + +export async function addMachine(config: RemoteMachineConfig): Promise { + const id = await addRemoteMachine(config); + machines.push({ + id, + label: config.label, + url: config.url, + status: 'disconnected', + auto_connect: config.auto_connect, + }); + return id; +} + +export async function removeMachine(id: string): Promise { + await removeRemoteMachine(id); + machines = machines.filter(m => m.id !== id); +} + +export async function connectMachine(id: string): Promise { + const machine = machines.find(m => m.id === id); + if (machine) machine.status = 'connecting'; + try { + await connectRemoteMachine(id); + if (machine) machine.status = 'connected'; + } catch (e) { + if (machine) machine.status = 'error'; + throw e; + } +} + +export async function disconnectMachine(id: string): Promise { + await disconnectRemoteMachine(id); + const machine = machines.find(m => m.id === id); + if (machine) machine.status = 'disconnected'; +} + +// Stored unlisten functions for cleanup +let unlistenFns: (() => void)[] = []; + +// Initialize event listeners for machine status updates +export async function initMachineListeners(): Promise { + // Clean up any existing listeners first + destroyMachineListeners(); + + unlistenFns.push(await onRemoteMachineReady((msg) => { + const machine = machines.find(m => m.id === msg.machineId); + if (machine) { + machine.status = 'connected'; + notify('success', `Connected to ${machine.label}`); + } + })); + + unlistenFns.push(await onRemoteMachineDisconnected((msg) => { + const machine = machines.find(m => m.id === msg.machineId); + if (machine) { + machine.status = 'disconnected'; + notify('warning', `Disconnected from ${machine.label}`); + } + })); + + unlistenFns.push(await onRemoteError((msg) => { + const machine = machines.find(m => m.id === msg.machineId); + if (machine) { + machine.status = 'error'; + notify('error', `Error from ${machine.label}: ${msg.error}`); + } + })); + + unlistenFns.push(await onRemoteMachineReconnecting((msg) => { + const machine = machines.find(m => m.id === msg.machineId); + if (machine) { + machine.status = 'reconnecting'; + notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s…`); + } + })); + + unlistenFns.push(await onRemoteMachineReconnectReady((msg) => { + const machine = machines.find(m => m.id === msg.machineId); + if (machine) { + notify('info', `${machine.label} reachable — reconnecting…`); + connectMachine(msg.machineId).catch((e) => { + notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`); + }); + } + })); +} + +/** Remove all event listeners to prevent leaks */ +export function destroyMachineListeners(): void { + for (const unlisten of unlistenFns) { + unlisten(); + } + unlistenFns = []; +} diff --git a/v2/src/lib/stores/notifications.svelte.ts b/v2/src/lib/stores/notifications.svelte.ts new file mode 100644 index 0000000..e8c9364 --- /dev/null +++ b/v2/src/lib/stores/notifications.svelte.ts @@ -0,0 +1,38 @@ +// Notification store — ephemeral toast messages + +export type NotificationType = 'info' | 'success' | 'warning' | 'error'; + +export interface Notification { + id: string; + type: NotificationType; + message: string; + timestamp: number; +} + +let notifications = $state([]); + +const MAX_TOASTS = 5; +const TOAST_DURATION_MS = 4000; + +export function getNotifications(): Notification[] { + return notifications; +} + +export function notify(type: NotificationType, message: string): string { + const id = crypto.randomUUID(); + notifications.push({ id, type, message, timestamp: Date.now() }); + + // Cap visible toasts + if (notifications.length > MAX_TOASTS) { + notifications = notifications.slice(-MAX_TOASTS); + } + + // Auto-dismiss + setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); + + return id; +} + +export function dismissNotification(id: string): void { + notifications = notifications.filter(n => n.id !== id); +} diff --git a/v2/src/lib/stores/sessions.svelte.ts b/v2/src/lib/stores/sessions.svelte.ts new file mode 100644 index 0000000..ab1ecef --- /dev/null +++ b/v2/src/lib/stores/sessions.svelte.ts @@ -0,0 +1,26 @@ +// Session state management — Svelte 5 runes +// Phase 4: full session CRUD, persistence + +export type SessionType = 'terminal' | 'agent' | 'markdown'; + +export interface Session { + id: string; + type: SessionType; + title: string; + createdAt: number; +} + +// Reactive session list +let sessions = $state([]); + +export function getSessions() { + return sessions; +} + +export function addSession(session: Session) { + sessions.push(session); +} + +export function removeSession(id: string) { + sessions = sessions.filter(s => s.id !== id); +} diff --git a/v2/src/lib/stores/theme.svelte.ts b/v2/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..8c32966 --- /dev/null +++ b/v2/src/lib/stores/theme.svelte.ts @@ -0,0 +1,98 @@ +// Theme store — persists theme selection via settings bridge + +import { getSetting, setSetting } from '../adapters/settings-bridge'; +import { + type ThemeId, + type CatppuccinFlavor, + ALL_THEME_IDS, + buildXtermTheme, + applyCssVariables, + type XtermTheme, +} from '../styles/themes'; + +let currentTheme = $state('mocha'); + +/** Registered theme-change listeners */ +const themeChangeCallbacks = new Set<() => void>(); + +/** Register a callback invoked after every theme change. Returns an unsubscribe function. */ +export function onThemeChange(callback: () => void): () => void { + themeChangeCallbacks.add(callback); + return () => { + themeChangeCallbacks.delete(callback); + }; +} + +export function getCurrentTheme(): ThemeId { + return currentTheme; +} + +/** @deprecated Use getCurrentTheme() */ +export function getCurrentFlavor(): CatppuccinFlavor { + // Return valid CatppuccinFlavor or default to 'mocha' + const catFlavors: string[] = ['latte', 'frappe', 'macchiato', 'mocha']; + return catFlavors.includes(currentTheme) ? currentTheme as CatppuccinFlavor : 'mocha'; +} + +export function getXtermTheme(): XtermTheme { + return buildXtermTheme(currentTheme); +} + +/** Change theme, apply CSS variables, and persist to settings DB */ +export async function setTheme(theme: ThemeId): Promise { + currentTheme = theme; + applyCssVariables(theme); + // Notify all listeners (e.g. open xterm.js terminals) + for (const cb of themeChangeCallbacks) { + try { + cb(); + } catch (e) { + console.error('Theme change callback error:', e); + } + } + + try { + await setSetting('theme', theme); + } catch (e) { + console.error('Failed to persist theme setting:', e); + } +} + +/** @deprecated Use setTheme() */ +export async function setFlavor(flavor: CatppuccinFlavor): Promise { + return setTheme(flavor); +} + +/** Load saved theme from settings DB and apply. Call once on app startup. */ +export async function initTheme(): Promise { + try { + const saved = await getSetting('theme'); + if (saved && ALL_THEME_IDS.includes(saved as ThemeId)) { + currentTheme = saved as ThemeId; + } + } catch { + // Fall back to default (mocha) — catppuccin.css provides Mocha defaults + } + // Always apply to sync CSS vars with current theme + // (skip if mocha — catppuccin.css already has Mocha values) + if (currentTheme !== 'mocha') { + applyCssVariables(currentTheme); + } + + // Apply saved font settings + try { + const [uiFont, uiSize, termFont, termSize] = await Promise.all([ + getSetting('ui_font_family'), + getSetting('ui_font_size'), + getSetting('term_font_family'), + getSetting('term_font_size'), + ]); + const root = document.documentElement.style; + if (uiFont) root.setProperty('--ui-font-family', `'${uiFont}', sans-serif`); + if (uiSize) root.setProperty('--ui-font-size', `${uiSize}px`); + if (termFont) root.setProperty('--term-font-family', `'${termFont}', monospace`); + if (termSize) root.setProperty('--term-font-size', `${termSize}px`); + } catch { + // Font settings are optional — defaults from catppuccin.css apply + } +} diff --git a/v2/src/lib/stores/workspace.svelte.ts b/v2/src/lib/stores/workspace.svelte.ts new file mode 100644 index 0000000..6cb10a6 --- /dev/null +++ b/v2/src/lib/stores/workspace.svelte.ts @@ -0,0 +1,260 @@ +import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; +import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups'; +import { agentToProject } from '../types/groups'; +import { clearAllAgentSessions } from '../stores/agents.svelte'; +import { clearHealthTracking } from '../stores/health.svelte'; +import { clearAllConflicts } from '../stores/conflicts.svelte'; +import { waitForPendingPersistence } from '../agent-dispatcher'; + +export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms'; + +export interface TerminalTab { + id: string; + title: string; + type: 'shell' | 'ssh' | 'agent-terminal' | 'agent-preview'; + /** SSH session ID if type === 'ssh' */ + sshSessionId?: string; + /** Agent session ID if type === 'agent-preview' */ + agentSessionId?: string; +} + +// --- Core state --- + +let groupsConfig = $state(null); +let activeGroupId = $state(''); +let activeTab = $state('sessions'); +let activeProjectId = $state(null); + +/** Terminal tabs per project (keyed by project ID) */ +let projectTerminals = $state>({}); + +// --- Getters --- + +export function getGroupsConfig(): GroupsFile | null { + return groupsConfig; +} + +export function getActiveGroupId(): string { + return activeGroupId; +} + +export function getActiveTab(): WorkspaceTab { + return activeTab; +} + +export function getActiveProjectId(): string | null { + return activeProjectId; +} + +export function getActiveGroup(): GroupConfig | undefined { + return groupsConfig?.groups.find(g => g.id === activeGroupId); +} + +export function getEnabledProjects(): ProjectConfig[] { + const group = getActiveGroup(); + if (!group) return []; + return group.projects.filter(p => p.enabled); +} + +/** Get all work items: enabled projects + agents as virtual project entries */ +export function getAllWorkItems(): ProjectConfig[] { + const group = getActiveGroup(); + if (!group) return []; + const projects = group.projects.filter(p => p.enabled); + const agentProjects = (group.agents ?? []) + .filter(a => a.enabled) + .map(a => { + // Use first project's parent dir as default CWD for agents + const groupCwd = projects[0]?.cwd?.replace(/\/[^/]+\/?$/, '/') ?? '/tmp'; + return agentToProject(a, groupCwd); + }); + return [...agentProjects, ...projects]; +} + +export function getAllGroups(): GroupConfig[] { + return groupsConfig?.groups ?? []; +} + +// --- Setters --- + +export function setActiveTab(tab: WorkspaceTab): void { + activeTab = tab; +} + +export function setActiveProject(projectId: string | null): void { + activeProjectId = projectId; +} + +export async function switchGroup(groupId: string): Promise { + if (groupId === activeGroupId) return; + + // Wait for any in-flight persistence before clearing state + await waitForPendingPersistence(); + + // Teardown: clear terminal tabs, agent sessions, and health tracking for the old group + projectTerminals = {}; + clearAllAgentSessions(); + clearHealthTracking(); + clearAllConflicts(); + + activeGroupId = groupId; + activeProjectId = null; + + // Auto-focus first enabled project + const projects = getEnabledProjects(); + if (projects.length > 0) { + activeProjectId = projects[0].id; + } + + // Persist active group + if (groupsConfig) { + groupsConfig.activeGroupId = groupId; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + } +} + +// --- Terminal tab management per project --- + +export function getTerminalTabs(projectId: string): TerminalTab[] { + return projectTerminals[projectId] ?? []; +} + +export function addTerminalTab(projectId: string, tab: TerminalTab): void { + const tabs = projectTerminals[projectId] ?? []; + projectTerminals[projectId] = [...tabs, tab]; +} + +export function removeTerminalTab(projectId: string, tabId: string): void { + const tabs = projectTerminals[projectId] ?? []; + projectTerminals[projectId] = tabs.filter(t => t.id !== tabId); +} + +// --- Persistence --- + +export async function loadWorkspace(initialGroupId?: string): Promise { + try { + const config = await loadGroups(); + groupsConfig = config; + projectTerminals = {}; + + // CLI --group flag takes priority, then explicit param, then persisted + let cliGroup: string | null = null; + if (!initialGroupId) { + cliGroup = await getCliGroup(); + } + const targetId = initialGroupId || cliGroup || config.activeGroupId; + // Match by ID or by name (CLI users may pass name) + const targetGroup = config.groups.find( + g => g.id === targetId || g.name === targetId, + ); + + if (targetGroup) { + activeGroupId = targetGroup.id; + } else if (config.groups.length > 0) { + activeGroupId = config.groups[0].id; + } + + // Auto-focus first enabled project + const projects = getEnabledProjects(); + if (projects.length > 0) { + activeProjectId = projects[0].id; + } + } catch (e) { + console.warn('Failed to load groups config:', e); + groupsConfig = { version: 1, groups: [], activeGroupId: '' }; + } +} + +export async function saveWorkspace(): Promise { + if (!groupsConfig) return; + await saveGroups(groupsConfig); +} + +// --- Group/project mutation --- + +export function addGroup(group: GroupConfig): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: [...groupsConfig.groups, group], + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function removeGroup(groupId: string): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.filter(g => g.id !== groupId), + }; + if (activeGroupId === groupId) { + activeGroupId = groupsConfig.groups[0]?.id ?? ''; + activeProjectId = null; + } + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function updateProject(groupId: string, projectId: string, updates: Partial): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { + ...g, + projects: g.projects.map(p => { + if (p.id !== projectId) return p; + return { ...p, ...updates }; + }), + }; + }), + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function addProject(groupId: string, project: ProjectConfig): void { + if (!groupsConfig) return; + const group = groupsConfig.groups.find(g => g.id === groupId); + if (!group || group.projects.length >= 5) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { ...g, projects: [...g.projects, project] }; + }), + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function removeProject(groupId: string, projectId: string): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { ...g, projects: g.projects.filter(p => p.id !== projectId) }; + }), + }; + if (activeProjectId === projectId) { + activeProjectId = null; + } + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function updateAgent(groupId: string, agentId: string, updates: Partial): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { + ...g, + agents: (g.agents ?? []).map(a => { + if (a.id !== agentId) return a; + return { ...a, ...updates }; + }), + }; + }), + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} diff --git a/v2/src/lib/stores/workspace.test.ts b/v2/src/lib/stores/workspace.test.ts new file mode 100644 index 0000000..4365d32 --- /dev/null +++ b/v2/src/lib/stores/workspace.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock groups-bridge before importing the workspace store +function mockGroupsData() { + return { + version: 1, + groups: [ + { + id: 'g1', + name: 'Group One', + projects: [ + { id: 'p1', name: 'Project 1', identifier: 'project-1', description: '', icon: '', cwd: '/tmp/p1', profile: 'default', enabled: true }, + { id: 'p2', name: 'Project 2', identifier: 'project-2', description: '', icon: '', cwd: '/tmp/p2', profile: 'default', enabled: true }, + { id: 'p3', name: 'Disabled', identifier: 'disabled', description: '', icon: '', cwd: '/tmp/p3', profile: 'default', enabled: false }, + ], + }, + { + id: 'g2', + name: 'Group Two', + projects: [ + { id: 'p4', name: 'Project 4', identifier: 'project-4', description: '', icon: '', cwd: '/tmp/p4', profile: 'default', enabled: true }, + ], + }, + ], + activeGroupId: 'g1', + }; +} + +vi.mock('../stores/agents.svelte', () => ({ + clearAllAgentSessions: vi.fn(), +})); + +vi.mock('../stores/conflicts.svelte', () => ({ + clearAllConflicts: vi.fn(), +})); + +vi.mock('../agent-dispatcher', () => ({ + waitForPendingPersistence: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../adapters/groups-bridge', () => ({ + loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())), + saveGroups: vi.fn().mockResolvedValue(undefined), + getCliGroup: vi.fn().mockResolvedValue(null), +})); + +import { + getGroupsConfig, + getActiveGroupId, + getActiveTab, + getActiveProjectId, + getActiveGroup, + getEnabledProjects, + getAllGroups, + setActiveTab, + setActiveProject, + switchGroup, + getTerminalTabs, + addTerminalTab, + removeTerminalTab, + loadWorkspace, + addGroup, + removeGroup, + updateProject, + addProject, + removeProject, +} from './workspace.svelte'; + +import { saveGroups, getCliGroup } from '../adapters/groups-bridge'; + +beforeEach(async () => { + vi.clearAllMocks(); + // Reset state by reloading + await loadWorkspace(); +}); + +describe('workspace store', () => { + describe('loadWorkspace', () => { + it('loads groups config and sets active group', async () => { + expect(getGroupsConfig()).not.toBeNull(); + expect(getActiveGroupId()).toBe('g1'); + }); + + it('auto-focuses first enabled project', async () => { + expect(getActiveProjectId()).toBe('p1'); + }); + + it('accepts initialGroupId override', async () => { + await loadWorkspace('g2'); + expect(getActiveGroupId()).toBe('g2'); + expect(getActiveProjectId()).toBe('p4'); + }); + + it('falls back to first group if target not found', async () => { + await loadWorkspace('nonexistent'); + expect(getActiveGroupId()).toBe('g1'); + }); + + it('uses CLI --group flag when no initialGroupId given', async () => { + vi.mocked(getCliGroup).mockResolvedValueOnce('Group Two'); + await loadWorkspace(); + expect(getActiveGroupId()).toBe('g2'); + }); + }); + + describe('getters', () => { + it('getActiveGroup returns the active group config', () => { + const group = getActiveGroup(); + expect(group).toBeDefined(); + expect(group!.id).toBe('g1'); + expect(group!.name).toBe('Group One'); + }); + + it('getEnabledProjects filters disabled projects', () => { + const projects = getEnabledProjects(); + expect(projects).toHaveLength(2); + expect(projects.map(p => p.id)).toEqual(['p1', 'p2']); + }); + + it('getAllGroups returns all groups', () => { + const groups = getAllGroups(); + expect(groups).toHaveLength(2); + }); + }); + + describe('setters', () => { + it('setActiveTab changes the active tab', () => { + setActiveTab('docs'); + expect(getActiveTab()).toBe('docs'); + setActiveTab('sessions'); + expect(getActiveTab()).toBe('sessions'); + }); + + it('setActiveProject changes the active project', () => { + setActiveProject('p2'); + expect(getActiveProjectId()).toBe('p2'); + }); + }); + + describe('switchGroup', () => { + it('switches to a different group and auto-focuses first project', async () => { + await switchGroup('g2'); + expect(getActiveGroupId()).toBe('g2'); + expect(getActiveProjectId()).toBe('p4'); + }); + + it('clears terminal tabs on group switch', async () => { + addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' }); + expect(getTerminalTabs('p1')).toHaveLength(1); + + await switchGroup('g2'); + expect(getTerminalTabs('p1')).toHaveLength(0); + }); + + it('no-ops when switching to current group', async () => { + const projectBefore = getActiveProjectId(); + vi.mocked(saveGroups).mockClear(); + await switchGroup('g1'); + // State should remain unchanged + expect(getActiveGroupId()).toBe('g1'); + expect(getActiveProjectId()).toBe(projectBefore); + expect(saveGroups).not.toHaveBeenCalled(); + }); + + it('persists active group', async () => { + await switchGroup('g2'); + expect(saveGroups).toHaveBeenCalled(); + }); + }); + + describe('terminal tabs', () => { + it('adds and retrieves terminal tabs per project', () => { + addTerminalTab('p1', { id: 't1', title: 'Shell 1', type: 'shell' }); + addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' }); + addTerminalTab('p2', { id: 't3', title: 'SSH', type: 'ssh', sshSessionId: 'ssh1' }); + + expect(getTerminalTabs('p1')).toHaveLength(2); + expect(getTerminalTabs('p2')).toHaveLength(1); + expect(getTerminalTabs('p2')[0].sshSessionId).toBe('ssh1'); + }); + + it('removes terminal tabs by id', () => { + addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' }); + addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' }); + + removeTerminalTab('p1', 't1'); + expect(getTerminalTabs('p1')).toHaveLength(1); + expect(getTerminalTabs('p1')[0].id).toBe('t2'); + }); + + it('returns empty array for unknown project', () => { + expect(getTerminalTabs('unknown')).toEqual([]); + }); + }); + + describe('group mutation', () => { + it('addGroup adds a new group', () => { + addGroup({ id: 'g3', name: 'New Group', projects: [] }); + expect(getAllGroups()).toHaveLength(3); + expect(saveGroups).toHaveBeenCalled(); + }); + + it('removeGroup removes the group and resets active if needed', () => { + removeGroup('g1'); + expect(getAllGroups()).toHaveLength(1); + expect(getActiveGroupId()).toBe('g2'); + }); + + it('removeGroup with non-active group keeps active unchanged', () => { + removeGroup('g2'); + expect(getAllGroups()).toHaveLength(1); + expect(getActiveGroupId()).toBe('g1'); + }); + }); + + describe('project mutation', () => { + it('updateProject updates project fields', () => { + updateProject('g1', 'p1', { name: 'Renamed' }); + const group = getActiveGroup()!; + expect(group.projects.find(p => p.id === 'p1')!.name).toBe('Renamed'); + expect(saveGroups).toHaveBeenCalled(); + }); + + it('addProject adds a project to a group', () => { + addProject('g1', { + id: 'p5', name: 'New', identifier: 'new', description: '', + icon: '', cwd: '/tmp', profile: 'default', enabled: true, + }); + const group = getActiveGroup()!; + expect(group.projects).toHaveLength(4); + }); + + it('addProject respects 5-project limit', () => { + // g1 already has 3 projects, add 2 more to reach 5 + addProject('g1', { id: 'x1', name: 'X1', identifier: 'x1', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); + addProject('g1', { id: 'x2', name: 'X2', identifier: 'x2', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); + // This 6th should be rejected + addProject('g1', { id: 'x3', name: 'X3', identifier: 'x3', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); + const group = getActiveGroup()!; + expect(group.projects).toHaveLength(5); + }); + + it('removeProject removes and clears activeProjectId if needed', () => { + setActiveProject('p1'); + removeProject('g1', 'p1'); + expect(getActiveProjectId()).toBeNull(); + const group = getActiveGroup()!; + expect(group.projects.find(p => p.id === 'p1')).toBeUndefined(); + }); + }); +}); diff --git a/v2/src/lib/styles/catppuccin.css b/v2/src/lib/styles/catppuccin.css new file mode 100644 index 0000000..73a3ccd --- /dev/null +++ b/v2/src/lib/styles/catppuccin.css @@ -0,0 +1,61 @@ +/* Catppuccin Mocha — https://catppuccin.com/palette */ +:root { + --ctp-rosewater: #f5e0dc; + --ctp-flamingo: #f2cdcd; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-maroon: #eba0ac; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-sapphire: #74c7ec; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-text: #cdd6f4; + --ctp-subtext1: #bac2de; + --ctp-subtext0: #a6adc8; + --ctp-overlay2: #9399b2; + --ctp-overlay1: #7f849c; + --ctp-overlay0: #6c7086; + --ctp-surface2: #585b70; + --ctp-surface1: #45475a; + --ctp-surface0: #313244; + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --ctp-crust: #11111b; + + /* Semantic aliases */ + --bg-primary: var(--ctp-base); + --bg-secondary: var(--ctp-mantle); + --bg-tertiary: var(--ctp-crust); + --bg-surface: var(--ctp-surface0); + --bg-surface-hover: var(--ctp-surface1); + --text-primary: var(--ctp-text); + --text-secondary: var(--ctp-subtext1); + --text-muted: var(--ctp-overlay1); + --border: var(--ctp-surface1); + --accent: var(--ctp-blue); + --accent-hover: var(--ctp-sapphire); + --success: var(--ctp-green); + --warning: var(--ctp-yellow); + --error: var(--ctp-red); + + /* Typography */ + --ui-font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --ui-font-size: 13px; + --term-font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --term-font-size: 13px; + + /* Layout */ + --sidebar-width: 260px; + --right-panel-width: 380px; + --pane-header-height: 32px; + --pane-gap: 2px; + --border-radius: 4px; + + /* Pane content padding — shared between AgentPane and MarkdownPane */ + --bterminal-pane-padding-inline: clamp(0.75rem, 3.5cqi, 2rem); +} diff --git a/v2/src/lib/styles/themes.ts b/v2/src/lib/styles/themes.ts new file mode 100644 index 0000000..cdeaa17 --- /dev/null +++ b/v2/src/lib/styles/themes.ts @@ -0,0 +1,383 @@ +// Theme system — Catppuccin flavors + popular editor themes +// All themes map to the same --ctp-* CSS custom property slots. + +/** All available theme identifiers */ +export type ThemeId = + | 'mocha' | 'macchiato' | 'frappe' | 'latte' + | 'vscode-dark' | 'atom-one-dark' | 'monokai' | 'dracula' + | 'nord' | 'solarized-dark' | 'github-dark' + | 'tokyo-night' | 'gruvbox-dark' | 'ayu-dark' | 'poimandres' + | 'vesper' | 'midnight'; + +/** Keep for backwards compat — subset of ThemeId */ +export type CatppuccinFlavor = 'latte' | 'frappe' | 'macchiato' | 'mocha'; + +export interface ThemePalette { + rosewater: string; + flamingo: string; + pink: string; + mauve: string; + red: string; + maroon: string; + peach: string; + yellow: string; + green: string; + teal: string; + sky: string; + sapphire: string; + blue: string; + lavender: string; + text: string; + subtext1: string; + subtext0: string; + overlay2: string; + overlay1: string; + overlay0: string; + surface2: string; + surface1: string; + surface0: string; + base: string; + mantle: string; + crust: string; +} + +/** Keep old name as alias */ +export type CatppuccinPalette = ThemePalette; + +export interface XtermTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + selectionForeground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export interface ThemeMeta { + id: ThemeId; + label: string; + group: string; // For grouping in + isDark: boolean; +} + +export const THEME_LIST: ThemeMeta[] = [ + { id: 'mocha', label: 'Catppuccin Mocha', group: 'Catppuccin', isDark: true }, + { id: 'macchiato', label: 'Catppuccin Macchiato',group: 'Catppuccin', isDark: true }, + { id: 'frappe', label: 'Catppuccin Frappé', group: 'Catppuccin', isDark: true }, + { id: 'latte', label: 'Catppuccin Latte', group: 'Catppuccin', isDark: false }, + { id: 'vscode-dark', label: 'VSCode Dark+', group: 'Editor', isDark: true }, + { id: 'atom-one-dark', label: 'Atom One Dark', group: 'Editor', isDark: true }, + { id: 'monokai', label: 'Monokai', group: 'Editor', isDark: true }, + { id: 'dracula', label: 'Dracula', group: 'Editor', isDark: true }, + { id: 'nord', label: 'Nord', group: 'Editor', isDark: true }, + { id: 'solarized-dark', label: 'Solarized Dark', group: 'Editor', isDark: true }, + { id: 'github-dark', label: 'GitHub Dark', group: 'Editor', isDark: true }, + { id: 'tokyo-night', label: 'Tokyo Night', group: 'Deep Dark', isDark: true }, + { id: 'gruvbox-dark', label: 'Gruvbox Dark', group: 'Deep Dark', isDark: true }, + { id: 'ayu-dark', label: 'Ayu Dark', group: 'Deep Dark', isDark: true }, + { id: 'poimandres', label: 'Poimandres', group: 'Deep Dark', isDark: true }, + { id: 'vesper', label: 'Vesper', group: 'Deep Dark', isDark: true }, + { id: 'midnight', label: 'Midnight', group: 'Deep Dark', isDark: true }, +]; + +const palettes: Record = { + // --- Catppuccin --- + latte: { + rosewater: '#dc8a78', flamingo: '#dd7878', pink: '#ea76cb', mauve: '#8839ef', + red: '#d20f39', maroon: '#e64553', peach: '#fe640b', yellow: '#df8e1d', + green: '#40a02b', teal: '#179299', sky: '#04a5e5', sapphire: '#209fb5', + blue: '#1e66f5', lavender: '#7287fd', + text: '#4c4f69', subtext1: '#5c5f77', subtext0: '#6c6f85', + overlay2: '#7c7f93', overlay1: '#8c8fa1', overlay0: '#9ca0b0', + surface2: '#acb0be', surface1: '#bcc0cc', surface0: '#ccd0da', + base: '#eff1f5', mantle: '#e6e9ef', crust: '#dce0e8', + }, + frappe: { + rosewater: '#f2d5cf', flamingo: '#eebebe', pink: '#f4b8e4', mauve: '#ca9ee6', + red: '#e78284', maroon: '#ea999c', peach: '#ef9f76', yellow: '#e5c890', + green: '#a6d189', teal: '#81c8be', sky: '#99d1db', sapphire: '#85c1dc', + blue: '#8caaee', lavender: '#babbf1', + text: '#c6d0f5', subtext1: '#b5bfe2', subtext0: '#a5adce', + overlay2: '#949cbb', overlay1: '#838ba7', overlay0: '#737994', + surface2: '#626880', surface1: '#51576d', surface0: '#414559', + base: '#303446', mantle: '#292c3c', crust: '#232634', + }, + macchiato: { + rosewater: '#f4dbd6', flamingo: '#f0c6c6', pink: '#f5bde6', mauve: '#c6a0f6', + red: '#ed8796', maroon: '#ee99a0', peach: '#f5a97f', yellow: '#eed49f', + green: '#a6da95', teal: '#8bd5ca', sky: '#91d7e3', sapphire: '#7dc4e4', + blue: '#8aadf4', lavender: '#b7bdf8', + text: '#cad3f5', subtext1: '#b8c0e0', subtext0: '#a5adcb', + overlay2: '#939ab7', overlay1: '#8087a2', overlay0: '#6e738d', + surface2: '#5b6078', surface1: '#494d64', surface0: '#363a4f', + base: '#24273a', mantle: '#1e2030', crust: '#181926', + }, + mocha: { + rosewater: '#f5e0dc', flamingo: '#f2cdcd', pink: '#f5c2e7', mauve: '#cba6f7', + red: '#f38ba8', maroon: '#eba0ac', peach: '#fab387', yellow: '#f9e2af', + green: '#a6e3a1', teal: '#94e2d5', sky: '#89dceb', sapphire: '#74c7ec', + blue: '#89b4fa', lavender: '#b4befe', + text: '#cdd6f4', subtext1: '#bac2de', subtext0: '#a6adc8', + overlay2: '#9399b2', overlay1: '#7f849c', overlay0: '#6c7086', + surface2: '#585b70', surface1: '#45475a', surface0: '#313244', + base: '#1e1e2e', mantle: '#181825', crust: '#11111b', + }, + + // --- VSCode Dark+ --- + 'vscode-dark': { + rosewater: '#d4a0a0', flamingo: '#cf8686', pink: '#c586c0', mauve: '#c586c0', + red: '#f44747', maroon: '#d16969', peach: '#ce9178', yellow: '#dcdcaa', + green: '#6a9955', teal: '#4ec9b0', sky: '#9cdcfe', sapphire: '#4fc1ff', + blue: '#569cd6', lavender: '#b4b4f7', + text: '#d4d4d4', subtext1: '#cccccc', subtext0: '#b0b0b0', + overlay2: '#858585', overlay1: '#6e6e6e', overlay0: '#5a5a5a', + surface2: '#3e3e42', surface1: '#333338', surface0: '#2d2d30', + base: '#1e1e1e', mantle: '#181818', crust: '#111111', + }, + + // --- Atom One Dark --- + 'atom-one-dark': { + rosewater: '#e5c07b', flamingo: '#e06c75', pink: '#c678dd', mauve: '#c678dd', + red: '#e06c75', maroon: '#be5046', peach: '#d19a66', yellow: '#e5c07b', + green: '#98c379', teal: '#56b6c2', sky: '#56b6c2', sapphire: '#61afef', + blue: '#61afef', lavender: '#c8ccd4', + text: '#abb2bf', subtext1: '#9da5b4', subtext0: '#8b92a0', + overlay2: '#7f848e', overlay1: '#636d83', overlay0: '#545862', + surface2: '#474b56', surface1: '#3b3f4c', surface0: '#333842', + base: '#282c34', mantle: '#21252b', crust: '#181a1f', + }, + + // --- Monokai --- + monokai: { + rosewater: '#f8f8f2', flamingo: '#f92672', pink: '#f92672', mauve: '#ae81ff', + red: '#f92672', maroon: '#f92672', peach: '#fd971f', yellow: '#e6db74', + green: '#a6e22e', teal: '#66d9ef', sky: '#66d9ef', sapphire: '#66d9ef', + blue: '#66d9ef', lavender: '#ae81ff', + text: '#f8f8f2', subtext1: '#e8e8e2', subtext0: '#cfcfc2', + overlay2: '#a8a8a2', overlay1: '#90908a', overlay0: '#75715e', + surface2: '#595950', surface1: '#49483e', surface0: '#3e3d32', + base: '#272822', mantle: '#1e1f1c', crust: '#141411', + }, + + // --- Dracula --- + dracula: { + rosewater: '#f1c4e0', flamingo: '#ff79c6', pink: '#ff79c6', mauve: '#bd93f9', + red: '#ff5555', maroon: '#ff6e6e', peach: '#ffb86c', yellow: '#f1fa8c', + green: '#50fa7b', teal: '#8be9fd', sky: '#8be9fd', sapphire: '#8be9fd', + blue: '#6272a4', lavender: '#bd93f9', + text: '#f8f8f2', subtext1: '#e8e8e2', subtext0: '#c0c0ba', + overlay2: '#a0a0a0', overlay1: '#7f7f7f', overlay0: '#6272a4', + surface2: '#555969', surface1: '#44475a', surface0: '#383a4a', + base: '#282a36', mantle: '#21222c', crust: '#191a21', + }, + + // --- Nord --- + nord: { + rosewater: '#d08770', flamingo: '#bf616a', pink: '#b48ead', mauve: '#b48ead', + red: '#bf616a', maroon: '#bf616a', peach: '#d08770', yellow: '#ebcb8b', + green: '#a3be8c', teal: '#8fbcbb', sky: '#88c0d0', sapphire: '#81a1c1', + blue: '#5e81ac', lavender: '#b48ead', + text: '#eceff4', subtext1: '#e5e9f0', subtext0: '#d8dee9', + overlay2: '#a5adba', overlay1: '#8891a0', overlay0: '#6c7588', + surface2: '#4c566a', surface1: '#434c5e', surface0: '#3b4252', + base: '#2e3440', mantle: '#272c36', crust: '#20242c', + }, + + // --- Solarized Dark --- + 'solarized-dark': { + rosewater: '#d33682', flamingo: '#dc322f', pink: '#d33682', mauve: '#6c71c4', + red: '#dc322f', maroon: '#cb4b16', peach: '#cb4b16', yellow: '#b58900', + green: '#859900', teal: '#2aa198', sky: '#2aa198', sapphire: '#268bd2', + blue: '#268bd2', lavender: '#6c71c4', + text: '#839496', subtext1: '#93a1a1', subtext0: '#778a8b', + overlay2: '#657b83', overlay1: '#586e75', overlay0: '#4a6068', + surface2: '#1c4753', surface1: '#143845', surface0: '#073642', + base: '#002b36', mantle: '#00222b', crust: '#001a21', + }, + + // --- GitHub Dark --- + 'github-dark': { + rosewater: '#ffa198', flamingo: '#ff7b72', pink: '#f778ba', mauve: '#d2a8ff', + red: '#ff7b72', maroon: '#ffa198', peach: '#ffa657', yellow: '#e3b341', + green: '#7ee787', teal: '#56d4dd', sky: '#79c0ff', sapphire: '#79c0ff', + blue: '#58a6ff', lavender: '#d2a8ff', + text: '#c9d1d9', subtext1: '#b1bac4', subtext0: '#8b949e', + overlay2: '#6e7681', overlay1: '#565c64', overlay0: '#484f58', + surface2: '#373e47', surface1: '#30363d', surface0: '#21262d', + base: '#0d1117', mantle: '#090c10', crust: '#050608', + }, + + // --- Tokyo Night --- + 'tokyo-night': { + rosewater: '#f7768e', flamingo: '#ff9e64', pink: '#bb9af7', mauve: '#bb9af7', + red: '#f7768e', maroon: '#db4b4b', peach: '#ff9e64', yellow: '#e0af68', + green: '#9ece6a', teal: '#73daca', sky: '#7dcfff', sapphire: '#7aa2f7', + blue: '#7aa2f7', lavender: '#bb9af7', + text: '#c0caf5', subtext1: '#a9b1d6', subtext0: '#9aa5ce', + overlay2: '#787c99', overlay1: '#565f89', overlay0: '#414868', + surface2: '#3b4261', surface1: '#292e42', surface0: '#232433', + base: '#1a1b26', mantle: '#16161e', crust: '#101014', + }, + + // --- Gruvbox Dark --- + 'gruvbox-dark': { + rosewater: '#d65d0e', flamingo: '#cc241d', pink: '#d3869b', mauve: '#b16286', + red: '#fb4934', maroon: '#cc241d', peach: '#fe8019', yellow: '#fabd2f', + green: '#b8bb26', teal: '#8ec07c', sky: '#83a598', sapphire: '#83a598', + blue: '#458588', lavender: '#d3869b', + text: '#ebdbb2', subtext1: '#d5c4a1', subtext0: '#bdae93', + overlay2: '#a89984', overlay1: '#928374', overlay0: '#7c6f64', + surface2: '#504945', surface1: '#3c3836', surface0: '#32302f', + base: '#1d2021', mantle: '#191b1c', crust: '#141617', + }, + + // --- Ayu Dark --- + 'ayu-dark': { + rosewater: '#f07178', flamingo: '#f07178', pink: '#d2a6ff', mauve: '#d2a6ff', + red: '#f07178', maroon: '#f07178', peach: '#ff8f40', yellow: '#ffb454', + green: '#aad94c', teal: '#95e6cb', sky: '#73b8ff', sapphire: '#59c2ff', + blue: '#59c2ff', lavender: '#d2a6ff', + text: '#bfbdb6', subtext1: '#acaaa4', subtext0: '#9b9892', + overlay2: '#73726e', overlay1: '#5c5b57', overlay0: '#464542', + surface2: '#383838', surface1: '#2c2c2c', surface0: '#242424', + base: '#0b0e14', mantle: '#080a0f', crust: '#05070a', + }, + + // --- Poimandres --- + 'poimandres': { + rosewater: '#d0679d', flamingo: '#d0679d', pink: '#fcc5e9', mauve: '#a6accd', + red: '#d0679d', maroon: '#d0679d', peach: '#e4f0fb', yellow: '#fffac2', + green: '#5de4c7', teal: '#5de4c7', sky: '#89ddff', sapphire: '#add7ff', + blue: '#91b4d5', lavender: '#a6accd', + text: '#e4f0fb', subtext1: '#d0d6e0', subtext0: '#a6accd', + overlay2: '#767c9d', overlay1: '#506477', overlay0: '#3e4f5e', + surface2: '#303340', surface1: '#252b37', surface0: '#1e2433', + base: '#1b1e28', mantle: '#171922', crust: '#12141c', + }, + + // --- Vesper --- + 'vesper': { + rosewater: '#de6e6e', flamingo: '#de6e6e', pink: '#c79bf0', mauve: '#c79bf0', + red: '#de6e6e', maroon: '#de6e6e', peach: '#ffcfa8', yellow: '#ffc799', + green: '#7cb37c', teal: '#6bccb0', sky: '#8abeb7', sapphire: '#6eb4bf', + blue: '#6eb4bf', lavender: '#c79bf0', + text: '#b8b5ad', subtext1: '#a09d95', subtext0: '#878480', + overlay2: '#6e6b66', overlay1: '#55524d', overlay0: '#3d3a36', + surface2: '#302e2a', surface1: '#252320', surface0: '#1c1a17', + base: '#101010', mantle: '#0a0a0a', crust: '#050505', + }, + + // --- Midnight (pure black OLED) --- + midnight: { + rosewater: '#e8a0bf', flamingo: '#ea6f91', pink: '#e8a0bf', mauve: '#c4a7e7', + red: '#eb6f92', maroon: '#ea6f91', peach: '#f6c177', yellow: '#ebbcba', + green: '#9ccfd8', teal: '#9ccfd8', sky: '#a4d4e4', sapphire: '#8bbee8', + blue: '#7ba4cc', lavender: '#c4a7e7', + text: '#c4c4c4', subtext1: '#a8a8a8', subtext0: '#8c8c8c', + overlay2: '#6e6e6e', overlay1: '#525252', overlay0: '#383838', + surface2: '#262626', surface1: '#1a1a1a', surface0: '#111111', + base: '#000000', mantle: '#000000', crust: '#000000', + }, +}; + +export function getPalette(theme: ThemeId): ThemePalette { + return palettes[theme]; +} + +/** Build xterm.js ITheme from a palette */ +export function buildXtermTheme(theme: ThemeId): XtermTheme { + const p = palettes[theme]; + return { + background: p.base, + foreground: p.text, + cursor: p.rosewater, + cursorAccent: p.base, + selectionBackground: p.surface1, + selectionForeground: p.text, + black: p.surface1, + red: p.red, + green: p.green, + yellow: p.yellow, + blue: p.blue, + magenta: p.pink, + cyan: p.teal, + white: p.subtext1, + brightBlack: p.surface2, + brightRed: p.red, + brightGreen: p.green, + brightYellow: p.yellow, + brightBlue: p.blue, + brightMagenta: p.pink, + brightCyan: p.teal, + brightWhite: p.subtext0, + }; +} + +/** CSS custom property names mapped to palette keys */ +const CSS_VAR_MAP: [string, keyof ThemePalette][] = [ + ['--ctp-rosewater', 'rosewater'], + ['--ctp-flamingo', 'flamingo'], + ['--ctp-pink', 'pink'], + ['--ctp-mauve', 'mauve'], + ['--ctp-red', 'red'], + ['--ctp-maroon', 'maroon'], + ['--ctp-peach', 'peach'], + ['--ctp-yellow', 'yellow'], + ['--ctp-green', 'green'], + ['--ctp-teal', 'teal'], + ['--ctp-sky', 'sky'], + ['--ctp-sapphire', 'sapphire'], + ['--ctp-blue', 'blue'], + ['--ctp-lavender', 'lavender'], + ['--ctp-text', 'text'], + ['--ctp-subtext1', 'subtext1'], + ['--ctp-subtext0', 'subtext0'], + ['--ctp-overlay2', 'overlay2'], + ['--ctp-overlay1', 'overlay1'], + ['--ctp-overlay0', 'overlay0'], + ['--ctp-surface2', 'surface2'], + ['--ctp-surface1', 'surface1'], + ['--ctp-surface0', 'surface0'], + ['--ctp-base', 'base'], + ['--ctp-mantle', 'mantle'], + ['--ctp-crust', 'crust'], +]; + +/** Apply a theme's CSS custom properties to document root */ +export function applyCssVariables(theme: ThemeId): void { + const p = palettes[theme]; + const style = document.documentElement.style; + for (const [varName, key] of CSS_VAR_MAP) { + style.setProperty(varName, p[key]); + } +} + +/** @deprecated Use THEME_LIST instead */ +export const FLAVOR_LABELS: Record = { + latte: 'Latte (Light)', + frappe: 'Frappe', + macchiato: 'Macchiato', + mocha: 'Mocha (Default)', +}; + +/** @deprecated Use THEME_LIST instead */ +export const ALL_FLAVORS: CatppuccinFlavor[] = ['latte', 'frappe', 'macchiato', 'mocha']; + +/** All valid theme IDs for validation */ +export const ALL_THEME_IDS: ThemeId[] = THEME_LIST.map(t => t.id); diff --git a/v2/src/lib/types/anchors.ts b/v2/src/lib/types/anchors.ts new file mode 100644 index 0000000..cc61270 --- /dev/null +++ b/v2/src/lib/types/anchors.ts @@ -0,0 +1,72 @@ +// Session Anchor types — preserves important conversation turns through compaction chains +// Anchored turns are re-injected into system prompt on subsequent queries + +/** Anchor classification */ +export type AnchorType = 'auto' | 'pinned' | 'promoted'; + +/** A single anchored turn, stored per-project */ +export interface SessionAnchor { + id: string; + projectId: string; + messageId: string; + anchorType: AnchorType; + /** Serialized turn text for re-injection (observation-masked) */ + content: string; + /** Estimated token count (~chars/4) */ + estimatedTokens: number; + /** Turn index in original session */ + turnIndex: number; + createdAt: number; +} + +/** Settings for anchor behavior, stored per-project */ +export interface AnchorSettings { + /** Number of turns to auto-anchor on first compaction (default: 3) */ + anchorTurns: number; + /** Hard cap on re-injectable anchor tokens (default: 6144) */ + anchorTokenBudget: number; +} + +export const DEFAULT_ANCHOR_SETTINGS: AnchorSettings = { + anchorTurns: 3, + anchorTokenBudget: 6144, +}; + +/** Maximum token budget for re-injected anchors */ +export const MAX_ANCHOR_TOKEN_BUDGET = 20_000; +/** Minimum token budget */ +export const MIN_ANCHOR_TOKEN_BUDGET = 2_000; + +/** Budget scale presets — maps to provider context window sizes */ +export type AnchorBudgetScale = 'small' | 'medium' | 'large' | 'full'; + +/** Token budget for each scale preset */ +export const ANCHOR_BUDGET_SCALE_MAP: Record = { + small: 2_000, + medium: 6_144, + large: 12_000, + full: 20_000, +}; + +/** Human-readable labels for budget scale presets */ +export const ANCHOR_BUDGET_SCALE_LABELS: Record = { + small: 'Small (2K)', + medium: 'Medium (6K)', + large: 'Large (12K)', + full: 'Full (20K)', +}; + +/** Ordered list of scales for slider indexing */ +export const ANCHOR_BUDGET_SCALES: AnchorBudgetScale[] = ['small', 'medium', 'large', 'full']; + +/** Rust-side record shape (matches SessionAnchorRecord in session.rs) */ +export interface SessionAnchorRecord { + id: string; + project_id: string; + message_id: string; + anchor_type: string; + content: string; + estimated_tokens: number; + turn_index: number; + created_at: number; +} diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts new file mode 100644 index 0000000..ff6e73d --- /dev/null +++ b/v2/src/lib/types/groups.ts @@ -0,0 +1,98 @@ +import type { ProviderId } from '../providers/types'; +import type { AnchorBudgetScale } from './anchors'; + +export interface ProjectConfig { + id: string; + name: string; + identifier: string; + description: string; + icon: string; + cwd: string; + profile: string; + enabled: boolean; + /** Agent provider for this project (defaults to 'claude') */ + provider?: ProviderId; + /** When true, agents for this project use git worktrees for isolation */ + useWorktrees?: boolean; + /** Anchor token budget scale (defaults to 'medium' = 6K tokens) */ + anchorBudgetScale?: AnchorBudgetScale; + /** Stall detection threshold in minutes (defaults to 15) */ + stallThresholdMin?: number; + /** True for Tier 1 management agents rendered as project boxes */ + isAgent?: boolean; + /** Agent role (manager/architect/tester/reviewer) — only when isAgent */ + agentRole?: GroupAgentRole; + /** System prompt injected at session start — only when isAgent */ + systemPrompt?: string; +} + +export const AGENT_ROLE_ICONS: Record = { + manager: '🎯', + architect: '🏗', + tester: '🧪', + reviewer: '🔍', +}; + +/** Convert a GroupAgentConfig to a ProjectConfig for unified rendering */ +export function agentToProject(agent: GroupAgentConfig, groupCwd: string): ProjectConfig { + return { + id: agent.id, + name: agent.name, + identifier: agent.role, + description: `${agent.role.charAt(0).toUpperCase() + agent.role.slice(1)} agent`, + icon: AGENT_ROLE_ICONS[agent.role] ?? '🤖', + cwd: agent.cwd ?? groupCwd, + profile: 'default', + enabled: agent.enabled, + isAgent: true, + agentRole: agent.role, + systemPrompt: agent.systemPrompt, + }; +} + +/** Group-level agent role (Tier 1 management agents) */ +export type GroupAgentRole = 'manager' | 'architect' | 'tester' | 'reviewer'; + +/** Group-level agent status */ +export type GroupAgentStatus = 'active' | 'sleeping' | 'stopped'; + +/** Group-level agent configuration */ +export interface GroupAgentConfig { + id: string; + name: string; + role: GroupAgentRole; + model?: string; + cwd?: string; + systemPrompt?: string; + enabled: boolean; + /** Auto-wake interval in minutes (Manager only, default 3) */ + wakeIntervalMin?: number; +} + +export interface GroupConfig { + id: string; + name: string; + projects: ProjectConfig[]; + /** Group-level orchestration agents (Tier 1) */ + agents?: GroupAgentConfig[]; +} + +export interface GroupsFile { + version: number; + groups: GroupConfig[]; + activeGroupId: string; +} + +/** Derive a project identifier from a name: lowercase, spaces to dashes */ +export function deriveIdentifier(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); +} + +/** Project accent colors by slot index (0-4), Catppuccin Mocha */ +export const PROJECT_ACCENTS = [ + '--ctp-blue', + '--ctp-green', + '--ctp-mauve', + '--ctp-peach', + '--ctp-pink', +] as const; diff --git a/v2/src/lib/types/ids.test.ts b/v2/src/lib/types/ids.test.ts new file mode 100644 index 0000000..43798b3 --- /dev/null +++ b/v2/src/lib/types/ids.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { SessionId, ProjectId, type SessionId as SessionIdType, type ProjectId as ProjectIdType } from './ids'; + +describe('branded types', () => { + describe('SessionId', () => { + it('creates a SessionId from a string', () => { + const id = SessionId('sess-abc-123'); + expect(id).toBe('sess-abc-123'); + }); + + it('is usable as a string (template literal)', () => { + const id = SessionId('sess-1'); + expect(`session: ${id}`).toBe('session: sess-1'); + }); + + it('is usable as a Map key', () => { + const map = new Map(); + const id = SessionId('sess-1'); + map.set(id, 42); + expect(map.get(id)).toBe(42); + }); + + it('equality works between two SessionIds with same value', () => { + const a = SessionId('sess-1'); + const b = SessionId('sess-1'); + expect(a === b).toBe(true); + }); + }); + + describe('ProjectId', () => { + it('creates a ProjectId from a string', () => { + const id = ProjectId('proj-xyz'); + expect(id).toBe('proj-xyz'); + }); + + it('is usable as a Map key', () => { + const map = new Map(); + const id = ProjectId('proj-1'); + map.set(id, 'test-project'); + expect(map.get(id)).toBe('test-project'); + }); + }); + + describe('type safety (compile-time)', () => { + it('both types are strings at runtime', () => { + const sid = SessionId('s1'); + const pid = ProjectId('p1'); + expect(typeof sid).toBe('string'); + expect(typeof pid).toBe('string'); + }); + }); +}); diff --git a/v2/src/lib/types/ids.ts b/v2/src/lib/types/ids.ts new file mode 100644 index 0000000..1a51aca --- /dev/null +++ b/v2/src/lib/types/ids.ts @@ -0,0 +1,18 @@ +// Branded types for domain identifiers — prevents accidental swapping of sessionId/projectId +// These are compile-time only; at runtime they are plain strings. + +/** Unique identifier for an agent session */ +export type SessionId = string & { readonly __brand: 'SessionId' }; + +/** Unique identifier for a project */ +export type ProjectId = string & { readonly __brand: 'ProjectId' }; + +/** Create a SessionId from a raw string */ +export function SessionId(value: string): SessionId { + return value as SessionId; +} + +/** Create a ProjectId from a raw string */ +export function ProjectId(value: string): ProjectId { + return value as ProjectId; +} diff --git a/v2/src/lib/utils/agent-prompts.ts b/v2/src/lib/utils/agent-prompts.ts new file mode 100644 index 0000000..36c752e --- /dev/null +++ b/v2/src/lib/utils/agent-prompts.ts @@ -0,0 +1,360 @@ +/** + * System prompt generator for management agents. + * Builds comprehensive introductory context including: + * - Environment description (group, projects, team) + * - Role-specific instructions + * - Full btmsg/bttask tool documentation + * - Communication hierarchy + * - Custom editable context (from groups.json or Memora) + * + * This prompt is injected at every session start and should be + * re-injected periodically (e.g., hourly) for long-running agents. + */ + +import type { GroupAgentRole, GroupConfig, GroupAgentConfig, ProjectConfig } from '../types/groups'; + +// ─── Role descriptions ────────────────────────────────────── + +const ROLE_DESCRIPTIONS: Record = { + manager: `You are the **Manager** — the central coordinator of this project group. + +**Your authority:** +- You have FULL visibility across all projects and agents +- You create and assign tasks to team members +- You can edit context/instructions for your subordinates +- You escalate blockers and decisions to the Operator (human admin) +- You are the ONLY agent who communicates directly with the Operator + +**Your responsibilities:** +- Break down high-level goals into actionable tasks +- Assign work to the right agents based on their capabilities +- Monitor progress, deadlines, and blockers across all projects +- Coordinate between Architect, Tester, and project agents +- Ensure team alignment and resolve conflicts +- Provide status summaries to the Operator when asked`, + + architect: `You are the **Architect** — responsible for technical design and code quality. + +**Your authority:** +- You review architecture decisions across ALL projects +- You can request changes or block merges on architectural grounds +- You propose technical solutions and document them + +**Your responsibilities:** +- Ensure API consistency between all components (backend, display, etc.) +- Review code for architectural correctness, patterns, and anti-patterns +- Design system interfaces and data flows +- Document architectural decisions and trade-offs +- Report architectural concerns to the Manager +- Mentor project agents on best practices`, + + tester: `You are the **Tester** — responsible for quality assurance across all projects. + +**Your authority:** +- You validate all features before they're considered "done" +- You can mark tasks as "blocked" if they fail tests +- You define testing standards for the team + +**Your responsibilities:** +- Write and run unit, integration, and E2E tests +- Validate features work end-to-end across projects +- Report bugs with clear reproduction steps (to Manager) +- Track test coverage and suggest improvements +- Use Selenium/browser automation for UI testing when needed +- Verify deployments on target hardware (Raspberry Pi)`, + + reviewer: `You are the **Reviewer** — responsible for code review and standards. + +**Your authority:** +- You review all code changes for quality and security +- You can request changes before approval + +**Your responsibilities:** +- Review code quality, security, and adherence to best practices +- Provide constructive, actionable feedback +- Ensure consistent coding standards across projects +- Flag security vulnerabilities and performance issues +- Verify error handling and edge cases`, +}; + +// ─── Tool documentation ───────────────────────────────────── + +const BTMSG_DOCS = ` +## Tool: btmsg — Agent Messenger + +btmsg is your primary communication channel with other agents and the Operator. +Your identity is set automatically (BTMSG_AGENT_ID env var). You don't need to configure it. + +### Reading messages +\`\`\`bash +btmsg inbox # Show unread messages (CHECK THIS FIRST!) +btmsg inbox --all # Show all messages (including read) +btmsg read # Read a specific message (marks as read) +\`\`\` + +### Sending messages +\`\`\`bash +btmsg send "Your message here" # Send direct message +btmsg reply "Your reply here" # Reply to a message +\`\`\` +You can only message agents in your contacts list. Use \`btmsg contacts\` to see who. + +### Information +\`\`\`bash +btmsg contacts # List agents you can message +btmsg history # Conversation history with an agent +btmsg status # All agents and their current status +btmsg whoami # Your identity and unread count +btmsg graph # Visual hierarchy of the team +\`\`\` + +### Channels (group chat) +\`\`\`bash +btmsg channel list # List channels +btmsg channel send "message" # Post to a channel +btmsg channel history # Channel message history +btmsg channel create # Create a new channel +\`\`\` + +### Communication rules +- **Always check \`btmsg inbox\` first** when you start or wake up +- Respond to messages promptly — other agents may be waiting on you +- Keep messages concise and actionable +- Use reply threading (\`btmsg reply\`) to maintain conversation context +- If you need someone not in your contacts, ask the Manager to relay`; + +const BTTASK_DOCS = ` +## Tool: bttask — Task Board + +bttask is a Kanban-style task tracker shared across the team. +Tasks flow through: todo → progress → review → done (or blocked). + +### Viewing tasks +\`\`\`bash +bttask list # List all tasks +bttask board # Kanban board view (5 columns) +bttask show # Full task details + comments +\`\`\` + +### Managing tasks (Manager only) +\`\`\`bash +bttask add "Title" --desc "Description" --priority high # Create task +bttask assign # Assign to agent +bttask delete # Delete task +\`\`\` + +### Working on tasks (all agents) +\`\`\`bash +bttask status progress # Mark as in progress +bttask status review # Ready for review +bttask status done # Completed +bttask status blocked # Blocked (explain in comment!) +bttask comment "Comment" # Add a comment/update +\`\`\` + +### Task priorities: low, medium, high, critical +### Task statuses: todo, progress, review, done, blocked`; + +// ─── Prompt generator ─────────────────────────────────────── + +export interface AgentPromptContext { + role: GroupAgentRole; + agentId: string; + agentName: string; + group: GroupConfig; + /** Custom context editable by Manager/admin */ + customPrompt?: string; +} + +/** + * Generate the full introductory context for an agent. + * This should be injected at session start AND periodically re-injected. + */ +export function generateAgentPrompt(ctx: AgentPromptContext): string { + const { role, agentId, agentName, group, customPrompt } = ctx; + const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`; + + const parts: string[] = []; + + // ── Section 1: Identity ── + parts.push(`# You are: ${agentName} + +${roleDesc} + +**Agent ID:** \`${agentId}\` +**Group:** ${group.name}`); + + // ── Section 2: Environment ── + parts.push(buildEnvironmentSection(group)); + + // ── Section 3: Team ── + parts.push(buildTeamSection(group, agentId)); + + // ── Section 4: Tools ── + parts.push(BTMSG_DOCS); + if (role === 'manager' || role === 'architect') { + parts.push(BTTASK_DOCS); + } else { + // Other agents get read-only bttask info + parts.push(` +## Tool: bttask — Task Board (read + update) + +You can view and update tasks, but cannot create or assign them. + +\`\`\`bash +bttask board # Kanban board view +bttask show # Task details +bttask status # Update: progress/review/done/blocked +bttask comment "update" # Add a comment +\`\`\``); + } + + // ── Section 5: Custom context (editable by Manager/admin) ── + if (customPrompt) { + parts.push(`## Project-Specific Context + +${customPrompt}`); + } + + // ── Section 6: Workflow ── + parts.push(buildWorkflowSection(role)); + + return parts.join('\n\n---\n\n'); +} + +function buildEnvironmentSection(group: GroupConfig): string { + const projects = group.projects.filter(p => p.enabled); + + const projectLines = projects.map(p => { + const parts = [`- **${p.name}** (\`${p.identifier}\`)`]; + if (p.description) parts.push(`— ${p.description}`); + parts.push(`\n CWD: \`${p.cwd}\``); + return parts.join(' '); + }).join('\n'); + + return `## Environment + +**Platform:** BTerminal Mission Control — multi-agent orchestration system +**Group:** ${group.name} +**Your working directory:** Same as the monorepo root (shared across Tier 1 agents) + +### Projects in this group +${projectLines} + +### How it works +- Each project has its own Claude session, terminal, file browser, and context +- Tier 1 agents (you and your peers) coordinate across ALL projects +- Tier 2 agents (project-level) execute code within their specific project CWD +- All communication goes through \`btmsg\`. There is no other way to talk to other agents. +- Task tracking goes through \`bttask\`. This is the shared task board.`; +} + +function buildTeamSection(group: GroupConfig, myId: string): string { + const agents = group.agents ?? []; + const projects = group.projects.filter(p => p.enabled); + + const lines: string[] = ['## Team']; + + // Tier 1 + const tier1 = agents.filter(a => a.id !== myId); + if (tier1.length > 0) { + lines.push('\n### Tier 1 — Management (your peers)'); + for (const a of tier1) { + const status = a.enabled ? '' : ' *(disabled)*'; + lines.push(`- **${a.name}** (\`${a.id}\`, ${a.role})${status}`); + } + } + + // Tier 2 + if (projects.length > 0) { + lines.push('\n### Tier 2 — Execution (project agents)'); + for (const p of projects) { + lines.push(`- **${p.name}** (\`${p.id}\`, project) — works in \`${p.cwd}\``); + } + } + + // Operator + lines.push('\n### Operator (human admin)'); + lines.push('- **Operator** (`admin`) — the human who controls this system. Has full visibility and authority.'); + if (agents.find(a => a.id === myId)?.role === 'manager') { + lines.push(' You report directly to the Operator. Escalate decisions and blockers to them.'); + } else { + lines.push(' Communicate with the Operator only through the Manager, unless directly addressed.'); + } + + // Communication hierarchy + lines.push(`\n### Communication hierarchy +- **Operator** ↔ Manager (direct line) +- **Manager** ↔ all Tier 1 agents ↔ Tier 2 agents they manage +- **Tier 2 agents** report to Manager (and can talk to assigned Tier 1 reviewers) +- Use \`btmsg contacts\` to see exactly who you can reach`); + + return lines.join('\n'); +} + +function buildWorkflowSection(role: GroupAgentRole): string { + if (role === 'manager') { + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — read and respond to all messages +2. **Review task board:** \`bttask board\` — check status of all tasks +3. **Coordinate:** Assign new tasks, unblock agents, resolve conflicts +4. **Monitor:** Check agent status (\`btmsg status\`), follow up on stalled work +5. **Report:** Summarize progress to the Operator when asked +6. **Repeat:** Check inbox again — new messages may have arrived + +**Important:** You are the hub of all communication. If an agent is blocked, YOU unblock them. +If the Operator sends a message, it's your TOP PRIORITY.`; + } + + if (role === 'architect') { + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — the Manager may have requests +2. **Review tasks:** \`bttask board\` — look for tasks assigned to you +3. **Analyze:** Review code, architecture, and design across projects +4. **Document:** Write down decisions and rationale +5. **Communicate:** Send findings to Manager, guide project agents +6. **Update tasks:** Mark completed reviews, comment on progress`; + } + + if (role === 'tester') { + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — the Manager assigns testing tasks +2. **Review assignments:** Check \`bttask board\` for testing tasks +3. **Write tests:** Create test cases, scripts, or Selenium scenarios +4. **Run tests:** Execute and collect results +5. **Report:** Send bug reports to Manager via btmsg, update task status +6. **Verify fixes:** Re-test when developers say a bug is fixed`; + } + + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — read all unread messages +2. **Check tasks:** \`bttask board\` — see what's assigned to you +3. **Work:** Execute your assigned tasks +4. **Update:** \`bttask status progress\` and \`bttask comment "update"\` +5. **Report:** Message the Manager when done or blocked +6. **Repeat:** Check inbox for new messages`; +} + +// ─── Legacy signature (backward compat) ───────────────────── + +/** + * @deprecated Use generateAgentPrompt(ctx) with full context instead + */ +export function generateAgentPromptSimple( + role: GroupAgentRole, + agentId: string, + customPrompt?: string, +): string { + // Minimal fallback without group context + const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`; + return [ + `# Agent Role\n\n${roleDesc}`, + `\nYour agent ID: \`${agentId}\``, + BTMSG_DOCS, + customPrompt ? `\n## Additional Context\n\n${customPrompt}` : '', + ].filter(Boolean).join('\n'); +} diff --git a/v2/src/lib/utils/agent-tree.test.ts b/v2/src/lib/utils/agent-tree.test.ts new file mode 100644 index 0000000..2268244 --- /dev/null +++ b/v2/src/lib/utils/agent-tree.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect } from 'vitest'; +import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree'; +import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/claude-messages'; +import type { AgentTreeNode } from './agent-tree'; + +// Helper to create typed AgentMessages +function makeToolCall( + uuid: string, + toolUseId: string, + name: string, + parentId?: string, +): AgentMessage { + return { + id: uuid, + type: 'tool_call', + parentId, + content: { toolUseId, name, input: {} } satisfies ToolCallContent, + timestamp: Date.now(), + }; +} + +function makeToolResult(uuid: string, toolUseId: string, parentId?: string): AgentMessage { + return { + id: uuid, + type: 'tool_result', + parentId, + content: { toolUseId, output: 'ok' } satisfies ToolResultContent, + timestamp: Date.now(), + }; +} + +function makeTextMessage(uuid: string, text: string, parentId?: string): AgentMessage { + return { + id: uuid, + type: 'text', + parentId, + content: { text }, + timestamp: Date.now(), + }; +} + +describe('buildAgentTree', () => { + it('creates a root node with no children from empty messages', () => { + const tree = buildAgentTree('session-1', [], 'done', 0.05, 1500); + + expect(tree.id).toBe('session-1'); + expect(tree.label).toBe('session-'); + expect(tree.status).toBe('done'); + expect(tree.costUsd).toBe(0.05); + expect(tree.tokens).toBe(1500); + expect(tree.children).toEqual([]); + }); + + it('maps running/starting status to running', () => { + const tree1 = buildAgentTree('s1', [], 'running', 0, 0); + expect(tree1.status).toBe('running'); + + const tree2 = buildAgentTree('s2', [], 'starting', 0, 0); + expect(tree2.status).toBe('running'); + }); + + it('maps error status to error', () => { + const tree = buildAgentTree('s3', [], 'error', 0, 0); + expect(tree.status).toBe('error'); + }); + + it('maps other statuses to done', () => { + const tree = buildAgentTree('s4', [], 'completed', 0, 0); + expect(tree.status).toBe('done'); + }); + + it('adds tool_call messages as children of root', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'tool-1', 'Read'), + makeToolCall('m2', 'tool-2', 'Write'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + + expect(tree.children).toHaveLength(2); + expect(tree.children[0].id).toBe('tool-1'); + expect(tree.children[0].label).toBe('Read'); + expect(tree.children[0].toolName).toBe('Read'); + expect(tree.children[1].id).toBe('tool-2'); + expect(tree.children[1].label).toBe('Write'); + }); + + it('marks tool nodes as running until a result arrives', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'tool-1', 'Bash'), + ]; + + const tree = buildAgentTree('sess', messages, 'running', 0, 0); + expect(tree.children[0].status).toBe('running'); + }); + + it('marks tool nodes as done when result arrives', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'tool-1', 'Bash'), + makeToolResult('m2', 'tool-1'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + expect(tree.children[0].status).toBe('done'); + }); + + it('nests subagent tool calls under their parent tool node', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'tool-parent', 'Agent'), + makeToolCall('m2', 'tool-child', 'Read', 'tool-parent'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + + expect(tree.children).toHaveLength(1); + const parentNode = tree.children[0]; + expect(parentNode.id).toBe('tool-parent'); + expect(parentNode.children).toHaveLength(1); + expect(parentNode.children[0].id).toBe('tool-child'); + expect(parentNode.children[0].label).toBe('Read'); + }); + + it('handles deeply nested subagents (3 levels)', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'level-1', 'Agent'), + makeToolCall('m2', 'level-2', 'SubAgent', 'level-1'), + makeToolCall('m3', 'level-3', 'Read', 'level-2'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].children[0].id).toBe('level-3'); + }); + + it('attaches to root when parentId references a non-existent tool node', () => { + const messages: AgentMessage[] = [ + makeToolCall('m1', 'orphan-tool', 'Bash', 'nonexistent-parent'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].id).toBe('orphan-tool'); + }); + + it('ignores non-tool messages (text, thinking, etc.)', () => { + const messages: AgentMessage[] = [ + makeTextMessage('m1', 'Hello'), + makeToolCall('m2', 'tool-1', 'Read'), + makeTextMessage('m3', 'Done'), + ]; + + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].id).toBe('tool-1'); + }); + + it('handles tool_result for a non-existent tool gracefully', () => { + const messages: AgentMessage[] = [ + makeToolResult('m1', 'nonexistent-tool'), + ]; + + // Should not throw + const tree = buildAgentTree('sess', messages, 'done', 0, 0); + expect(tree.children).toHaveLength(0); + }); + + it('truncates session ID to 8 chars for label', () => { + const tree = buildAgentTree('abcdefghijklmnop', [], 'done', 0, 0); + expect(tree.label).toBe('abcdefgh'); + }); +}); + +describe('countTreeNodes', () => { + it('returns 1 for a leaf node', () => { + const leaf: AgentTreeNode = { + id: 'leaf', + label: 'leaf', + status: 'done', + costUsd: 0, + tokens: 0, + children: [], + }; + expect(countTreeNodes(leaf)).toBe(1); + }); + + it('counts all nodes in a flat tree', () => { + const root: AgentTreeNode = { + id: 'root', + label: 'root', + status: 'done', + costUsd: 0, + tokens: 0, + children: [ + { id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] }, + { id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] }, + { id: 'c', label: 'c', status: 'done', costUsd: 0, tokens: 0, children: [] }, + ], + }; + expect(countTreeNodes(root)).toBe(4); + }); + + it('counts all nodes in a nested tree', () => { + const root: AgentTreeNode = { + id: 'root', + label: 'root', + status: 'done', + costUsd: 0, + tokens: 0, + children: [ + { + id: 'a', + label: 'a', + status: 'done', + costUsd: 0, + tokens: 0, + children: [ + { id: 'a1', label: 'a1', status: 'done', costUsd: 0, tokens: 0, children: [] }, + { id: 'a2', label: 'a2', status: 'done', costUsd: 0, tokens: 0, children: [] }, + ], + }, + { id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] }, + ], + }; + expect(countTreeNodes(root)).toBe(5); + }); +}); + +describe('subtreeCost', () => { + it('returns own cost for a leaf node', () => { + const leaf: AgentTreeNode = { + id: 'leaf', + label: 'leaf', + status: 'done', + costUsd: 0.05, + tokens: 0, + children: [], + }; + expect(subtreeCost(leaf)).toBe(0.05); + }); + + it('aggregates cost across children', () => { + const root: AgentTreeNode = { + id: 'root', + label: 'root', + status: 'done', + costUsd: 0.10, + tokens: 0, + children: [ + { id: 'a', label: 'a', status: 'done', costUsd: 0.03, tokens: 0, children: [] }, + { id: 'b', label: 'b', status: 'done', costUsd: 0.02, tokens: 0, children: [] }, + ], + }; + expect(subtreeCost(root)).toBeCloseTo(0.15); + }); + + it('aggregates cost recursively across nested children', () => { + const root: AgentTreeNode = { + id: 'root', + label: 'root', + status: 'done', + costUsd: 1.0, + tokens: 0, + children: [ + { + id: 'a', + label: 'a', + status: 'done', + costUsd: 0.5, + tokens: 0, + children: [ + { id: 'a1', label: 'a1', status: 'done', costUsd: 0.25, tokens: 0, children: [] }, + ], + }, + ], + }; + expect(subtreeCost(root)).toBeCloseTo(1.75); + }); + + it('returns 0 for a tree with all zero costs', () => { + const root: AgentTreeNode = { + id: 'root', + label: 'root', + status: 'done', + costUsd: 0, + tokens: 0, + children: [ + { id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] }, + ], + }; + expect(subtreeCost(root)).toBe(0); + }); +}); diff --git a/v2/src/lib/utils/agent-tree.ts b/v2/src/lib/utils/agent-tree.ts new file mode 100644 index 0000000..0f11bb2 --- /dev/null +++ b/v2/src/lib/utils/agent-tree.ts @@ -0,0 +1,88 @@ +// Agent tree builder — constructs hierarchical tree from agent messages +// Subagents are identified by parent_tool_use_id on their messages + +import type { AgentMessage, ToolCallContent, CostContent } from '../adapters/claude-messages'; + +export interface AgentTreeNode { + id: string; + label: string; + toolName?: string; + status: 'running' | 'done' | 'error'; + costUsd: number; + tokens: number; + children: AgentTreeNode[]; +} + +/** + * Build a tree from a flat list of agent messages. + * Root node represents the main agent session. + * Child nodes represent tool_use calls (potential subagents). + */ +export function buildAgentTree( + sessionId: string, + messages: AgentMessage[], + sessionStatus: string, + sessionCost: number, + sessionTokens: number, +): AgentTreeNode { + const root: AgentTreeNode = { + id: sessionId, + label: sessionId.slice(0, 8), + status: sessionStatus === 'running' || sessionStatus === 'starting' ? 'running' : + sessionStatus === 'error' ? 'error' : 'done', + costUsd: sessionCost, + tokens: sessionTokens, + children: [], + }; + + // Map tool_use_id -> node for nesting + const toolNodes = new Map(); + + for (const msg of messages) { + if (msg.type === 'tool_call') { + const tc = msg.content as ToolCallContent; + const node: AgentTreeNode = { + id: tc.toolUseId, + label: tc.name, + toolName: tc.name, + status: 'running', // will be updated by result + costUsd: 0, + tokens: 0, + children: [], + }; + toolNodes.set(tc.toolUseId, node); + + if (msg.parentId) { + // This is a subagent tool call — attach to parent tool node + const parent = toolNodes.get(msg.parentId); + if (parent) { + parent.children.push(node); + } else { + root.children.push(node); + } + } else { + root.children.push(node); + } + } + + if (msg.type === 'tool_result') { + const tr = msg.content as { toolUseId: string }; + const node = toolNodes.get(tr.toolUseId); + if (node) { + node.status = 'done'; + } + } + } + + return root; +} + +/** Flatten tree to get total count of nodes */ +export function countTreeNodes(node: AgentTreeNode): number { + return 1 + node.children.reduce((sum, c) => sum + countTreeNodes(c), 0); +} + +/** Aggregate cost across a subtree */ +export function subtreeCost(node: AgentTreeNode): number { + return node.costUsd + node.children.reduce((sum, c) => sum + subtreeCost(c), 0); +} diff --git a/v2/src/lib/utils/anchor-serializer.test.ts b/v2/src/lib/utils/anchor-serializer.test.ts new file mode 100644 index 0000000..f664c18 --- /dev/null +++ b/v2/src/lib/utils/anchor-serializer.test.ts @@ -0,0 +1,229 @@ +// Tests for anchor-serializer.ts — turn grouping, observation masking, token budgets + +import { describe, it, expect } from 'vitest'; +import { + estimateTokens, + groupMessagesIntoTurns, + selectAutoAnchors, + serializeAnchorsForInjection, +} from './anchor-serializer'; +import type { AgentMessage } from '../adapters/claude-messages'; + +function msg(type: AgentMessage['type'], content: unknown, id?: string): AgentMessage { + return { + id: id ?? crypto.randomUUID(), + type, + content, + timestamp: Date.now(), + }; +} + +describe('estimateTokens', () => { + it('estimates ~4 chars per token', () => { + expect(estimateTokens('abcd')).toBe(1); + expect(estimateTokens('abcdefgh')).toBe(2); + expect(estimateTokens('')).toBe(0); + }); + + it('rounds up', () => { + expect(estimateTokens('ab')).toBe(1); // ceil(2/4) = 1 + expect(estimateTokens('abcde')).toBe(2); // ceil(5/4) = 2 + }); +}); + +describe('groupMessagesIntoTurns', () => { + it('returns empty for no messages', () => { + expect(groupMessagesIntoTurns([])).toEqual([]); + }); + + it('groups text + tool_call + tool_result + cost into one turn', () => { + const messages: AgentMessage[] = [ + msg('text', { text: 'I will help you.' }), + msg('tool_call', { toolUseId: 'tc1', name: 'Read', input: { file_path: '/foo.ts' } }), + msg('tool_result', { toolUseId: 'tc1', output: 'file content here' }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns).toHaveLength(1); + expect(turns[0].index).toBe(0); + expect(turns[0].assistantText).toBe('I will help you.'); + expect(turns[0].toolSummaries).toHaveLength(1); + expect(turns[0].toolSummaries[0]).toContain('[Read'); + expect(turns[0].toolSummaries[0]).toContain('/foo.ts'); + }); + + it('splits turns on cost events', () => { + const messages: AgentMessage[] = [ + msg('text', { text: 'First response' }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + msg('text', { text: 'Second response' }), + msg('cost', { totalCostUsd: 0.02, durationMs: 200, inputTokens: 200, outputTokens: 100, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns).toHaveLength(2); + expect(turns[0].assistantText).toBe('First response'); + expect(turns[1].assistantText).toBe('Second response'); + expect(turns[0].index).toBe(0); + expect(turns[1].index).toBe(1); + }); + + it('handles session without final cost event', () => { + const messages: AgentMessage[] = [ + msg('text', { text: 'Working on it...' }), + msg('tool_call', { toolUseId: 'tc1', name: 'Bash', input: { command: 'npm test' } }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns).toHaveLength(1); + expect(turns[0].assistantText).toBe('Working on it...'); + expect(turns[0].toolSummaries[0]).toContain('[Bash'); + }); + + it('skips init, thinking, compaction, status messages', () => { + const messages: AgentMessage[] = [ + msg('init', { sessionId: 's1', model: 'claude', cwd: '/', tools: [] }), + msg('thinking', { text: 'Hmm...' }), + msg('text', { text: 'Here is the plan.' }), + msg('status', { subtype: 'progress' }), + msg('compaction', { trigger: 'auto', preTokens: 50000 }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns).toHaveLength(1); + expect(turns[0].assistantText).toBe('Here is the plan.'); + }); + + it('compacts tool summaries for Write with line count', () => { + const messages: AgentMessage[] = [ + msg('text', { text: 'Creating file.' }), + msg('tool_call', { toolUseId: 'tc1', name: 'Write', input: { file_path: '/app.ts', content: 'line1\nline2\nline3' } }), + msg('tool_result', { toolUseId: 'tc1', output: 'ok' }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns[0].toolSummaries[0]).toContain('[Write'); + expect(turns[0].toolSummaries[0]).toContain('/app.ts'); + expect(turns[0].toolSummaries[0]).toContain('3 lines'); + }); + + it('compacts Bash tool with truncated command', () => { + const longCmd = 'a'.repeat(100); + const messages: AgentMessage[] = [ + msg('text', { text: 'Running command.' }), + msg('tool_call', { toolUseId: 'tc1', name: 'Bash', input: { command: longCmd } }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + // Command should be truncated to 80 chars + expect(turns[0].toolSummaries[0].length).toBeLessThan(longCmd.length); + }); + + it('concatenates multiple text messages in same turn', () => { + const messages: AgentMessage[] = [ + msg('text', { text: 'Part 1.' }), + msg('text', { text: 'Part 2.' }), + msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), + ]; + + const turns = groupMessagesIntoTurns(messages); + expect(turns[0].assistantText).toBe('Part 1.\nPart 2.'); + }); +}); + +describe('selectAutoAnchors', () => { + const makeSessionMessages = (turnCount: number): AgentMessage[] => { + const messages: AgentMessage[] = []; + for (let i = 0; i < turnCount; i++) { + messages.push(msg('text', { text: `Response for turn ${i + 1}` })); + messages.push(msg('cost', { + totalCostUsd: 0.01, + durationMs: 100, + inputTokens: 100, + outputTokens: 50, + numTurns: 1, + isError: false, + })); + } + return messages; + }; + + it('selects first N turns up to maxTurns', () => { + const messages = makeSessionMessages(10); + const { turns } = selectAutoAnchors(messages, 'Build auth module', 3, 50000); + expect(turns).toHaveLength(3); + }); + + it('injects session prompt as turn 0 user prompt', () => { + const messages = makeSessionMessages(3); + const { turns } = selectAutoAnchors(messages, 'Build auth module', 3, 50000); + expect(turns[0].userPrompt).toBe('Build auth module'); + }); + + it('respects token budget', () => { + const messages = makeSessionMessages(10); + // Very small budget — should only fit 1-2 turns + const { turns } = selectAutoAnchors(messages, 'task', 10, 30); + expect(turns.length).toBeLessThan(10); + expect(turns.length).toBeGreaterThan(0); + }); + + it('returns empty for no messages', () => { + const { turns, totalTokens } = selectAutoAnchors([], 'task', 3, 6000); + expect(turns).toHaveLength(0); + expect(totalTokens).toBe(0); + }); +}); + +describe('serializeAnchorsForInjection', () => { + it('produces session-anchors XML wrapper', () => { + const turns = [{ + index: 0, + userPrompt: 'Build auth', + assistantText: 'I will create auth.ts', + toolSummaries: ['[Write /auth.ts → 50 lines]'], + estimatedTokens: 30, + }]; + + const result = serializeAnchorsForInjection(turns, 6000, 'my-project'); + expect(result).toContain(''); + expect(result).toContain('Build auth'); + expect(result).toContain('auth.ts'); + }); + + it('respects token budget by truncating turns', () => { + const turns = Array.from({ length: 10 }, (_, i) => ({ + index: i, + userPrompt: `Prompt ${i}`, + assistantText: 'A'.repeat(200), // ~50 tokens each + toolSummaries: [], + estimatedTokens: 80, + })); + + // Budget for ~3 turns + const result = serializeAnchorsForInjection(turns, 300); + // Should not contain all 10 turns + expect(result).toContain('Prompt 0'); + expect(result).not.toContain('Prompt 9'); + }); + + it('works without project name', () => { + const turns = [{ + index: 0, + userPrompt: 'Hello', + assistantText: 'Hi', + toolSummaries: [], + estimatedTokens: 5, + }]; + + const result = serializeAnchorsForInjection(turns, 6000); + expect(result).toContain(''); + expect(result).not.toContain('project='); + }); +}); diff --git a/v2/src/lib/utils/anchor-serializer.ts b/v2/src/lib/utils/anchor-serializer.ts new file mode 100644 index 0000000..e2f48f2 --- /dev/null +++ b/v2/src/lib/utils/anchor-serializer.ts @@ -0,0 +1,211 @@ +// Anchor Serializer — converts agent messages into observation-masked anchor text +// Observation masking: preserve user prompts + assistant reasoning, compact tool results + +import type { AgentMessage, TextContent, ToolCallContent, ToolResultContent } from '../adapters/claude-messages'; + +/** Estimate token count from text (~4 chars per token) */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** A turn group: one user prompt + assistant response + tool interactions */ +export interface TurnGroup { + index: number; + userPrompt: string; + assistantText: string; + toolSummaries: string[]; + estimatedTokens: number; +} + +/** + * Group messages into turns. A new turn starts at each 'cost' event boundary + * or at session start. The first turn includes messages from init to the first cost event. + */ +export function groupMessagesIntoTurns(messages: AgentMessage[]): TurnGroup[] { + const turns: TurnGroup[] = []; + let currentTurn: { userPrompt: string; assistantText: string; toolSummaries: string[]; messages: AgentMessage[] } = { + userPrompt: '', + assistantText: '', + toolSummaries: [], + messages: [], + }; + let turnIndex = 0; + + // Build a map of toolUseId -> tool_result for compact summaries + const toolResults = new Map(); + for (const msg of messages) { + if (msg.type === 'tool_result') { + const tr = msg.content as ToolResultContent; + toolResults.set(tr.toolUseId, msg); + } + } + + for (const msg of messages) { + switch (msg.type) { + case 'text': { + const text = (msg.content as TextContent).text; + currentTurn.assistantText += (currentTurn.assistantText ? '\n' : '') + text; + break; + } + case 'tool_call': { + const tc = msg.content as ToolCallContent; + const result = toolResults.get(tc.toolUseId); + const summary = compactToolSummary(tc, result); + currentTurn.toolSummaries.push(summary); + break; + } + case 'cost': { + // End of turn — finalize and start new one + if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { + const serialized = serializeTurn(turnIndex, currentTurn); + turns.push({ + index: turnIndex, + userPrompt: currentTurn.userPrompt, + assistantText: currentTurn.assistantText, + toolSummaries: currentTurn.toolSummaries, + estimatedTokens: estimateTokens(serialized), + }); + turnIndex++; + } + currentTurn = { userPrompt: '', assistantText: '', toolSummaries: [], messages: [] }; + break; + } + // Skip init, thinking, compaction, status, etc. + } + } + + // Finalize last turn if it has content (session may not have ended with cost) + if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { + const serialized = serializeTurn(turnIndex, currentTurn); + turns.push({ + index: turnIndex, + userPrompt: currentTurn.userPrompt, + assistantText: currentTurn.assistantText, + toolSummaries: currentTurn.toolSummaries, + estimatedTokens: estimateTokens(serialized), + }); + } + + return turns; +} + +/** Compact a tool_call + optional tool_result into a short summary */ +function compactToolSummary(tc: ToolCallContent, result?: AgentMessage): string { + const name = tc.name; + const input = tc.input as Record | undefined; + + // Extract key info based on tool type + let detail = ''; + if (input) { + if (name === 'Read' || name === 'read_file') { + detail = ` ${input.file_path ?? input.path ?? ''}`; + } else if (name === 'Write' || name === 'write_file') { + const path = input.file_path ?? input.path ?? ''; + const content = typeof input.content === 'string' ? input.content : ''; + detail = ` ${path} → ${content.split('\n').length} lines`; + } else if (name === 'Edit' || name === 'edit_file') { + detail = ` ${input.file_path ?? input.path ?? ''}`; + } else if (name === 'Bash' || name === 'execute_bash') { + const cmd = typeof input.command === 'string' ? input.command.slice(0, 80) : ''; + detail = ` \`${cmd}\``; + } else if (name === 'Glob' || name === 'Grep') { + const pattern = input.pattern ?? ''; + detail = ` ${pattern}`; + } + } + + // Add compact result indicator + let resultNote = ''; + if (result) { + const output = result.content as ToolResultContent; + const outStr = typeof output.output === 'string' ? output.output : JSON.stringify(output.output ?? ''); + const lineCount = outStr.split('\n').length; + resultNote = ` → ${lineCount} lines`; + } + + return `[${name}${detail}${resultNote}]`; +} + +/** Serialize a single turn to observation-masked text */ +function serializeTurn( + index: number, + turn: { userPrompt: string; assistantText: string; toolSummaries: string[] }, +): string { + const parts: string[] = []; + + if (turn.userPrompt) { + parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`); + } + if (turn.assistantText) { + // Preserve assistant reasoning in full — research consensus (JetBrains NeurIPS 2025, + // SWE-agent, OpenDev ACC) is that agent reasoning must never be truncated; + // only tool outputs (observations) get masked + parts.push(`[Turn ${index + 1}] Assistant: "${turn.assistantText}"`); + } + if (turn.toolSummaries.length > 0) { + parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`); + } + + return parts.join('\n'); +} + +/** + * Serialize turns into anchor text for system prompt re-injection. + * Respects token budget — stops adding turns when budget would be exceeded. + */ +export function serializeAnchorsForInjection( + turns: TurnGroup[], + tokenBudget: number, + projectName?: string, +): string { + const header = ``; + const footer = ''; + const headerTokens = estimateTokens(header + '\n' + footer); + + let remaining = tokenBudget - headerTokens; + const lines: string[] = [header]; + lines.push('Key decisions and context from earlier in this project:'); + lines.push(''); + + for (const turn of turns) { + const text = serializeTurn(turn.index, turn); + const tokens = estimateTokens(text); + if (tokens > remaining) break; + lines.push(text); + lines.push(''); + remaining -= tokens; + } + + lines.push(footer); + return lines.join('\n'); +} + +/** + * Select turns for auto-anchoring on first compaction. + * Takes first N turns up to token budget, using the session's original prompt as turn 0 user prompt. + */ +export function selectAutoAnchors( + messages: AgentMessage[], + sessionPrompt: string, + maxTurns: number, + tokenBudget: number, +): { turns: TurnGroup[]; totalTokens: number } { + const allTurns = groupMessagesIntoTurns(messages); + + // Inject session prompt as user prompt for turn 0 + if (allTurns.length > 0 && !allTurns[0].userPrompt) { + allTurns[0].userPrompt = sessionPrompt; + } + + const selected: TurnGroup[] = []; + let totalTokens = 0; + + for (const turn of allTurns) { + if (selected.length >= maxTurns) break; + if (totalTokens + turn.estimatedTokens > tokenBudget) break; + selected.push(turn); + totalTokens += turn.estimatedTokens; + } + + return { turns: selected, totalTokens }; +} diff --git a/v2/src/lib/utils/attention-scorer.test.ts b/v2/src/lib/utils/attention-scorer.test.ts new file mode 100644 index 0000000..0f61d7e --- /dev/null +++ b/v2/src/lib/utils/attention-scorer.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { scoreAttention, type AttentionInput } from './attention-scorer'; + +function makeInput(overrides: Partial = {}): AttentionInput { + return { + sessionStatus: undefined, + sessionError: undefined, + activityState: 'inactive', + idleDurationMs: 0, + contextPressure: null, + fileConflictCount: 0, + externalConflictCount: 0, + ...overrides, + }; +} + +describe('scoreAttention', () => { + it('returns zero score when no attention needed', () => { + const result = scoreAttention(makeInput()); + expect(result.score).toBe(0); + expect(result.reason).toBeNull(); + }); + + it('scores error highest after stalled', () => { + const result = scoreAttention(makeInput({ + sessionStatus: 'error', + sessionError: 'Connection refused', + })); + expect(result.score).toBe(90); + expect(result.reason).toContain('Connection refused'); + }); + + it('truncates long error messages to 60 chars', () => { + const longError = 'A'.repeat(100); + const result = scoreAttention(makeInput({ + sessionStatus: 'error', + sessionError: longError, + })); + expect(result.reason!.length).toBeLessThanOrEqual(68); // "Error: " + 60 chars + null safety + }); + + it('scores stalled at 100', () => { + const result = scoreAttention(makeInput({ + activityState: 'stalled', + idleDurationMs: 20 * 60_000, + })); + expect(result.score).toBe(100); + expect(result.reason).toContain('20 min'); + }); + + it('scores critical context pressure (>90%) at 80', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.95, + })); + expect(result.score).toBe(80); + expect(result.reason).toContain('95%'); + }); + + it('scores file conflicts at 70', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + fileConflictCount: 3, + })); + expect(result.score).toBe(70); + expect(result.reason).toContain('3 file conflicts'); + }); + + it('includes external conflict note when present', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + fileConflictCount: 2, + externalConflictCount: 1, + })); + expect(result.reason).toContain('(1 external)'); + }); + + it('scores high context pressure (>75%) at 40', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.80, + })); + expect(result.score).toBe(40); + expect(result.reason).toContain('80%'); + }); + + it('error takes priority over stalled', () => { + const result = scoreAttention(makeInput({ + sessionStatus: 'error', + sessionError: 'fail', + activityState: 'stalled', + idleDurationMs: 30 * 60_000, + })); + expect(result.score).toBe(90); + }); + + it('stalled takes priority over context pressure', () => { + const result = scoreAttention(makeInput({ + activityState: 'stalled', + idleDurationMs: 20 * 60_000, + contextPressure: 0.95, + })); + expect(result.score).toBe(100); + }); + + it('critical context takes priority over file conflicts', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.92, + fileConflictCount: 5, + })); + expect(result.score).toBe(80); + }); + + it('file conflicts take priority over high context', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.78, + fileConflictCount: 1, + })); + expect(result.score).toBe(70); + }); + + it('singular file conflict uses singular grammar', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + fileConflictCount: 1, + })); + expect(result.reason).toBe('1 file conflict'); + }); + + it('handles undefined session error gracefully', () => { + const result = scoreAttention(makeInput({ + sessionStatus: 'error', + sessionError: undefined, + })); + expect(result.reason).toContain('Unknown'); + }); +}); diff --git a/v2/src/lib/utils/attention-scorer.ts b/v2/src/lib/utils/attention-scorer.ts new file mode 100644 index 0000000..10c59a7 --- /dev/null +++ b/v2/src/lib/utils/attention-scorer.ts @@ -0,0 +1,68 @@ +// Attention scoring — pure function extracted from health store +// Determines which project needs attention most urgently + +import type { ActivityState } from '../stores/health.svelte'; + +// Attention score weights (higher = more urgent) +const SCORE_STALLED = 100; +const SCORE_ERROR = 90; +const SCORE_CONTEXT_CRITICAL = 80; // >90% context +const SCORE_FILE_CONFLICT = 70; +const SCORE_CONTEXT_HIGH = 40; // >75% context + +export interface AttentionInput { + sessionStatus: string | undefined; + sessionError: string | undefined; + activityState: ActivityState; + idleDurationMs: number; + contextPressure: number | null; + fileConflictCount: number; + externalConflictCount: number; +} + +export interface AttentionResult { + score: number; + reason: string | null; +} + +/** Score how urgently a project needs human attention. Highest-priority signal wins. */ +export function scoreAttention(input: AttentionInput): AttentionResult { + if (input.sessionStatus === 'error') { + return { + score: SCORE_ERROR, + reason: `Error: ${input.sessionError?.slice(0, 60) ?? 'Unknown'}`, + }; + } + + if (input.activityState === 'stalled') { + const mins = Math.floor(input.idleDurationMs / 60_000); + return { + score: SCORE_STALLED, + reason: `Stalled — ${mins} min since last activity`, + }; + } + + if (input.contextPressure !== null && input.contextPressure > 0.9) { + return { + score: SCORE_CONTEXT_CRITICAL, + reason: `Context ${Math.round(input.contextPressure * 100)}% — near limit`, + }; + } + + if (input.fileConflictCount > 0) { + const extNote = input.externalConflictCount > 0 ? ` (${input.externalConflictCount} external)` : ''; + return { + score: SCORE_FILE_CONFLICT, + reason: `${input.fileConflictCount} file conflict${input.fileConflictCount > 1 ? 's' : ''}${extNote}`, + }; + } + + if (input.contextPressure !== null && input.contextPressure > 0.75) { + return { + score: SCORE_CONTEXT_HIGH, + reason: `Context ${Math.round(input.contextPressure * 100)}%`, + }; + } + + return { score: 0, reason: null }; +} diff --git a/v2/src/lib/utils/auto-anchoring.ts b/v2/src/lib/utils/auto-anchoring.ts new file mode 100644 index 0000000..604e5c0 --- /dev/null +++ b/v2/src/lib/utils/auto-anchoring.ts @@ -0,0 +1,52 @@ +// Auto-anchoring — creates session anchors on first compaction event +// Extracted from agent-dispatcher.ts (SRP: anchor creation concern) + +import type { ProjectId as ProjectIdType } from '../types/ids'; +import type { AgentMessage } from '../adapters/claude-messages'; +import type { SessionAnchor } from '../types/anchors'; +import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte'; +import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer'; +import { getEnabledProjects } from '../stores/workspace.svelte'; +import { tel } from '../adapters/telemetry-bridge'; +import { notify } from '../stores/notifications.svelte'; + +/** Auto-anchor first N turns on first compaction event for a project */ +export function triggerAutoAnchor( + projectId: ProjectIdType, + messages: AgentMessage[], + sessionPrompt: string, +): void { + const project = getEnabledProjects().find(p => p.id === projectId); + const settings = getAnchorSettings(project?.anchorBudgetScale); + const { turns, totalTokens } = selectAutoAnchors( + messages, + sessionPrompt, + settings.anchorTurns, + settings.anchorTokenBudget, + ); + + if (turns.length === 0) return; + + const nowSecs = Math.floor(Date.now() / 1000); + const anchors: SessionAnchor[] = turns.map((turn) => { + const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget); + return { + id: crypto.randomUUID(), + projectId, + messageId: `turn-${turn.index}`, + anchorType: 'auto' as const, + content: content, + estimatedTokens: turn.estimatedTokens, + turnIndex: turn.index, + createdAt: nowSecs, + }; + }); + + addAnchors(projectId, anchors); + tel.info('auto_anchor_created', { + projectId, + anchorCount: anchors.length, + totalTokens, + }); + notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); +} diff --git a/v2/src/lib/utils/detach.ts b/v2/src/lib/utils/detach.ts new file mode 100644 index 0000000..0c57561 --- /dev/null +++ b/v2/src/lib/utils/detach.ts @@ -0,0 +1,68 @@ +// Detachable pane support — opens panes in separate OS windows +// Uses Tauri's WebviewWindow API + +import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; +import type { Pane } from '../stores/layout.svelte'; + +let detachCounter = 0; + +export async function detachPane(pane: Pane): Promise { + detachCounter++; + const label = `detached-${detachCounter}`; + + const params = new URLSearchParams({ + detached: 'true', + type: pane.type, + title: pane.title, + }); + + if (pane.shell) params.set('shell', pane.shell); + if (pane.cwd) params.set('cwd', pane.cwd); + if (pane.args) params.set('args', JSON.stringify(pane.args)); + if (pane.type === 'agent') params.set('sessionId', pane.id); + + const webview = new WebviewWindow(label, { + url: `index.html?${params.toString()}`, + title: `BTerminal — ${pane.title}`, + width: 800, + height: 600, + decorations: true, + resizable: true, + }); + + // Wait for the window to be created + await webview.once('tauri://created', () => { + // Window created successfully + }); + + await webview.once('tauri://error', (e) => { + console.error('Failed to create detached window:', e); + }); +} + +export function isDetachedMode(): boolean { + const params = new URLSearchParams(window.location.search); + return params.get('detached') === 'true'; +} + +export function getDetachedConfig(): { + type: string; + title: string; + shell?: string; + cwd?: string; + args?: string[]; + sessionId?: string; +} | null { + const params = new URLSearchParams(window.location.search); + if (params.get('detached') !== 'true') return null; + + const argsStr = params.get('args'); + return { + type: params.get('type') ?? 'terminal', + title: params.get('title') ?? 'Detached', + shell: params.get('shell') ?? undefined, + cwd: params.get('cwd') ?? undefined, + args: argsStr ? JSON.parse(argsStr) : undefined, + sessionId: params.get('sessionId') ?? undefined, + }; +} diff --git a/v2/src/lib/utils/highlight.ts b/v2/src/lib/utils/highlight.ts new file mode 100644 index 0000000..4b15697 --- /dev/null +++ b/v2/src/lib/utils/highlight.ts @@ -0,0 +1,51 @@ +import { createHighlighter, type Highlighter } from 'shiki'; + +let highlighter: Highlighter | null = null; +let initPromise: Promise | null = null; + +// Use catppuccin-mocha theme (bundled with shiki) +const THEME = 'catppuccin-mocha'; + +// Common languages to preload +const LANGS = [ + 'typescript', 'javascript', 'rust', 'python', 'bash', + 'json', 'html', 'css', 'svelte', 'sql', 'yaml', 'toml', 'markdown', +]; + +export async function getHighlighter(): Promise { + if (highlighter) return highlighter; + if (initPromise) return initPromise; + + initPromise = createHighlighter({ + themes: [THEME], + langs: LANGS, + }); + + highlighter = await initPromise; + return highlighter; +} + +export function highlightCode(code: string, lang: string): string { + if (!highlighter) return escapeHtml(code); + + try { + const loadedLangs = highlighter.getLoadedLanguages(); + if (!loadedLangs.includes(lang as any)) { + return escapeHtml(code); + } + + return highlighter.codeToHtml(code, { + lang, + theme: THEME, + }); + } catch { + return escapeHtml(code); + } +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/v2/src/lib/utils/session-persistence.ts b/v2/src/lib/utils/session-persistence.ts new file mode 100644 index 0000000..da1825f --- /dev/null +++ b/v2/src/lib/utils/session-persistence.ts @@ -0,0 +1,118 @@ +// Session persistence — maps session IDs to projects/providers and persists state to SQLite +// Extracted from agent-dispatcher.ts (SRP: persistence concern) + +import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; +import type { ProviderId } from '../providers/types'; +import { getAgentSession } from '../stores/agents.svelte'; +import { + saveProjectAgentState, + saveAgentMessages, + saveSessionMetric, + type AgentMessageRecord, +} from '../adapters/groups-bridge'; + +// Map sessionId -> projectId for persistence routing +const sessionProjectMap = new Map(); + +// Map sessionId -> provider for message adapter routing +const sessionProviderMap = new Map(); + +// Map sessionId -> start timestamp for metrics +const sessionStartTimes = new Map(); + +// In-flight persistence counter — prevents teardown from racing with async saves +let pendingPersistCount = 0; + +export function registerSessionProject(sessionId: SessionIdType, projectId: ProjectIdType, provider: ProviderId = 'claude'): void { + sessionProjectMap.set(sessionId, projectId); + sessionProviderMap.set(sessionId, provider); +} + +export function getSessionProjectId(sessionId: SessionIdType): ProjectIdType | undefined { + return sessionProjectMap.get(sessionId); +} + +export function getSessionProvider(sessionId: SessionIdType): ProviderId { + return sessionProviderMap.get(sessionId) ?? 'claude'; +} + +export function recordSessionStart(sessionId: SessionIdType): void { + sessionStartTimes.set(sessionId, Date.now()); +} + +/** Wait until all in-flight persistence operations complete */ +export async function waitForPendingPersistence(): Promise { + while (pendingPersistCount > 0) { + await new Promise(r => setTimeout(r, 10)); + } +} + +/** Persist session state + messages to SQLite for the project that owns this session */ +export async function persistSessionForProject(sessionId: SessionIdType): Promise { + const projectId = sessionProjectMap.get(sessionId); + if (!projectId) return; // Not a project-scoped session + + const session = getAgentSession(sessionId); + if (!session) return; + + pendingPersistCount++; + try { + // Save agent state + await saveProjectAgentState({ + project_id: projectId, + last_session_id: sessionId, + sdk_session_id: session.sdkSessionId ?? null, + status: session.status, + cost_usd: session.costUsd, + input_tokens: session.inputTokens, + output_tokens: session.outputTokens, + last_prompt: session.prompt, + updated_at: Math.floor(Date.now() / 1000), + }); + + // Save messages (use seconds to match session.rs convention) + const nowSecs = Math.floor(Date.now() / 1000); + const records: AgentMessageRecord[] = session.messages.map((m, i) => ({ + id: i, + session_id: sessionId, + project_id: projectId, + sdk_session_id: session.sdkSessionId ?? null, + message_type: m.type, + content: JSON.stringify(m.content), + parent_id: m.parentId ?? null, + created_at: nowSecs, + })); + + if (records.length > 0) { + await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); + } + + // Persist session metric for historical tracking + const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; + const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); + await saveSessionMetric({ + project_id: projectId, + session_id: sessionId, + start_time: Math.floor(startTime / 1000), + end_time: nowSecs, + peak_tokens: session.inputTokens + session.outputTokens, + turn_count: session.numTurns, + tool_call_count: toolCallCount, + cost_usd: session.costUsd, + model: session.model ?? null, + status: session.status, + error_message: session.error ?? null, + }); + } catch (e) { + console.warn('Failed to persist agent session:', e); + } finally { + pendingPersistCount--; + } +} + +/** Clear all session maps — called on dispatcher shutdown */ +export function clearSessionMaps(): void { + sessionProjectMap.clear(); + sessionProviderMap.clear(); + sessionStartTimes.clear(); +} diff --git a/v2/src/lib/utils/subagent-router.ts b/v2/src/lib/utils/subagent-router.ts new file mode 100644 index 0000000..5576a15 --- /dev/null +++ b/v2/src/lib/utils/subagent-router.ts @@ -0,0 +1,78 @@ +// Subagent routing — manages subagent pane creation and message routing +// Extracted from agent-dispatcher.ts (SRP: subagent lifecycle concern) + +import type { ToolCallContent } from '../adapters/claude-messages'; +import { + createAgentSession, + updateAgentStatus, + findChildByToolUseId, +} from '../stores/agents.svelte'; +import { addPane, getPanes } from '../stores/layout.svelte'; +import { getSessionProjectId } from './session-persistence'; + +// Tool names that indicate a subagent spawn +const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); + +// Map toolUseId -> child session pane id for routing +const toolUseToChildPane = new Map(); + +/** Check if a tool call is a subagent spawn */ +export function isSubagentToolCall(toolName: string): boolean { + return SUBAGENT_TOOL_NAMES.has(toolName); +} + +/** Get the child pane ID for a given toolUseId */ +export function getChildPaneId(toolUseId: string): string | undefined { + return toolUseToChildPane.get(toolUseId); +} + +/** Check if a toolUseId has been mapped to a child pane */ +export function hasChildPane(toolUseId: string): boolean { + return toolUseToChildPane.has(toolUseId); +} + +export function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { + // Don't create duplicate pane for same tool_use + if (toolUseToChildPane.has(tc.toolUseId)) return; + const existing = findChildByToolUseId(parentSessionId, tc.toolUseId); + if (existing) { + toolUseToChildPane.set(tc.toolUseId, existing.id); + return; + } + + const childId = crypto.randomUUID(); + const prompt = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).prompt as string ?? tc.name + : tc.name; + const label = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).name as string ?? tc.name + : tc.name; + + // Register routing + toolUseToChildPane.set(tc.toolUseId, childId); + + // Create agent session with parent link + createAgentSession(childId, prompt, { + sessionId: parentSessionId, + toolUseId: tc.toolUseId, + }); + updateAgentStatus(childId, 'running'); + + // For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane) + // For non-project sessions (detached mode), create a layout pane + if (!getSessionProjectId(parentSessionId)) { + const parentPane = getPanes().find(p => p.id === parentSessionId); + const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`; + addPane({ + id: childId, + type: 'agent', + title: `Sub: ${label}`, + group: groupName, + }); + } +} + +/** Clear subagent routing maps — called on dispatcher shutdown */ +export function clearSubagentRoutes(): void { + toolUseToChildPane.clear(); +} diff --git a/v2/src/lib/utils/tool-files.test.ts b/v2/src/lib/utils/tool-files.test.ts new file mode 100644 index 0000000..be20735 --- /dev/null +++ b/v2/src/lib/utils/tool-files.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files'; +import type { ToolCallContent } from '../adapters/claude-messages'; + +function makeTc(name: string, input: unknown): ToolCallContent { + return { toolUseId: `tu-${Math.random()}`, name, input }; +} + +describe('extractFilePaths', () => { + it('extracts Read file_path', () => { + const result = extractFilePaths(makeTc('Read', { file_path: '/src/main.ts' })); + expect(result).toEqual([{ path: '/src/main.ts', op: 'read' }]); + }); + + it('extracts Write file_path as write op', () => { + const result = extractFilePaths(makeTc('Write', { file_path: '/src/out.ts' })); + expect(result).toEqual([{ path: '/src/out.ts', op: 'write' }]); + }); + + it('extracts Edit file_path as write op', () => { + const result = extractFilePaths(makeTc('Edit', { file_path: '/src/edit.ts' })); + expect(result).toEqual([{ path: '/src/edit.ts', op: 'write' }]); + }); + + it('extracts Glob pattern', () => { + const result = extractFilePaths(makeTc('Glob', { pattern: '**/*.ts' })); + expect(result).toEqual([{ path: '**/*.ts', op: 'glob' }]); + }); + + it('extracts Grep path', () => { + const result = extractFilePaths(makeTc('Grep', { path: '/src', pattern: 'TODO' })); + expect(result).toEqual([{ path: '/src', op: 'grep' }]); + }); + + it('extracts Bash read paths from common commands', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' })); + expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]); + }); + + it('handles lowercase tool names', () => { + const result = extractFilePaths(makeTc('read', { file_path: '/foo' })); + expect(result).toEqual([{ path: '/foo', op: 'read' }]); + }); + + it('returns empty for unknown tool', () => { + const result = extractFilePaths(makeTc('Agent', { prompt: 'do stuff' })); + expect(result).toEqual([]); + }); + + it('returns empty when input has no file_path', () => { + const result = extractFilePaths(makeTc('Read', {})); + expect(result).toEqual([]); + }); + + // Bash write detection + it('detects echo > redirect as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'echo "hello" > /tmp/out.txt' })); + expect(result).toEqual([{ path: '/tmp/out.txt', op: 'write' }]); + }); + + it('detects >> append redirect as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'echo "data" >> /tmp/log.txt' })); + expect(result).toEqual([{ path: '/tmp/log.txt', op: 'write' }]); + }); + + it('detects sed -i as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: "sed -i 's/foo/bar/g' /src/config.ts" })); + expect(result).toEqual([{ path: '/src/config.ts', op: 'write' }]); + }); + + it('detects tee as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'echo "content" | tee /tmp/output.log' })); + expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]); + }); + + it('detects tee -a as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'echo "append" | tee -a /tmp/output.log' })); + expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]); + }); + + it('detects cp destination as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'cp /src/a.ts /src/b.ts' })); + expect(result).toEqual([{ path: '/src/b.ts', op: 'write' }]); + }); + + it('detects mv destination as write', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'mv /old/file.ts /new/file.ts' })); + expect(result).toEqual([{ path: '/new/file.ts', op: 'write' }]); + }); + + it('ignores /dev/null redirects', () => { + const result = extractFilePaths(makeTc('Bash', { command: 'echo "test" > /dev/null' })); + expect(result).toEqual([]); + }); + + it('prefers write detection over read for ambiguous commands', () => { + // "cat file > out" should detect the write target, not the read source + const result = extractFilePaths(makeTc('Bash', { command: 'cat /src/input.ts > /tmp/output.ts' })); + expect(result).toEqual([{ path: '/tmp/output.ts', op: 'write' }]); + }); +}); + +describe('extractWritePaths', () => { + it('returns only write-op paths for Write/Edit tools', () => { + expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']); + expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']); + }); + + it('returns empty for read-only tools', () => { + expect(extractWritePaths(makeTc('Read', { file_path: '/c.ts' }))).toEqual([]); + expect(extractWritePaths(makeTc('Glob', { pattern: '*.ts' }))).toEqual([]); + expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]); + }); + + it('returns empty for bash read commands', () => { + expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]); + }); + + it('detects bash write commands', () => { + expect(extractWritePaths(makeTc('Bash', { command: 'echo "x" > /tmp/out.ts' }))).toEqual(['/tmp/out.ts']); + expect(extractWritePaths(makeTc('Bash', { command: "sed -i 's/a/b/' /src/file.ts" }))).toEqual(['/src/file.ts']); + expect(extractWritePaths(makeTc('Bash', { command: 'cp /a.ts /b.ts' }))).toEqual(['/b.ts']); + }); +}); + +describe('extractWorktreePath', () => { + it('detects Agent tool with isolation: worktree', () => { + const result = extractWorktreePath(makeTc('Agent', { prompt: 'do stuff', isolation: 'worktree' })); + expect(result).toMatch(/^worktree:/); + }); + + it('detects Task tool with isolation: worktree', () => { + const result = extractWorktreePath(makeTc('Task', { prompt: 'do stuff', isolation: 'worktree' })); + expect(result).toMatch(/^worktree:/); + }); + + it('returns null for Agent without isolation', () => { + expect(extractWorktreePath(makeTc('Agent', { prompt: 'do stuff' }))).toBeNull(); + }); + + it('detects EnterWorktree with path', () => { + expect(extractWorktreePath(makeTc('EnterWorktree', { path: '/tmp/wt-1' }))).toBe('/tmp/wt-1'); + }); + + it('returns null for unrelated tool', () => { + expect(extractWorktreePath(makeTc('Read', { file_path: '/foo' }))).toBeNull(); + }); +}); diff --git a/v2/src/lib/utils/tool-files.ts b/v2/src/lib/utils/tool-files.ts new file mode 100644 index 0000000..05ba8aa --- /dev/null +++ b/v2/src/lib/utils/tool-files.ts @@ -0,0 +1,120 @@ +// Extracts file paths from agent tool_call inputs +// Used by ContextTab (all file ops) and conflicts store (write ops only) + +import type { ToolCallContent } from '../adapters/claude-messages'; + +export interface ToolFileRef { + path: string; + op: 'read' | 'write' | 'glob' | 'grep' | 'bash'; +} + +// Patterns for read-like bash commands +const BASH_READ_RE = /(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/; + +// Patterns for bash commands that write to files +const BASH_WRITE_PATTERNS: RegExp[] = [ + // Redirection: echo/printf/cat ... > file or >> file + /(?:>>?)\s*["']?([^\s"'|;&]+)/, + // sed -i (in-place edit) + /\bsed\s+(?:-[^i\s]*)?-i[^-]?\s*(?:'[^']*'|"[^"]*"|[^\s]+\s+)["']?([^\s"'|;&]+)/, + // tee file + /\btee\s+(?:-a\s+)?["']?([^\s"'|;&]+)/, + // cp source dest — last arg is destination + /\bcp\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, + // mv source dest — last arg is destination + /\bmv\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, + // chmod/chown — modifies file metadata + /\b(?:chmod|chown)\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, +]; + +/** Extract file paths referenced by a tool call */ +export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] { + const results: ToolFileRef[] = []; + const input = tc.input as Record; + + switch (tc.name) { + case 'Read': + case 'read': + if (input?.file_path) results.push({ path: String(input.file_path), op: 'read' }); + break; + case 'Write': + case 'write': + if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' }); + break; + case 'Edit': + case 'edit': + if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' }); + break; + case 'Glob': + case 'glob': + if (input?.pattern) results.push({ path: String(input.pattern), op: 'glob' }); + break; + case 'Grep': + case 'grep': + if (input?.path) results.push({ path: String(input.path), op: 'grep' }); + break; + case 'Bash': + case 'bash': { + const cmd = String(input?.command ?? ''); + // Check for write patterns first + const writeRefs = extractBashWritePaths(cmd); + for (const path of writeRefs) { + results.push({ path, op: 'write' }); + } + // Check for read patterns (only if no write detected to avoid double-counting) + if (writeRefs.length === 0) { + const readMatch = cmd.match(BASH_READ_RE); + if (readMatch) results.push({ path: readMatch[1], op: 'bash' }); + } + break; + } + } + return results; +} + +/** Extract write-target file paths from a bash command string */ +function extractBashWritePaths(cmd: string): string[] { + const paths: string[] = []; + const seen = new Set(); + + for (const pattern of BASH_WRITE_PATTERNS) { + const match = cmd.match(pattern); + if (match && match[1] && !seen.has(match[1])) { + // Filter out obvious non-file targets (flags, -, /dev/null) + const target = match[1]; + if (target === '-' || target.startsWith('-') || target === '/dev/null') continue; + seen.add(target); + paths.push(target); + } + } + + return paths; +} + +/** Extract only write-operation file paths (Write, Edit, and Bash writes) */ +export function extractWritePaths(tc: ToolCallContent): string[] { + return extractFilePaths(tc) + .filter(r => r.op === 'write') + .map(r => r.path); +} + +/** Extract worktree path from an Agent/Task tool call with isolation: "worktree", or EnterWorktree */ +export function extractWorktreePath(tc: ToolCallContent): string | null { + const input = tc.input as Record | null; + if (!input) return null; + + const name = tc.name; + // Agent/Task tool with isolation: "worktree" + if ((name === 'Agent' || name === 'Task' || name === 'dispatch_agent') && input.isolation === 'worktree') { + // The worktree path comes from the tool_result, not the tool_call. + // But we can flag this session as "worktree-isolated" with a synthetic marker. + return `worktree:${tc.toolUseId}`; + } + + // EnterWorktree tool call carries the path directly + if (name === 'EnterWorktree' && typeof input.path === 'string') { + return input.path; + } + + return null; +} diff --git a/v2/src/lib/utils/type-guards.ts b/v2/src/lib/utils/type-guards.ts new file mode 100644 index 0000000..8af4b72 --- /dev/null +++ b/v2/src/lib/utils/type-guards.ts @@ -0,0 +1,11 @@ +// Runtime type guards for safely extracting values from untyped wire formats + +/** Returns value if it's a string, fallback otherwise */ +export function str(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : fallback; +} + +/** Returns value if it's a number, fallback otherwise */ +export function num(v: unknown, fallback = 0): number { + return typeof v === 'number' ? v : fallback; +} diff --git a/v2/src/lib/utils/updater.ts b/v2/src/lib/utils/updater.ts new file mode 100644 index 0000000..5a1f0c9 --- /dev/null +++ b/v2/src/lib/utils/updater.ts @@ -0,0 +1,32 @@ +// Auto-update checker — uses Tauri updater plugin +// Requires signing key to be configured in tauri.conf.json before use + +import { check } from '@tauri-apps/plugin-updater'; + +export async function checkForUpdates(): Promise<{ + available: boolean; + version?: string; + notes?: string; +}> { + try { + const update = await check(); + if (update) { + return { + available: true, + version: update.version, + notes: update.body ?? undefined, + }; + } + return { available: false }; + } catch { + // Updater not configured or network error — silently skip + return { available: false }; + } +} + +export async function installUpdate(): Promise { + const update = await check(); + if (update) { + await update.downloadAndInstall(); + } +} diff --git a/v2/src/lib/utils/worktree-detection.test.ts b/v2/src/lib/utils/worktree-detection.test.ts new file mode 100644 index 0000000..10af0dd --- /dev/null +++ b/v2/src/lib/utils/worktree-detection.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { detectWorktreeFromCwd } from './worktree-detection'; + +describe('detectWorktreeFromCwd', () => { + it('detects Claude Code worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session'); + expect(result).toBe('/.claude/worktrees/my-session'); + }); + + it('detects Codex worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1'); + expect(result).toBe('/.codex/worktrees/task-1'); + }); + + it('detects Cursor worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x'); + expect(result).toBe('/.cursor/worktrees/feature-x'); + }); + + it('returns null for non-worktree CWD', () => { + expect(detectWorktreeFromCwd('/home/user/project')).toBeNull(); + expect(detectWorktreeFromCwd('/tmp/work')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(detectWorktreeFromCwd('')).toBeNull(); + }); +}); diff --git a/v2/src/lib/utils/worktree-detection.ts b/v2/src/lib/utils/worktree-detection.ts new file mode 100644 index 0000000..745696b --- /dev/null +++ b/v2/src/lib/utils/worktree-detection.ts @@ -0,0 +1,17 @@ +// Worktree path detection — extracts worktree paths from CWD strings +// Used by agent-dispatcher for conflict suppression (agents in different worktrees don't conflict) + +const WORKTREE_CWD_PATTERNS = [ + /\/\.claude\/worktrees\/([^/]+)/, // Claude Code: /.claude/worktrees// + /\/\.codex\/worktrees\/([^/]+)/, // Codex + /\/\.cursor\/worktrees\/([^/]+)/, // Cursor +]; + +/** Extract worktree path from CWD if it matches a known worktree pattern */ +export function detectWorktreeFromCwd(cwd: string): string | null { + for (const pattern of WORKTREE_CWD_PATTERNS) { + const match = cwd.match(pattern); + if (match) return match[0]; // Return the full worktree path segment + } + return null; +} diff --git a/v2/src/main.ts b/v2/src/main.ts new file mode 100644 index 0000000..664a057 --- /dev/null +++ b/v2/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/v2/svelte.config.js b/v2/svelte.config.js new file mode 100644 index 0000000..96b3455 --- /dev/null +++ b/v2/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/v2/tests/e2e/README.md b/v2/tests/e2e/README.md new file mode 100644 index 0000000..4955997 --- /dev/null +++ b/v2/tests/e2e/README.md @@ -0,0 +1,71 @@ +# E2E Tests (WebDriver) + +Tauri apps use the WebDriver protocol for E2E testing (not Playwright directly). +The app runs inside WebKit2GTK on Linux, so tests interact with the real WebView. + +## Prerequisites + +- Rust toolchain (for building the Tauri app) +- Display server (X11 or Wayland) — headless Xvfb works for CI +- `tauri-driver` installed: `cargo install tauri-driver` +- `webkit2gtk-driver` system package: `sudo apt install webkit2gtk-driver` +- npm devDeps already in package.json (`@wdio/cli`, `@wdio/local-runner`, `@wdio/mocha-framework`, `@wdio/spec-reporter`) + +## Running + +```bash +# From v2/ directory — builds debug binary automatically, spawns tauri-driver +npm run test:e2e +``` + +The `wdio.conf.js` handles: +1. Building the debug binary (`cargo tauri build --debug --no-bundle`) in `onPrepare` +2. Spawning `tauri-driver` before each session +3. Killing `tauri-driver` after each session + +## CI setup (headless) + +```bash +# Install virtual framebuffer + WebKit driver +sudo apt install xvfb webkit2gtk-driver + +# Run with Xvfb wrapper +xvfb-run npm run test:e2e +``` + +## Writing tests + +Tests use WebdriverIO with Mocha. Specs go in `specs/`: + +```typescript +import { browser, expect } from '@wdio/globals'; + +describe('BTerminal', () => { + it('should show the status bar', async () => { + const statusBar = await browser.$('.status-bar'); + await expect(statusBar).toBeDisplayed(); + }); +}); +``` + +Key constraints: +- `maxInstances: 1` — Tauri doesn't support parallel WebDriver sessions +- Mocha timeout is 60s — the app needs time to initialize +- Tests interact with the real WebKit2GTK WebView, not a browser + +## File structure + +``` +tests/e2e/ +├── README.md # This file +├── wdio.conf.js # WebdriverIO config with tauri-driver lifecycle +├── tsconfig.json # TypeScript config for test specs +└── specs/ + └── smoke.test.ts # Basic smoke tests (app renders, sidebar toggle) +``` + +## References + +- Tauri WebDriver docs: https://v2.tauri.app/develop/tests/webdriver/ +- WebdriverIO docs: https://webdriver.io/ +- tauri-driver: https://crates.io/crates/tauri-driver diff --git a/v2/tests/e2e/specs/bterminal.test.ts b/v2/tests/e2e/specs/bterminal.test.ts new file mode 100644 index 0000000..ced3c5e --- /dev/null +++ b/v2/tests/e2e/specs/bterminal.test.ts @@ -0,0 +1,705 @@ +import { browser, expect } from '@wdio/globals'; + +// All E2E tests run in a single spec file because Tauri launches one app +// instance per session, and tauri-driver doesn't support re-creating sessions. + +describe('BTerminal — Smoke Tests', () => { + it('should render the application window', async () => { + const title = await browser.getTitle(); + expect(title).toBe('BTerminal'); + }); + + it('should display the status bar', async () => { + const statusBar = await browser.$('.status-bar'); + await expect(statusBar).toBeDisplayed(); + }); + + it('should show version text in status bar', async () => { + const version = await browser.$('.status-bar .version'); + await expect(version).toBeDisplayed(); + const text = await version.getText(); + expect(text).toContain('BTerminal'); + }); + + it('should display the sidebar rail', async () => { + const sidebarRail = await browser.$('.sidebar-rail'); + await expect(sidebarRail).toBeDisplayed(); + }); + + it('should display the workspace area', async () => { + const workspace = await browser.$('.workspace'); + await expect(workspace).toBeDisplayed(); + }); + + it('should toggle sidebar with settings button', async () => { + const settingsBtn = await browser.$('.rail-btn'); + await settingsBtn.click(); + + const sidebarPanel = await browser.$('.sidebar-panel'); + await expect(sidebarPanel).toBeDisplayed(); + + // Click again to close + await settingsBtn.click(); + await expect(sidebarPanel).not.toBeDisplayed(); + }); +}); + +describe('BTerminal — Workspace & Projects', () => { + it('should display the project grid', async () => { + const grid = await browser.$('.project-grid'); + await expect(grid).toBeDisplayed(); + }); + + it('should render at least one project box', async () => { + const boxes = await browser.$$('.project-box'); + expect(boxes.length).toBeGreaterThanOrEqual(1); + }); + + it('should show project header with name', async () => { + const header = await browser.$('.project-header'); + await expect(header).toBeDisplayed(); + + const name = await browser.$('.project-name'); + const text = await name.getText(); + expect(text.length).toBeGreaterThan(0); + }); + + it('should show project-level tabs (Claude, Files, Context)', async () => { + const box = await browser.$('.project-box'); + const tabs = await box.$$('.ptab'); + expect(tabs.length).toBe(3); + }); + + it('should highlight active project on click', async () => { + const header = await browser.$('.project-header'); + await header.click(); + + const activeBox = await browser.$('.project-box.active'); + await expect(activeBox).toBeDisplayed(); + }); + + it('should switch project tabs', async () => { + // Use JS click — WebDriver clicks don't always trigger Svelte onclick + // on buttons inside complex components via WebKit2GTK/tauri-driver + const switched = await browser.execute(() => { + const box = document.querySelector('.project-box'); + if (!box) return false; + const tabs = box.querySelectorAll('.ptab'); + if (tabs.length < 2) return false; + (tabs[1] as HTMLElement).click(); + return true; + }); + expect(switched).toBe(true); + await browser.pause(500); + + const box = await browser.$('.project-box'); + const activeTab = await box.$('.ptab.active'); + const text = await activeTab.getText(); + expect(text.toLowerCase()).toContain('files'); + + // Switch back to Claude tab + await browser.execute(() => { + const tab = document.querySelector('.project-box .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + }); + + it('should display the status bar with project count', async () => { + const statusBar = await browser.$('.status-bar .left'); + const text = await statusBar.getText(); + expect(text).toContain('projects'); + }); + + it('should display agent count in status bar', async () => { + const statusBar = await browser.$('.status-bar .left'); + const text = await statusBar.getText(); + expect(text).toContain('agents'); + }); +}); + +describe('BTerminal — Settings Panel', () => { + before(async () => { + // Open settings panel + const settingsBtn = await browser.$('.rail-btn'); + await settingsBtn.click(); + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 5000 }); + }); + + after(async () => { + // Close settings if still open — use keyboard shortcut as most reliable method + const panel = await browser.$('.sidebar-panel'); + if (await panel.isDisplayed()) { + await browser.keys('Escape'); + await browser.pause(500); + } + }); + + it('should display the settings tab container', async () => { + const settingsTab = await browser.$('.settings-tab'); + await expect(settingsTab).toBeDisplayed(); + }); + + it('should show settings sections', async () => { + const sections = await browser.$$('.settings-section'); + expect(sections.length).toBeGreaterThanOrEqual(1); + }); + + it('should display theme dropdown', async () => { + const dropdown = await browser.$('.custom-dropdown .dropdown-trigger'); + await expect(dropdown).toBeDisplayed(); + }); + + it('should open theme dropdown and show options', async () => { + // Use JS click — WebDriver clicks don't reliably trigger Svelte onclick + // on buttons inside scrollable panels via WebKit2GTK/tauri-driver + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + const menu = await browser.$('.dropdown-menu'); + await menu.waitForExist({ timeout: 3000 }); + + const options = await browser.$$('.dropdown-option'); + expect(options.length).toBeGreaterThan(0); + + // Close dropdown by clicking trigger again + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(300); + }); + + it('should display group list', async () => { + const groupList = await browser.$('.group-list'); + await expect(groupList).toBeDisplayed(); + }); + + it('should close settings panel with close button', async () => { + // Ensure settings is open + const panel = await browser.$('.sidebar-panel'); + if (!(await panel.isDisplayed())) { + const settingsBtn = await browser.$('.rail-btn'); + await settingsBtn.click(); + await panel.waitForDisplayed({ timeout: 3000 }); + } + + // Use JS click for reliability + await browser.execute(() => { + const btn = document.querySelector('.panel-close'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + await expect(panel).not.toBeDisplayed(); + }); +}); + +describe('BTerminal — Command Palette', () => { + beforeEach(async () => { + // Ensure palette is closed before each test + const palette = await browser.$('.palette'); + if (await palette.isExisting() && await palette.isDisplayed()) { + await browser.keys('Escape'); + await browser.pause(300); + } + }); + + it('should show palette input with autofocus', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(200); + await browser.keys(['Control', 'k']); + + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const input = await browser.$('.palette-input'); + await expect(input).toBeDisplayed(); + + // Input should have focus (check by verifying it's the active element) + const isFocused = await browser.execute(() => { + return document.activeElement?.classList.contains('palette-input'); + }); + expect(isFocused).toBe(true); + + await browser.keys('Escape'); + await browser.pause(300); + }); + + it('should show palette items with group names and project counts', async () => { + await browser.keys(['Control', 'k']); + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const items = await browser.$$('.palette-item'); + expect(items.length).toBeGreaterThanOrEqual(1); + + // Each item should have group name and project count + const groupName = await browser.$('.palette-item .group-name'); + await expect(groupName).toBeDisplayed(); + const nameText = await groupName.getText(); + expect(nameText.length).toBeGreaterThan(0); + + const projectCount = await browser.$('.palette-item .project-count'); + await expect(projectCount).toBeDisplayed(); + + await browser.keys('Escape'); + await browser.pause(300); + }); + + it('should mark active group in palette', async () => { + await browser.keys(['Control', 'k']); + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const activeItem = await browser.$('.palette-item.active'); + await expect(activeItem).toBeDisplayed(); + + await browser.keys('Escape'); + await browser.pause(300); + }); + + it('should filter palette items by typing', async () => { + await browser.keys(['Control', 'k']); + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const itemsBefore = await browser.$$('.palette-item'); + const countBefore = itemsBefore.length; + + // Type a nonsense string that won't match any group name + const input = await browser.$('.palette-input'); + await input.setValue('zzz_nonexistent_group_xyz'); + await browser.pause(300); + + // Should show no results or fewer items + const noResults = await browser.$('.no-results'); + const itemsAfter = await browser.$$('.palette-item'); + // Either no-results message appears OR item count decreased + const filtered = (await noResults.isExisting()) || itemsAfter.length < countBefore; + expect(filtered).toBe(true); + + await browser.keys('Escape'); + await browser.pause(300); + }); + + it('should close palette by clicking backdrop', async () => { + await browser.keys(['Control', 'k']); + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + // Click the backdrop (outside the palette) + await browser.execute(() => { + const backdrop = document.querySelector('.palette-backdrop'); + if (backdrop) (backdrop as HTMLElement).click(); + }); + await browser.pause(500); + + await expect(palette).not.toBeDisplayed(); + }); +}); + +describe('BTerminal — Terminal Tabs', () => { + before(async () => { + // Ensure Claude tab is active so terminal section is visible + await browser.execute(() => { + const tab = document.querySelector('.project-box .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + }); + + it('should show terminal toggle on Claude tab', async () => { + const toggle = await browser.$('.terminal-toggle'); + await expect(toggle).toBeDisplayed(); + + const label = await browser.$('.toggle-label'); + const text = await label.getText(); + expect(text.toLowerCase()).toContain('terminal'); + }); + + it('should expand terminal area on toggle click', async () => { + // Click terminal toggle via JS + await browser.execute(() => { + const toggle = document.querySelector('.terminal-toggle'); + if (toggle) (toggle as HTMLElement).click(); + }); + await browser.pause(500); + + const termArea = await browser.$('.project-terminal-area'); + await expect(termArea).toBeDisplayed(); + + // Chevron should have expanded class + const chevron = await browser.$('.toggle-chevron.expanded'); + await expect(chevron).toBeExisting(); + }); + + it('should show add tab button when terminal expanded', async () => { + const addBtn = await browser.$('.tab-add'); + await expect(addBtn).toBeDisplayed(); + }); + + it('should add a shell tab', async () => { + // Click add tab button via JS (Svelte onclick) + await browser.execute(() => { + const btn = document.querySelector('.tab-bar .tab-add'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + // Verify tab title via JS to avoid stale element issues + const title = await browser.execute(() => { + const el = document.querySelector('.tab-bar .tab-title'); + return el ? el.textContent : ''; + }); + expect((title as string).toLowerCase()).toContain('shell'); + }); + + it('should show active tab styling', async () => { + const activeTab = await browser.$('.tab.active'); + await expect(activeTab).toBeExisting(); + }); + + it('should add a second shell tab and switch between them', async () => { + // Add second tab via JS + await browser.execute(() => { + const btn = document.querySelector('.tab-bar .tab-add'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + const tabCount = await browser.execute(() => { + return document.querySelectorAll('.tab-bar .tab').length; + }); + expect(tabCount as number).toBeGreaterThanOrEqual(2); + + // Click first tab and verify it becomes active with Shell title + await browser.execute(() => { + const tabs = document.querySelectorAll('.tab-bar .tab'); + if (tabs[0]) (tabs[0] as HTMLElement).click(); + }); + await browser.pause(300); + + const activeTitle = await browser.execute(() => { + const active = document.querySelector('.tab-bar .tab.active .tab-title'); + return active ? active.textContent : ''; + }); + expect(activeTitle as string).toContain('Shell'); + }); + + it('should close a tab', async () => { + const tabsBefore = await browser.$$('.tab'); + const countBefore = tabsBefore.length; + + // Close the last tab + await browser.execute(() => { + const closeBtns = document.querySelectorAll('.tab-close'); + if (closeBtns.length > 0) { + (closeBtns[closeBtns.length - 1] as HTMLElement).click(); + } + }); + await browser.pause(500); + + const tabsAfter = await browser.$$('.tab'); + expect(tabsAfter.length).toBe(Number(countBefore) - 1); + }); + + after(async () => { + // Clean up: close remaining tabs and collapse terminal + await browser.execute(() => { + // Close all tabs + const closeBtns = document.querySelectorAll('.tab-close'); + closeBtns.forEach(btn => (btn as HTMLElement).click()); + }); + await browser.pause(300); + + // Collapse terminal + await browser.execute(() => { + const toggle = document.querySelector('.terminal-toggle'); + if (toggle) { + const chevron = toggle.querySelector('.toggle-chevron.expanded'); + if (chevron) (toggle as HTMLElement).click(); + } + }); + await browser.pause(300); + }); +}); + +describe('BTerminal — Theme Switching', () => { + before(async () => { + // Open settings + const settingsBtn = await browser.$('.rail-btn'); + await settingsBtn.click(); + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 5000 }); + }); + + after(async () => { + // Close settings + const panel = await browser.$('.sidebar-panel'); + if (await panel.isDisplayed()) { + await browser.keys('Escape'); + await browser.pause(500); + } + }); + + it('should show theme dropdown with group labels', async () => { + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + const menu = await browser.$('.dropdown-menu'); + await menu.waitForExist({ timeout: 3000 }); + + // Should have group labels (Catppuccin, Editor, Deep Dark) + const groupLabels = await browser.$$('.dropdown-group-label'); + expect(groupLabels.length).toBeGreaterThanOrEqual(2); + + // Close dropdown + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(300); + }); + + it('should switch theme and update CSS variables', async () => { + // Get current base color + const baseBefore = await browser.execute(() => { + return getComputedStyle(document.documentElement).getPropertyValue('--ctp-base').trim(); + }); + + // Open theme dropdown and click a different theme + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + // Click the first non-active theme option + const changed = await browser.execute(() => { + const options = document.querySelectorAll('.dropdown-option:not(.active)'); + if (options.length > 0) { + (options[0] as HTMLElement).click(); + return true; + } + return false; + }); + expect(changed).toBe(true); + await browser.pause(500); + + // Verify CSS variable changed + const baseAfter = await browser.execute(() => { + return getComputedStyle(document.documentElement).getPropertyValue('--ctp-base').trim(); + }); + expect(baseAfter).not.toBe(baseBefore); + + // Switch back to Catppuccin Mocha (first option) to restore state + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + await browser.execute(() => { + const options = document.querySelectorAll('.dropdown-option'); + if (options.length > 0) (options[0] as HTMLElement).click(); + }); + await browser.pause(300); + }); + + it('should show active theme option', async () => { + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + const activeOption = await browser.$('.dropdown-option.active'); + await expect(activeOption).toBeExisting(); + + await browser.execute(() => { + const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(300); + }); +}); + +describe('BTerminal — Settings Interaction', () => { + before(async () => { + // Ensure settings is open + const panel = await browser.$('.sidebar-panel'); + if (!(await panel.isDisplayed())) { + const settingsBtn = await browser.$('.rail-btn'); + await settingsBtn.click(); + await panel.waitForDisplayed({ timeout: 5000 }); + } + }); + + after(async () => { + const panel = await browser.$('.sidebar-panel'); + if (await panel.isDisplayed()) { + await browser.keys('Escape'); + await browser.pause(500); + } + }); + + it('should show font size controls with increment/decrement', async () => { + const sizeControls = await browser.$$('.size-control'); + expect(sizeControls.length).toBeGreaterThanOrEqual(1); + + const sizeBtns = await browser.$$('.size-btn'); + expect(sizeBtns.length).toBeGreaterThanOrEqual(2); // at least - and + for one control + + const sizeInput = await browser.$('.size-input'); + await expect(sizeInput).toBeExisting(); + }); + + it('should increment font size', async () => { + const sizeInput = await browser.$('.size-input'); + const valueBefore = await sizeInput.getValue(); + + // Click the + button (second .size-btn in first .size-control) + await browser.execute(() => { + const btns = document.querySelectorAll('.size-control .size-btn'); + // Second button is + (first is -) + if (btns.length >= 2) (btns[1] as HTMLElement).click(); + }); + await browser.pause(300); + + const afterEl = await browser.$('.size-input'); + const valueAfter = await afterEl.getValue(); + expect(parseInt(valueAfter as string)).toBe(parseInt(valueBefore as string) + 1); + }); + + it('should decrement font size back', async () => { + const sizeInput = await browser.$('.size-input'); + const valueBefore = await sizeInput.getValue(); + + // Click the - button (first .size-btn) + await browser.execute(() => { + const btns = document.querySelectorAll('.size-control .size-btn'); + if (btns.length >= 1) (btns[0] as HTMLElement).click(); + }); + await browser.pause(300); + + const afterEl = await browser.$('.size-input'); + const valueAfter = await afterEl.getValue(); + expect(parseInt(valueAfter as string)).toBe(parseInt(valueBefore as string) - 1); + }); + + it('should display group rows with active indicator', async () => { + const groupRows = await browser.$$('.group-row'); + expect(groupRows.length).toBeGreaterThanOrEqual(1); + + const activeGroup = await browser.$('.group-row.active'); + await expect(activeGroup).toBeExisting(); + }); + + it('should show project cards', async () => { + const cards = await browser.$$('.project-card'); + expect(cards.length).toBeGreaterThanOrEqual(1); + }); + + it('should display project card with name and path', async () => { + const nameInput = await browser.$('.card-name-input'); + await expect(nameInput).toBeExisting(); + const name = await nameInput.getValue() as string; + expect(name.length).toBeGreaterThan(0); + + const cwdInput = await browser.$('.cwd-input'); + await expect(cwdInput).toBeExisting(); + const cwd = await cwdInput.getValue() as string; + expect(cwd.length).toBeGreaterThan(0); + }); + + it('should show project toggle switch', async () => { + const toggle = await browser.$('.card-toggle'); + await expect(toggle).toBeExisting(); + + const track = await browser.$('.toggle-track'); + await expect(track).toBeDisplayed(); + }); + + it('should show add project form', async () => { + const addForm = await browser.$('.add-project-form'); + await expect(addForm).toBeDisplayed(); + + const addBtn = await browser.$('.add-project-form .btn-primary'); + await expect(addBtn).toBeExisting(); + }); +}); + +describe('BTerminal — Keyboard Shortcuts', () => { + it('should open command palette with Ctrl+K', async () => { + // Focus the app window via JS to ensure keyboard events are received + await browser.execute(() => document.body.focus()); + await browser.pause(200); + await browser.keys(['Control', 'k']); + + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const input = await browser.$('.palette-input'); + await expect(input).toBeDisplayed(); + + // Close with Escape + await browser.keys('Escape'); + await palette.waitForDisplayed({ timeout: 3000, reverse: true }); + }); + + it('should toggle settings with Ctrl+,', async () => { + await browser.keys(['Control', ',']); + + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 3000 }); + + // Close with Ctrl+, + await browser.keys(['Control', ',']); + await panel.waitForDisplayed({ timeout: 3000, reverse: true }); + }); + + it('should toggle sidebar with Ctrl+B', async () => { + // Open sidebar first + await browser.keys(['Control', ',']); + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 3000 }); + + // Toggle off with Ctrl+B + await browser.keys(['Control', 'b']); + await panel.waitForDisplayed({ timeout: 3000, reverse: true }); + }); + + it('should close sidebar with Escape', async () => { + // Open sidebar + await browser.keys(['Control', ',']); + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 3000 }); + + // Close with Escape + await browser.keys('Escape'); + await panel.waitForDisplayed({ timeout: 3000, reverse: true }); + }); + + it('should show command palette with group list', async () => { + await browser.keys(['Control', 'k']); + + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const items = await browser.$$('.palette-item'); + expect(items.length).toBeGreaterThanOrEqual(1); + + const groupName = await browser.$('.palette-item .group-name'); + await expect(groupName).toBeDisplayed(); + + await browser.keys('Escape'); + await palette.waitForDisplayed({ timeout: 3000, reverse: true }); + }); +}); diff --git a/v2/tests/e2e/tsconfig.json b/v2/tests/e2e/tsconfig.json new file mode 100644 index 0000000..f7e974d --- /dev/null +++ b/v2/tests/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["@wdio/mocha-framework", "@wdio/globals/types"], + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["specs/**/*.ts"] +} diff --git a/v2/tests/e2e/wdio.conf.js b/v2/tests/e2e/wdio.conf.js new file mode 100644 index 0000000..35a71f5 --- /dev/null +++ b/v2/tests/e2e/wdio.conf.js @@ -0,0 +1,129 @@ +import { spawn } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, '../..'); + +// Debug binary path (built with `cargo tauri build --debug --no-bundle`) +// Cargo workspace target dir is at v2/target/, not v2/src-tauri/target/ +const tauriBinary = resolve(projectRoot, 'target/debug/bterminal'); + +let tauriDriver; + +export const config = { + // ── Runner ── + runner: 'local', + maxInstances: 1, // Tauri doesn't support parallel sessions + + // ── Connection (external tauri-driver on port 4444) ── + hostname: 'localhost', + port: 4444, + path: '/', + + // ── Specs ── + // Single spec file — Tauri launches one app instance per session, + // and tauri-driver can't re-create sessions between spec files. + specs: [resolve(__dirname, 'specs/bterminal.test.ts')], + + // ── Capabilities ── + capabilities: [{ + // Disable BiDi negotiation — tauri-driver doesn't support webSocketUrl + 'wdio:enforceWebDriverClassic': true, + 'tauri:options': { + application: tauriBinary, + }, + }], + + // ── Framework ── + framework: 'mocha', + mochaOpts: { + ui: 'bdd', + timeout: 60_000, + }, + + // ── Reporter ── + reporters: ['spec'], + + // ── Logging ── + logLevel: 'warn', + + // ── Timeouts ── + waitforTimeout: 10_000, + connectionRetryTimeout: 30_000, + connectionRetryCount: 3, + + // ── Hooks ── + + /** + * Build the debug binary before the test run. + * Uses --debug --no-bundle for fastest build time. + */ + onPrepare() { + if (process.env.SKIP_BUILD) { + console.log('SKIP_BUILD set — using existing debug binary.'); + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + console.log('Building Tauri debug binary...'); + const build = spawn('cargo', ['tauri', 'build', '--debug', '--no-bundle'], { + cwd: projectRoot, + stdio: 'inherit', + }); + build.on('close', (code) => { + if (code === 0) { + console.log('Debug binary ready.'); + resolve(); + } else { + reject(new Error(`Tauri build failed with exit code ${code}`)); + } + }); + build.on('error', reject); + }); + }, + + /** + * Spawn tauri-driver before the session. + * tauri-driver bridges WebDriver protocol to WebKit2GTK's inspector. + */ + beforeSession() { + return new Promise((resolve, reject) => { + tauriDriver = spawn('tauri-driver', [], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + tauriDriver.on('error', (err) => { + reject(new Error( + `Failed to start tauri-driver: ${err.message}. ` + + 'Install it with: cargo install tauri-driver' + )); + }); + + // Wait for tauri-driver to be ready (listens on port 4444) + const timeout = setTimeout(() => resolve(), 2000); + tauriDriver.stdout.on('data', (data) => { + if (data.toString().includes('4444')) { + clearTimeout(timeout); + resolve(); + } + }); + }); + }, + + /** + * Kill tauri-driver after the test run. + */ + afterSession() { + if (tauriDriver) { + tauriDriver.kill(); + tauriDriver = null; + } + }, + + // ── TypeScript (auto-compile via tsx) ── + autoCompileOpts: { + tsNodeOpts: { + project: resolve(__dirname, 'tsconfig.json'), + }, + }, +}; diff --git a/v2/tsconfig.app.json b/v2/tsconfig.app.json new file mode 100644 index 0000000..31c18cf --- /dev/null +++ b/v2/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "types": ["svelte", "vite/client"], + "noEmit": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/v2/tsconfig.json b/v2/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/v2/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/v2/tsconfig.node.json b/v2/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/v2/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/v2/vite.config.ts b/v2/vite.config.ts new file mode 100644 index 0000000..6a5bb20 --- /dev/null +++ b/v2/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + server: { + port: 9700, + strictPort: true, + }, + clearScreen: false, + test: { + include: ['src/**/*.test.ts'], + }, +})