feat: Agent Orchestrator — multi-project agent dashboard

Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents.
Includes Claude, Aider, Codex, and Ollama provider support, multi-agent
communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search,
Landlock sandboxing, and 507 vitest + 110 cargo tests.
This commit is contained in:
DexterFromLab 2026-03-15 15:45:27 +01:00
commit 3672e92b7e
272 changed files with 68600 additions and 0 deletions

417
src/App.svelte Normal file
View file

@ -0,0 +1,417 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initTheme } from './lib/stores/theme.svelte';
import { getSetting } from './lib/adapters/settings-bridge';
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte';
import { registerProvider } from './lib/providers/registry.svelte';
import { CLAUDE_PROVIDER } from './lib/providers/claude';
import { CODEX_PROVIDER } from './lib/providers/codex';
import { OLLAMA_PROVIDER } from './lib/providers/ollama';
import { AIDER_PROVIDER } from './lib/providers/aider';
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
import { MemoraAdapter } from './lib/adapters/memora-bridge';
import {
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
getEnabledProjects, getAllWorkItems, getActiveProjectId,
triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle,
} from './lib/stores/workspace.svelte';
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
import { pruneSeen } from './lib/adapters/btmsg-bridge';
import { invoke } from '@tauri-apps/api/core';
// Workspace components
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
import GroupAgentsPanel from './lib/components/Workspace/GroupAgentsPanel.svelte';
import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte';
import SettingsTab from './lib/components/Workspace/SettingsTab.svelte';
import CommsTab from './lib/components/Workspace/CommsTab.svelte';
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
import SearchOverlay from './lib/components/Workspace/SearchOverlay.svelte';
// Shared
import StatusBar from './lib/components/StatusBar/StatusBar.svelte';
import ToastContainer from './lib/components/Notifications/ToastContainer.svelte';
import SplashScreen from './lib/components/SplashScreen.svelte';
// Detached mode (preserved from v2)
import TerminalPane from './lib/components/Terminal/TerminalPane.svelte';
import AgentPane from './lib/components/Agent/AgentPane.svelte';
let detached = isDetachedMode();
let detachedConfig = getDetachedConfig();
let paletteOpen = $state(false);
let searchOpen = $state(false);
let drawerOpen = $state(false);
let loaded = $state(false);
// Splash screen loading steps
let splashSteps = $state([
{ label: 'Initializing theme...', done: false },
{ label: 'Registering providers...', done: false },
{ label: 'Starting agent dispatcher...', done: false },
{ label: 'Connecting sidecar...', done: false },
{ label: 'Loading workspace...', done: false },
]);
function markStep(idx: number) {
splashSteps[idx] = { ...splashSteps[idx], done: true };
}
let activeTab = $derived(getActiveTab());
let panelContentEl: HTMLElement | undefined = $state();
let panelWidth = $state<string | undefined>(undefined);
// Measure the panel content's natural width
$effect(() => {
const el = panelContentEl;
void activeTab;
if (!el) { panelWidth = undefined; return; }
const frame = requestAnimationFrame(() => {
let maxW = 0;
const candidates = el.querySelectorAll('[style*="white-space"], h3, h4, input, .settings-list, .settings-tab');
for (const c of candidates) {
maxW = Math.max(maxW, c.scrollWidth);
}
const child = el.firstElementChild as HTMLElement;
if (child) {
const cs = getComputedStyle(child);
const mw = parseFloat(cs.minWidth);
if (!isNaN(mw)) maxW = Math.max(maxW, mw);
}
if (maxW > 0) {
panelWidth = `${maxW + 24}px`;
}
});
return () => cancelAnimationFrame(frame);
});
function toggleDrawer() {
drawerOpen = !drawerOpen;
}
onMount(() => {
// Step 0: Theme
initTheme();
getSetting('project_max_aspect').then(v => {
if (v) document.documentElement.style.setProperty('--project-max-aspect', v);
});
markStep(0);
// Step 1: Providers
registerProvider(CLAUDE_PROVIDER);
registerProvider(CODEX_PROVIDER);
registerProvider(OLLAMA_PROVIDER);
registerProvider(AIDER_PROVIDER);
const memora = new MemoraAdapter();
registerMemoryAdapter(memora);
memora.checkAvailability();
markStep(1);
// Step 2: Agent dispatcher
startAgentDispatcher();
startHealthTick();
pruneSeen().catch(() => {}); // housekeeping: remove stale seen_messages on startup
markStep(2);
// Disable wake scheduler in test mode to prevent timer interference
invoke<boolean>('is_test_mode').then(isTest => {
if (isTest) disableWakeScheduler();
});
// Step 3: Sidecar (small delay to let sidecar report ready)
setTimeout(() => markStep(3), 300);
if (!detached) {
// Step 4: Workspace
loadWorkspace().then(() => {
markStep(4);
// Brief pause to show completed state before transition
setTimeout(() => { loaded = true; }, 400);
});
}
/** Check if event target is an editable element (input, textarea, contenteditable) */
function isEditing(e: KeyboardEvent): boolean {
const t = e.target as HTMLElement;
if (!t) return false;
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (t.isContentEditable) return true;
// xterm.js canvases and textareas should be considered editing
if (t.closest('.xterm')) return true;
return false;
}
function handleKeydown(e: KeyboardEvent) {
// Ctrl+K — command palette (always active)
if (e.ctrlKey && !e.shiftKey && e.key === 'k') {
e.preventDefault();
paletteOpen = !paletteOpen;
return;
}
// Ctrl+Shift+F — global search overlay
if (e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
e.preventDefault();
searchOpen = !searchOpen;
return;
}
// Alt+1..5 — quick-jump to project by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
e.preventDefault();
const projects = getAllWorkItems();
const idx = parseInt(e.key) - 1;
if (idx < projects.length) {
setActiveProject(projects[idx].id);
triggerFocusFlash(projects[idx].id);
}
return;
}
// Ctrl+Shift+1..9 — switch tab within focused project
if (e.ctrlKey && e.shiftKey && e.key >= '1' && e.key <= '9') {
// Allow Ctrl+Shift+K to pass through to its own handler
if (e.key === 'K') return;
e.preventDefault();
const projectId = getActiveProjectId();
if (projectId) {
const tabIdx = parseInt(e.key);
emitProjectTabSwitch(projectId, tabIdx);
}
return;
}
// Ctrl+Shift+K — focus agent pane (switch to Model tab)
if (e.ctrlKey && e.shiftKey && (e.key === 'K' || e.key === 'k')) {
e.preventDefault();
const projectId = getActiveProjectId();
if (projectId) {
emitProjectTabSwitch(projectId, 1); // Model tab
}
return;
}
// Vi-style navigation (skip when editing text)
if (e.ctrlKey && !e.shiftKey && !e.altKey && !isEditing(e)) {
const projects = getAllWorkItems();
const currentId = getActiveProjectId();
const currentIdx = projects.findIndex(p => p.id === currentId);
// Ctrl+H — focus previous project (left)
if (e.key === 'h') {
e.preventDefault();
if (currentIdx > 0) {
setActiveProject(projects[currentIdx - 1].id);
triggerFocusFlash(projects[currentIdx - 1].id);
}
return;
}
// Ctrl+L — focus next project (right)
if (e.key === 'l') {
e.preventDefault();
if (currentIdx >= 0 && currentIdx < projects.length - 1) {
setActiveProject(projects[currentIdx + 1].id);
triggerFocusFlash(projects[currentIdx + 1].id);
}
return;
}
// Ctrl+J — toggle terminal section in focused project
if (e.key === 'j') {
e.preventDefault();
if (currentId) {
emitTerminalToggle(currentId);
}
return;
}
}
// Ctrl+, — toggle settings panel
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
if (getActiveTab() === 'settings' && drawerOpen) {
drawerOpen = false;
} else {
setActiveTab('settings');
drawerOpen = true;
}
return;
}
// Ctrl+M — toggle messages panel
if (e.ctrlKey && !e.shiftKey && e.key === 'm') {
e.preventDefault();
if (getActiveTab() === 'comms' && drawerOpen) {
drawerOpen = false;
} else {
setActiveTab('comms');
drawerOpen = true;
}
return;
}
// Ctrl+B — toggle sidebar
if (e.ctrlKey && !e.shiftKey && e.key === 'b') {
e.preventDefault();
drawerOpen = !drawerOpen;
return;
}
// Escape — close drawer
if (e.key === 'Escape' && drawerOpen) {
e.preventDefault();
drawerOpen = false;
return;
}
}
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
stopAgentDispatcher();
stopHealthTick();
};
});
</script>
{#if detached && detachedConfig}
<div class="detached-pane">
{#if detachedConfig.type === 'terminal' || detachedConfig.type === 'ssh'}
<TerminalPane
shell={detachedConfig.shell}
cwd={detachedConfig.cwd}
args={detachedConfig.args}
/>
{:else if detachedConfig.type === 'agent'}
<AgentPane
sessionId={detachedConfig.sessionId ?? crypto.randomUUID()}
cwd={detachedConfig.cwd}
/>
{:else}
<TerminalPane />
{/if}
</div>
{:else if loaded}
<div class="app-shell">
<div class="main-row">
<GlobalTabBar expanded={drawerOpen} ontoggle={toggleDrawer} />
{#if drawerOpen}
<aside class="sidebar-panel" style:width={panelWidth}>
<div class="panel-header">
<h2>{activeTab === 'comms' ? 'Messages' : 'Settings'}</h2>
<button class="panel-close" onclick={() => drawerOpen = false} title="Close sidebar (Ctrl+B)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="panel-content" bind:this={panelContentEl}>
{#if activeTab === 'comms'}
<CommsTab />
{:else}
<SettingsTab />
{/if}
</div>
</aside>
{/if}
<main class="workspace">
<GroupAgentsPanel />
<ProjectGrid />
</main>
</div>
<StatusBar />
</div>
<CommandPalette open={paletteOpen} onclose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onclose={() => searchOpen = false} />
{:else}
<SplashScreen steps={splashSteps} />
{/if}
<ToastContainer />
<style>
.detached-pane {
height: 100vh;
width: 100vw;
background: var(--ctp-base);
}
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--ctp-base);
overflow: hidden;
}
.main-row {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar-panel {
min-width: 16em;
max-width: 50%;
display: flex;
flex-direction: column;
background: var(--ctp-base);
border-right: 1px solid var(--ctp-surface1);
flex-shrink: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.panel-header h2 {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-text);
margin: 0;
}
.panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
cursor: pointer;
}
.panel-close:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.panel-content {
flex: 1;
overflow-y: auto;
}
.workspace {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

44
src/app.css Normal file
View file

@ -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);
}

BIN
src/assets/splash.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View file

@ -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();
});
});
});

View file

@ -0,0 +1,86 @@
// 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<string, unknown>;
/** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */
extra_env?: Record<string, string>;
remote_machine_id?: string;
}
export async function queryAgent(options: AgentQueryOptions): Promise<void> {
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<void> {
if (remoteMachineId) {
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
}
return invoke('agent_stop', { sessionId });
}
export async function isAgentReady(): Promise<boolean> {
return invoke<boolean>('agent_ready');
}
export async function restartAgent(): Promise<void> {
return invoke('agent_restart');
}
/** Update Landlock sandbox config and restart sidecar to apply. */
export async function setSandbox(
projectCwds: string[],
worktreeRoots: string[],
enabled: boolean,
): Promise<void> {
return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled });
}
export interface SidecarMessage {
type: string;
sessionId?: string;
event?: Record<string, unknown>;
message?: string;
exitCode?: number | null;
signal?: string | null;
}
export async function onSidecarMessage(
callback: (msg: SidecarMessage) => void,
): Promise<UnlistenFn> {
return listen<SidecarMessage>('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<UnlistenFn> {
return listen('sidecar-exited', () => {
callback();
});
}

View file

@ -0,0 +1,140 @@
// Aider Message Adapter — transforms Aider runner events to internal AgentMessage format
// Aider runner emits: system/init, assistant (text lines), result, error
import type {
AgentMessage,
InitContent,
TextContent,
ThinkingContent,
ToolCallContent,
ToolResultContent,
CostContent,
ErrorContent,
} from './claude-messages';
import { str, num } from '../utils/type-guards';
/**
* Adapt a raw Aider runner event to AgentMessage[].
*
* The Aider runner emits events in this format:
* - {type:'system', subtype:'init', model, session_id, cwd}
* - {type:'assistant', message:{role:'assistant', content:'...'}} batched text block
* - {type:'thinking', content:'...'} thinking/reasoning block
* - {type:'input', prompt:'...'} incoming prompt/message (shown in console)
* - {type:'tool_use', id, name, input} shell command execution
* - {type:'tool_result', tool_use_id, content} shell command output
* - {type:'result', subtype:'result', cost_usd, duration_ms, is_error}
* - {type:'error', message:'...'}
*/
export function adaptAiderMessage(raw: Record<string, unknown>): AgentMessage[] {
const timestamp = Date.now();
const uuid = crypto.randomUUID();
switch (raw.type) {
case 'system':
if (str(raw.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: 'unknown',
content: raw,
timestamp,
}];
case 'input':
return [{
id: uuid,
type: 'text',
content: { text: `📨 **Received:**\n${str(raw.prompt)}` } satisfies TextContent,
timestamp,
}];
case 'thinking':
return [{
id: uuid,
type: 'thinking',
content: { text: str(raw.content) } satisfies ThinkingContent,
timestamp,
}];
case 'assistant': {
const msg = typeof raw.message === 'object' && raw.message !== null
? raw.message as Record<string, unknown>
: {};
const text = str(msg.content);
if (!text) return [];
return [{
id: uuid,
type: 'text',
content: { text } satisfies TextContent,
timestamp,
}];
}
case 'tool_use':
return [{
id: uuid,
type: 'tool_call',
content: {
toolUseId: str(raw.id),
name: str(raw.name, 'shell'),
input: raw.input,
} satisfies ToolCallContent,
timestamp,
}];
case 'tool_result':
return [{
id: uuid,
type: 'tool_result',
content: {
toolUseId: str(raw.tool_use_id),
output: raw.content,
} satisfies ToolResultContent,
timestamp,
}];
case 'result':
return [{
id: uuid,
type: 'cost',
content: {
totalCostUsd: num(raw.cost_usd),
durationMs: num(raw.duration_ms),
inputTokens: 0,
outputTokens: 0,
numTurns: num(raw.num_turns) || 1,
isError: raw.is_error === true,
} satisfies CostContent,
timestamp,
}];
case 'error':
return [{
id: uuid,
type: 'error',
content: { message: str(raw.message, 'Aider error') } satisfies ErrorContent,
timestamp,
}];
default:
return [{
id: uuid,
type: 'unknown',
content: raw,
timestamp,
}];
}
}

View file

@ -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<void> {
return invoke('session_anchors_save', { anchors });
}
export async function loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
return invoke('session_anchors_load', { projectId });
}
export async function deleteSessionAnchor(id: string): Promise<void> {
return invoke('session_anchor_delete', { id });
}
export async function clearProjectAnchors(projectId: string): Promise<void> {
return invoke('session_anchors_clear', { projectId });
}
export async function updateAnchorType(id: string, anchorType: string): Promise<void> {
return invoke('session_anchor_update_type', { id, anchorType });
}

View file

@ -0,0 +1,57 @@
/**
* Audit log bridge reads/writes audit events via Tauri IPC.
* Used by agent-dispatcher, wake-scheduler, and AgentSession for event tracking.
*/
import { invoke } from '@tauri-apps/api/core';
import type { AgentId, GroupId } from '../types/ids';
export interface AuditEntry {
id: number;
agentId: string;
eventType: string;
detail: string;
createdAt: string;
}
/** Audit event types */
export type AuditEventType =
| 'prompt_injection'
| 'wake_event'
| 'btmsg_sent'
| 'btmsg_received'
| 'status_change'
| 'heartbeat_missed'
| 'dead_letter';
/**
* Log an audit event for an agent.
*/
export async function logAuditEvent(
agentId: AgentId,
eventType: AuditEventType,
detail: string,
): Promise<void> {
return invoke('audit_log_event', { agentId, eventType, detail });
}
/**
* Get audit log entries for a group (reverse chronological).
*/
export async function getAuditLog(
groupId: GroupId,
limit: number = 200,
offset: number = 0,
): Promise<AuditEntry[]> {
return invoke('audit_log_list', { groupId, limit, offset });
}
/**
* Get audit log entries for a specific agent.
*/
export async function getAuditLogForAgent(
agentId: AgentId,
limit: number = 50,
): Promise<AuditEntry[]> {
return invoke('audit_log_for_agent', { agentId, limit });
}

View file

@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
import {
getGroupAgents,
getUnreadCount,
getUnreadMessages,
getHistory,
sendMessage,
setAgentStatus,
ensureAdmin,
getAllFeed,
markRead,
getChannels,
getChannelMessages,
sendChannelMessage,
createChannel,
addChannelMember,
registerAgents,
type BtmsgAgent,
type BtmsgMessage,
type BtmsgFeedMessage,
type BtmsgChannel,
type BtmsgChannelMessage,
} from './btmsg-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
});
describe('btmsg-bridge', () => {
// ---- REGRESSION: camelCase field names ----
// Bug: TypeScript interfaces used snake_case (group_id, unread_count, from_agent, etc.)
// but Rust serde(rename_all = "camelCase") sends camelCase.
describe('BtmsgAgent camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const agent: BtmsgAgent = {
id: AgentId('a1'),
name: 'Coder',
role: 'developer',
groupId: GroupId('g1'), // was: group_id
tier: 1,
model: 'claude-4',
status: 'active',
unreadCount: 3, // was: unread_count
};
mockInvoke.mockResolvedValue([agent]);
const result = await getGroupAgents(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].groupId).toBe('g1');
expect(result[0].unreadCount).toBe(3);
// Verify snake_case fields do NOT exist
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['unread_count']).toBeUndefined();
});
it('invokes btmsg_get_agents with groupId', async () => {
mockInvoke.mockResolvedValue([]);
await getGroupAgents(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
});
});
describe('BtmsgMessage camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const msg: BtmsgMessage = {
id: 'm1',
fromAgent: AgentId('a1'), // was: from_agent
toAgent: AgentId('a2'), // was: to_agent
content: 'hello',
read: false,
replyTo: null, // was: reply_to
createdAt: '2026-01-01', // was: created_at
senderName: 'Coder', // was: sender_name
senderRole: 'dev', // was: sender_role
};
mockInvoke.mockResolvedValue([msg]);
const result = await getUnreadMessages(AgentId('a2'));
expect(result[0].fromAgent).toBe('a1');
expect(result[0].toAgent).toBe('a2');
expect(result[0].replyTo).toBeNull();
expect(result[0].createdAt).toBe('2026-01-01');
expect(result[0].senderName).toBe('Coder');
expect(result[0].senderRole).toBe('dev');
});
});
describe('BtmsgFeedMessage camelCase fields', () => {
it('receives camelCase fields including recipient info', async () => {
const feed: BtmsgFeedMessage = {
id: 'm1',
fromAgent: AgentId('a1'),
toAgent: AgentId('a2'),
content: 'review this',
createdAt: '2026-01-01',
replyTo: null,
senderName: 'Coder',
senderRole: 'developer',
recipientName: 'Reviewer',
recipientRole: 'reviewer',
};
mockInvoke.mockResolvedValue([feed]);
const result = await getAllFeed(GroupId('g1'));
expect(result[0].senderName).toBe('Coder');
expect(result[0].recipientName).toBe('Reviewer');
expect(result[0].recipientRole).toBe('reviewer');
});
});
describe('BtmsgChannel camelCase fields', () => {
it('receives camelCase fields', async () => {
const channel: BtmsgChannel = {
id: 'ch1',
name: 'general',
groupId: GroupId('g1'), // was: group_id
createdBy: AgentId('admin'), // was: created_by
memberCount: 5, // was: member_count
createdAt: '2026-01-01',
};
mockInvoke.mockResolvedValue([channel]);
const result = await getChannels(GroupId('g1'));
expect(result[0].groupId).toBe('g1');
expect(result[0].createdBy).toBe('admin');
expect(result[0].memberCount).toBe(5);
});
});
describe('BtmsgChannelMessage camelCase fields', () => {
it('receives camelCase fields', async () => {
const msg: BtmsgChannelMessage = {
id: 'cm1',
channelId: 'ch1', // was: channel_id
fromAgent: AgentId('a1'),
content: 'hello',
createdAt: '2026-01-01',
senderName: 'Coder',
senderRole: 'dev',
};
mockInvoke.mockResolvedValue([msg]);
const result = await getChannelMessages('ch1');
expect(result[0].channelId).toBe('ch1');
expect(result[0].fromAgent).toBe('a1');
expect(result[0].senderName).toBe('Coder');
});
});
// ---- IPC command name tests ----
describe('IPC commands', () => {
it('getUnreadCount invokes btmsg_unread_count', async () => {
mockInvoke.mockResolvedValue(5);
const result = await getUnreadCount(AgentId('a1'));
expect(result).toBe(5);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' });
});
it('getHistory invokes btmsg_history', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory(AgentId('a1'), AgentId('a2'), 50);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
});
it('getHistory defaults limit to 20', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory(AgentId('a1'), AgentId('a2'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 });
});
it('sendMessage invokes btmsg_send', async () => {
mockInvoke.mockResolvedValue('msg-id');
const result = await sendMessage(AgentId('a1'), AgentId('a2'), 'hello');
expect(result).toBe('msg-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
});
it('setAgentStatus invokes btmsg_set_status', async () => {
mockInvoke.mockResolvedValue(undefined);
await setAgentStatus(AgentId('a1'), 'active');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' });
});
it('ensureAdmin invokes btmsg_ensure_admin', async () => {
mockInvoke.mockResolvedValue(undefined);
await ensureAdmin(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' });
});
it('markRead invokes btmsg_mark_read', async () => {
mockInvoke.mockResolvedValue(undefined);
await markRead(AgentId('a2'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' });
});
it('sendChannelMessage invokes btmsg_channel_send', async () => {
mockInvoke.mockResolvedValue('cm-id');
const result = await sendChannelMessage('ch1', AgentId('a1'), 'hello channel');
expect(result).toBe('cm-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' });
});
it('createChannel invokes btmsg_create_channel', async () => {
mockInvoke.mockResolvedValue('ch-id');
const result = await createChannel('general', GroupId('g1'), AgentId('admin'));
expect(result).toBe('ch-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' });
});
it('addChannelMember invokes btmsg_add_channel_member', async () => {
mockInvoke.mockResolvedValue(undefined);
await addChannelMember('ch1', AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' });
});
it('registerAgents invokes btmsg_register_agents with groups config', async () => {
mockInvoke.mockResolvedValue(undefined);
const config = {
version: 1,
groups: [{ id: 'g1', name: 'Test', projects: [], agents: [] }],
activeGroupId: 'g1',
};
await registerAgents(config as any);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_register_agents', { config });
});
});
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(getGroupAgents(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -0,0 +1,234 @@
/**
* 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';
import type { GroupId, AgentId } from '../types/ids';
export interface BtmsgAgent {
id: AgentId;
name: string;
role: string;
groupId: GroupId;
tier: number;
model: string | null;
status: string;
unreadCount: number;
}
export interface BtmsgMessage {
id: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
read: boolean;
replyTo: string | null;
createdAt: string;
senderName?: string;
senderRole?: string;
}
export interface BtmsgFeedMessage {
id: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
createdAt: string;
replyTo: string | null;
senderName: string;
senderRole: string;
recipientName: string;
recipientRole: string;
}
export interface BtmsgChannel {
id: string;
name: string;
groupId: GroupId;
createdBy: AgentId;
memberCount: number;
createdAt: string;
}
export interface BtmsgChannelMessage {
id: string;
channelId: string;
fromAgent: AgentId;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
/**
* Get all agents in a group with their unread counts.
*/
export async function getGroupAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
return invoke('btmsg_get_agents', { groupId });
}
/**
* Get unread message count for an agent.
*/
export async function getUnreadCount(agentId: AgentId): Promise<number> {
return invoke('btmsg_unread_count', { agentId });
}
/**
* Get unread messages for an agent.
*/
export async function getUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
return invoke('btmsg_unread_messages', { agentId });
}
/**
* Get conversation history between two agents.
*/
export async function getHistory(agentId: AgentId, otherId: AgentId, limit: number = 20): Promise<BtmsgMessage[]> {
return invoke('btmsg_history', { agentId, otherId, limit });
}
/**
* Send a message from one agent to another.
*/
export async function sendMessage(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_send', { fromAgent, toAgent, content });
}
/**
* Update agent status (active/sleeping/stopped).
*/
export async function setAgentStatus(agentId: AgentId, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status });
}
/**
* Ensure admin agent exists with contacts to all agents.
*/
export async function ensureAdmin(groupId: GroupId): Promise<void> {
return invoke('btmsg_ensure_admin', { groupId });
}
/**
* Get all messages in group (admin global feed).
*/
export async function getAllFeed(groupId: GroupId, limit: number = 100): Promise<BtmsgFeedMessage[]> {
return invoke('btmsg_all_feed', { groupId, limit });
}
/**
* Mark all messages from sender to reader as read.
*/
export async function markRead(readerId: AgentId, senderId: AgentId): Promise<void> {
return invoke('btmsg_mark_read', { readerId, senderId });
}
/**
* Get channels in a group.
*/
export async function getChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
return invoke('btmsg_get_channels', { groupId });
}
/**
* Get messages in a channel.
*/
export async function getChannelMessages(channelId: string, limit: number = 100): Promise<BtmsgChannelMessage[]> {
return invoke('btmsg_channel_messages', { channelId, limit });
}
/**
* Send a message to a channel.
*/
export async function sendChannelMessage(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
}
/**
* Create a new channel.
*/
export async function createChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
return invoke('btmsg_create_channel', { name, groupId, createdBy });
}
/**
* Add a member to a channel.
*/
export async function addChannelMember(channelId: string, agentId: AgentId): Promise<void> {
return invoke('btmsg_add_channel_member', { channelId, agentId });
}
/**
* Register all agents from groups config into the btmsg database.
* Creates/updates agent records, sets up contact permissions, ensures review channels.
* Should be called whenever groups are loaded or switched.
*/
export async function registerAgents(config: import('../types/groups').GroupsFile): Promise<void> {
return invoke('btmsg_register_agents', { config });
}
// ---- Per-message acknowledgment (seen_messages) ----
/**
* Get messages not yet seen by this session (per-session tracking).
*/
export async function getUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
return invoke('btmsg_unseen_messages', { agentId, sessionId });
}
/**
* Mark specific message IDs as seen by this session.
*/
export async function markMessagesSeen(sessionId: string, messageIds: string[]): Promise<void> {
return invoke('btmsg_mark_seen', { sessionId, messageIds });
}
/**
* Prune old seen_messages entries (7-day default, emergency 3-day at 200k rows).
*/
export async function pruneSeen(): Promise<number> {
return invoke('btmsg_prune_seen');
}
// ---- Heartbeat monitoring ----
/**
* Record a heartbeat for an agent (upserts timestamp).
*/
export async function recordHeartbeat(agentId: AgentId): Promise<void> {
return invoke('btmsg_record_heartbeat', { agentId });
}
/**
* Get stale agents in a group (no heartbeat within threshold).
*/
export async function getStaleAgents(groupId: GroupId, thresholdSecs: number = 300): Promise<string[]> {
return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs });
}
// ---- Dead letter queue ----
export interface DeadLetter {
id: number;
fromAgent: string;
toAgent: string;
content: string;
error: string;
createdAt: string;
}
/**
* Get dead letter queue entries for a group.
*/
export async function getDeadLetters(groupId: GroupId, limit: number = 50): Promise<DeadLetter[]> {
return invoke('btmsg_get_dead_letters', { groupId, limit });
}
/**
* Clear all dead letters for a group.
*/
export async function clearDeadLetters(groupId: GroupId): Promise<void> {
return invoke('btmsg_clear_dead_letters', { groupId });
}

View file

@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
import {
listTasks,
getTaskComments,
updateTaskStatus,
addTaskComment,
createTask,
deleteTask,
type Task,
type TaskComment,
} from './bttask-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
});
describe('bttask-bridge', () => {
// ---- REGRESSION: camelCase field names ----
describe('Task camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const task: Task = {
id: 't1',
title: 'Fix bug',
description: 'Critical fix',
status: 'progress',
priority: 'high',
assignedTo: AgentId('a1'), // was: assigned_to
createdBy: AgentId('admin'), // was: created_by
groupId: GroupId('g1'), // was: group_id
parentTaskId: null, // was: parent_task_id
sortOrder: 1, // was: sort_order
createdAt: '2026-01-01', // was: created_at
updatedAt: '2026-01-01', // was: updated_at
};
mockInvoke.mockResolvedValue([task]);
const result = await listTasks(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].assignedTo).toBe('a1');
expect(result[0].createdBy).toBe('admin');
expect(result[0].groupId).toBe('g1');
expect(result[0].parentTaskId).toBeNull();
expect(result[0].sortOrder).toBe(1);
// Verify no snake_case leaks
expect((result[0] as Record<string, unknown>)['assigned_to']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['created_by']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
});
});
describe('TaskComment camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const comment: TaskComment = {
id: 'c1',
taskId: 't1', // was: task_id
agentId: AgentId('a1'), // was: agent_id
content: 'Working on it',
createdAt: '2026-01-01',
};
mockInvoke.mockResolvedValue([comment]);
const result = await getTaskComments('t1');
expect(result[0].taskId).toBe('t1');
expect(result[0].agentId).toBe('a1');
expect((result[0] as Record<string, unknown>)['task_id']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['agent_id']).toBeUndefined();
});
});
// ---- IPC command name tests ----
describe('IPC commands', () => {
it('listTasks invokes bttask_list', async () => {
mockInvoke.mockResolvedValue([]);
await listTasks(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
});
it('getTaskComments invokes bttask_comments', async () => {
mockInvoke.mockResolvedValue([]);
await getTaskComments('t1');
expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' });
});
it('updateTaskStatus invokes bttask_update_status with version', async () => {
mockInvoke.mockResolvedValue(2);
const newVersion = await updateTaskStatus('t1', 'done', 1);
expect(newVersion).toBe(2);
expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'done', version: 1 });
});
it('addTaskComment invokes bttask_add_comment', async () => {
mockInvoke.mockResolvedValue('c-id');
const result = await addTaskComment('t1', AgentId('a1'), 'Done!');
expect(result).toBe('c-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' });
});
it('createTask invokes bttask_create with all fields', async () => {
mockInvoke.mockResolvedValue('t-id');
const result = await createTask('Fix bug', 'desc', 'high', GroupId('g1'), AgentId('admin'), AgentId('a1'));
expect(result).toBe('t-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Fix bug',
description: 'desc',
priority: 'high',
groupId: 'g1',
createdBy: 'admin',
assignedTo: 'a1',
});
});
it('createTask invokes bttask_create without assignedTo', async () => {
mockInvoke.mockResolvedValue('t-id');
await createTask('Add tests', '', 'medium', GroupId('g1'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Add tests',
description: '',
priority: 'medium',
groupId: 'g1',
createdBy: 'a1',
assignedTo: undefined,
});
});
it('deleteTask invokes bttask_delete', async () => {
mockInvoke.mockResolvedValue(undefined);
await deleteTask('t1');
expect(mockInvoke).toHaveBeenCalledWith('bttask_delete', { taskId: 't1' });
});
});
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(listTasks(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -0,0 +1,65 @@
// bttask Bridge — Tauri IPC adapter for task board
import { invoke } from '@tauri-apps/api/core';
import type { GroupId, AgentId } from '../types/ids';
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'progress' | 'review' | 'done' | 'blocked';
priority: 'low' | 'medium' | 'high' | 'critical';
assignedTo: AgentId | null;
createdBy: AgentId;
groupId: GroupId;
parentTaskId: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
version: number;
}
export interface TaskComment {
id: string;
taskId: string;
agentId: AgentId;
content: string;
createdAt: string;
}
export async function listTasks(groupId: GroupId): Promise<Task[]> {
return invoke<Task[]>('bttask_list', { groupId });
}
export async function getTaskComments(taskId: string): Promise<TaskComment[]> {
return invoke<TaskComment[]>('bttask_comments', { taskId });
}
/** Update task status with optimistic locking. Returns the new version number. */
export async function updateTaskStatus(taskId: string, status: string, version: number): Promise<number> {
return invoke<number>('bttask_update_status', { taskId, status, version });
}
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
}
export async function createTask(
title: string,
description: string,
priority: string,
groupId: GroupId,
createdBy: AgentId,
assignedTo?: AgentId,
): Promise<string> {
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
}
export async function deleteTask(taskId: string): Promise<void> {
return invoke('bttask_delete', { taskId });
}
/** Count tasks currently in 'review' status for a group */
export async function reviewQueueCount(groupId: GroupId): Promise<number> {
return invoke<number>('bttask_review_queue_count', { groupId });
}

View file

@ -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<ClaudeProfile[]> {
return invoke<ClaudeProfile[]>('claude_list_profiles');
}
export async function listSkills(): Promise<ClaudeSkill[]> {
return invoke<ClaudeSkill[]>('claude_list_skills');
}
export async function readSkill(path: string): Promise<string> {
return invoke<string>('claude_read_skill', { path });
}

View file

@ -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);
});
});
});

View file

@ -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<string, unknown>): 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<string, unknown>,
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<string, unknown>
: {};
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<string, unknown>,
uuid: string,
timestamp: number,
parentId?: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : 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<string, unknown>,
uuid: string,
timestamp: number,
parentId?: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : 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<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const usage = typeof raw.usage === 'object' && raw.usage !== null ? raw.usage as Record<string, unknown> : 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,
}];
}

View file

@ -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);
});
});
});

View file

@ -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<string, unknown>): 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<string, unknown>)?.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<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const usage = typeof raw.usage === 'object' && raw.usage !== null
? raw.usage as Record<string, unknown>
: {};
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<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const item = typeof raw.item === 'object' && raw.item !== null
? raw.item as Record<string, unknown>
: {};
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<string, unknown>,
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<string, unknown>,
uuid: string,
timestamp: number,
eventType: string,
): AgentMessage[] {
if (eventType !== 'item.completed') return [];
const changes = Array.isArray(item.changes) ? item.changes as Array<Record<string, unknown>> : [];
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<string, unknown>,
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<string, unknown>
: undefined;
const error = typeof item.error === 'object' && item.error !== null
? item.error as Record<string, unknown>
: 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;
}

View file

@ -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<void> {
return invoke('ctx_init_db');
}
export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
}
export async function ctxGetContext(project: string): Promise<CtxEntry[]> {
return invoke('ctx_get_context', { project });
}
export async function ctxGetShared(): Promise<CtxEntry[]> {
return invoke('ctx_get_shared');
}
export async function ctxGetSummaries(project: string, limit: number = 5): Promise<CtxSummary[]> {
return invoke('ctx_get_summaries', { project, limit });
}
export async function ctxSearch(query: string): Promise<CtxEntry[]> {
return invoke('ctx_search', { query });
}

View file

@ -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<string> {
return invoke('file_watch', { paneId, path });
}
export async function unwatchFile(paneId: string): Promise<void> {
return invoke('file_unwatch', { paneId });
}
export async function readFile(path: string): Promise<string> {
return invoke('file_read', { path });
}
export async function onFileChanged(
callback: (payload: FileChangedPayload) => void
): Promise<UnlistenFn> {
return listen<FileChangedPayload>('file-changed', (event) => {
callback(event.payload);
});
}

View file

@ -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<DirEntry[]> {
return invoke<DirEntry[]>('list_directory_children', { path });
}
export function readFileContent(path: string): Promise<FileContent> {
return invoke<FileContent>('read_file_content', { path });
}
export function writeFileContent(path: string, content: string): Promise<void> {
return invoke<void>('write_file_content', { path, content });
}

View file

@ -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<void> {
return invoke('fs_watch_project', { projectId, cwd });
}
/** Stop watching a project's CWD */
export function fsUnwatchProject(projectId: string): Promise<void> {
return invoke('fs_unwatch_project', { projectId });
}
/** Listen for filesystem write events from all watched projects */
export function onFsWriteDetected(
callback: (event: FsWriteEvent) => void,
): Promise<UnlistenFn> {
return listen<FsWriteEvent>('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<FsWatcherStatus> {
return invoke('fs_watcher_status');
}

View file

@ -0,0 +1,111 @@
import { invoke } from '@tauri-apps/api/core';
import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups';
import type { SessionId, ProjectId } from '../types/ids';
export type { GroupsFile, ProjectConfig, GroupConfig };
export interface MdFileEntry {
name: string;
path: string;
priority: boolean;
}
export interface AgentMessageRecord {
id: number;
session_id: SessionId;
project_id: ProjectId;
sdk_session_id: string | null;
message_type: string;
content: string;
parent_id: string | null;
created_at: number;
}
export interface ProjectAgentState {
project_id: ProjectId;
last_session_id: SessionId;
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<GroupsFile> {
return invoke('groups_load');
}
export async function saveGroups(config: GroupsFile): Promise<void> {
return invoke('groups_save', { config });
}
// --- Markdown discovery ---
export async function discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
return invoke('discover_markdown_files', { cwd });
}
// --- Agent message persistence ---
export async function saveAgentMessages(
sessionId: SessionId,
projectId: ProjectId,
sdkSessionId: string | undefined,
messages: AgentMessageRecord[],
): Promise<void> {
return invoke('agent_messages_save', {
sessionId,
projectId,
sdkSessionId: sdkSessionId ?? null,
messages,
});
}
export async function loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
return invoke('agent_messages_load', { projectId });
}
// --- Project agent state ---
export async function saveProjectAgentState(state: ProjectAgentState): Promise<void> {
return invoke('project_agent_state_save', { state });
}
export async function loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
return invoke('project_agent_state_load', { projectId });
}
// --- Session metrics ---
export interface SessionMetric {
id: number;
project_id: ProjectId;
session_id: SessionId;
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<SessionMetric, 'id'>): Promise<void> {
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
}
export async function loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetric[]> {
return invoke('session_metrics_load', { projectId, limit });
}
// --- CLI arguments ---
export async function getCliGroup(): Promise<string | null> {
return invoke('cli_get_group');
}

View file

@ -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();
});
});

View file

@ -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<string, unknown>;
created_at?: string;
updated_at?: string;
}
interface MemoraSearchResult {
nodes: MemoraNode[];
total: number;
}
// --- IPC wrappers ---
export async function memoraAvailable(): Promise<boolean> {
return invoke<boolean>('memora_available');
}
export async function memoraList(options?: {
tags?: string[];
limit?: number;
offset?: number;
}): Promise<MemoraSearchResult> {
return invoke<MemoraSearchResult>('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<MemoraSearchResult> {
return invoke<MemoraSearchResult>('memora_search', {
query,
tags: options?.tags ?? null,
limit: options?.limit ?? 50,
});
}
export async function memoraGet(id: number): Promise<MemoraNode | null> {
return invoke<MemoraNode | null>('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<boolean> {
this._available = await memoraAvailable();
return this._available;
}
async list(options?: {
tags?: string[];
limit?: number;
offset?: number;
}): Promise<MemorySearchResult> {
const result = await memoraList(options);
this._available = true;
return toSearchResult(result);
}
async search(
query: string,
options?: { tags?: string[]; limit?: number },
): Promise<MemorySearchResult> {
const result = await memoraSearch(query, options);
this._available = true;
return toSearchResult(result);
}
async get(id: string | number): Promise<MemoryNode | null> {
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;
}
}

View file

@ -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<string, unknown>;
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<MemorySearchResult>;
/** Semantic search across memories */
search(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemorySearchResult>;
/** Get a single memory by ID */
get(id: string | number): Promise<MemoryNode | null>;
}
/** Registry of available memory adapters */
const adapters = new Map<string, MemoryAdapter>();
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];
}

View file

@ -0,0 +1,35 @@
// 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';
import { adaptAiderMessage } from './aider-messages';
/** Function signature for a provider message adapter */
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
const adapters = new Map<ProviderId, MessageAdapter>();
/** 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<string, unknown>): 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);
registerMessageAdapter('aider', adaptAiderMessage);

View file

@ -0,0 +1,19 @@
// Notifications bridge — wraps Tauri desktop notification command
import { invoke } from '@tauri-apps/api/core';
export type NotificationUrgency = 'low' | 'normal' | 'critical';
/**
* Send an OS desktop notification via notify-rust.
* Fire-and-forget: errors are swallowed (notification daemon may not be running).
*/
export function sendDesktopNotification(
title: string,
body: string,
urgency: NotificationUrgency = 'normal',
): void {
invoke('notify_desktop', { title, body, urgency }).catch(() => {
// Swallow IPC errors — notifications must never break the app
});
}

View file

@ -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');
});
});
});

View file

@ -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<string, unknown>): 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<string, unknown>,
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<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null
? raw.message as Record<string, unknown>
: {};
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;
}

View file

@ -0,0 +1,22 @@
// Plugin discovery and file access — Tauri IPC adapter
import { invoke } from '@tauri-apps/api/core';
export interface PluginMeta {
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}
/** Discover all plugins in ~/.config/bterminal/plugins/ */
export async function discoverPlugins(): Promise<PluginMeta[]> {
return invoke<PluginMeta[]>('plugins_discover');
}
/** Read a file from a plugin's directory (path-traversal safe) */
export async function readPluginFile(pluginId: string, filename: string): Promise<string> {
return invoke<string>('plugin_read_file', { pluginId, filename });
}

View file

@ -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<ClaudeProfile[]> {
if (provider === 'claude') return claudeListProfiles();
return [];
}
/** List skills for a given provider (only Claude supports this) */
export async function listProviderSkills(provider: ProviderId): Promise<ClaudeSkill[]> {
if (provider === 'claude') return claudeListSkills();
return [];
}
/** Read a skill file (only Claude supports this) */
export async function readProviderSkill(provider: ProviderId, path: string): Promise<string> {
if (provider === 'claude') return claudeReadSkill(path);
return '';
}

View file

@ -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<string> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...ptyOptions } = options;
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
}
return invoke<string>('pty_spawn', { options });
}
export async function writePty(id: string, data: string, remoteMachineId?: string): Promise<void> {
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<void> {
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<void> {
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<UnlistenFn> {
return listen<string>(`pty-data-${id}`, (event) => {
callback(event.payload);
});
}
export async function onPtyExit(id: string, callback: () => void): Promise<UnlistenFn> {
return listen(`pty-exit-${id}`, () => {
callback();
});
}

View file

@ -0,0 +1,180 @@
// 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;
/** SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect. */
spki_pins?: string[];
}
export interface RemoteMachineInfo {
id: string;
label: string;
url: string;
status: string;
auto_connect: boolean;
/** Currently stored SPKI pin hashes (hex-encoded SHA-256) */
spki_pins: string[];
}
// --- Machine management ---
export async function listRemoteMachines(): Promise<RemoteMachineInfo[]> {
return invoke('remote_list');
}
export async function addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
return invoke('remote_add', { config });
}
export async function removeRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_remove', { machineId });
}
export async function connectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_connect', { machineId });
}
export async function disconnectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_disconnect', { machineId });
}
// --- SPKI certificate pinning ---
/** Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded). */
export async function probeSpki(url: string): Promise<string> {
return invoke('remote_probe_spki', { url });
}
/** Add an SPKI pin hash to a machine's trusted pins. */
export async function addSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_add_pin', { machineId, pin });
}
/** Remove an SPKI pin hash from a machine's trusted pins. */
export async function removeSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_remove_pin', { machineId, pin });
}
// --- Remote event listeners ---
export interface RemoteSidecarMessage {
machineId: string;
sessionId?: string;
event?: Record<string, unknown>;
}
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<UnlistenFn> {
return listen<RemoteSidecarMessage>('remote-sidecar-message', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyData(
callback: (msg: RemotePtyData) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyData>('remote-pty-data', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyExit(
callback: (msg: RemotePtyExit) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyExit>('remote-pty-exit', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineReady(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-ready', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineDisconnected(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-disconnected', (event) => {
callback(event.payload);
});
}
export async function onRemoteStateSync(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-state-sync', (event) => {
callback(event.payload);
});
}
export async function onRemoteError(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-error', (event) => {
callback(event.payload);
});
}
export interface RemoteReconnectingEvent {
machineId: string;
backoffSecs: number;
}
export async function onRemoteMachineReconnecting(
callback: (msg: RemoteReconnectingEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteReconnectingEvent>('remote-machine-reconnecting', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineReconnectReady(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-reconnect-ready', (event) => {
callback(event.payload);
});
}
// --- SPKI TOFU event ---
export interface RemoteSpkiTofuEvent {
machineId: string;
hash: string;
}
/** Listen for TOFU (Trust On First Use) events when a new SPKI pin is auto-stored. */
export async function onRemoteSpkiTofu(
callback: (msg: RemoteSpkiTofuEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteSpkiTofuEvent>('remote-spki-tofu', (event) => {
callback(event.payload);
});
}

View file

@ -0,0 +1,31 @@
// Search Bridge — Tauri IPC adapter for FTS5 full-text search
import { invoke } from '@tauri-apps/api/core';
export interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
/** Confirm search database is ready (no-op, initialized at app startup). */
export async function initSearch(): Promise<void> {
return invoke('search_init');
}
/** Search across all FTS5 tables (messages, tasks, btmsg). */
export async function searchAll(query: string, limit?: number): Promise<SearchResult[]> {
return invoke<SearchResult[]>('search_query', { query, limit: limit ?? 20 });
}
/** Drop and recreate all FTS5 tables (clears the index). */
export async function rebuildIndex(): Promise<void> {
return invoke('search_rebuild');
}
/** Index an agent message into the search database. */
export async function indexMessage(sessionId: string, role: string, content: string): Promise<void> {
return invoke('search_index_message', { sessionId, role, content });
}

View file

@ -0,0 +1,40 @@
import { invoke } from '@tauri-apps/api/core';
/** Store a secret in the system keyring. */
export async function storeSecret(key: string, value: string): Promise<void> {
return invoke('secrets_store', { key, value });
}
/** Retrieve a secret from the system keyring. Returns null if not found. */
export async function getSecret(key: string): Promise<string | null> {
return invoke('secrets_get', { key });
}
/** Delete a secret from the system keyring. */
export async function deleteSecret(key: string): Promise<void> {
return invoke('secrets_delete', { key });
}
/** List keys that have been stored in the keyring. */
export async function listSecrets(): Promise<string[]> {
return invoke('secrets_list');
}
/** Check if the system keyring is available. */
export async function hasKeyring(): Promise<boolean> {
return invoke('secrets_has_keyring');
}
/** Get the list of known/recognized secret key identifiers. */
export async function knownSecretKeys(): Promise<string[]> {
return invoke('secrets_known_keys');
}
/** Human-readable labels for known secret keys. */
export const SECRET_KEY_LABELS: Record<string, string> = {
anthropic_api_key: 'Anthropic API Key',
openai_api_key: 'OpenAI API Key',
openrouter_api_key: 'OpenRouter API Key',
github_token: 'GitHub Token',
relay_token: 'Relay Token',
};

View file

@ -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<PersistedSession[]> {
return invoke('session_list');
}
export async function saveSession(session: PersistedSession): Promise<void> {
return invoke('session_save', { session });
}
export async function deleteSession(id: string): Promise<void> {
return invoke('session_delete', { id });
}
export async function updateSessionTitle(id: string, title: string): Promise<void> {
return invoke('session_update_title', { id, title });
}
export async function touchSession(id: string): Promise<void> {
return invoke('session_touch', { id });
}
export async function updateSessionGroup(id: string, groupName: string): Promise<void> {
return invoke('session_update_group', { id, group_name: groupName });
}
export async function saveLayout(layout: PersistedLayout): Promise<void> {
return invoke('layout_save', { layout });
}
export async function loadLayout(): Promise<PersistedLayout> {
return invoke('layout_load');
}

View file

@ -0,0 +1,13 @@
import { invoke } from '@tauri-apps/api/core';
export async function getSetting(key: string): Promise<string | null> {
return invoke('settings_get', { key });
}
export async function setSetting(key: string, value: string): Promise<void> {
return invoke('settings_set', { key, value });
}
export async function listSettings(): Promise<[string, string][]> {
return invoke('settings_list');
}

View file

@ -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<SshSession[]> {
return invoke('ssh_session_list');
}
export async function saveSshSession(session: SshSession): Promise<void> {
return invoke('ssh_session_save', { session });
}
export async function deleteSshSession(id: string): Promise<void> {
return invoke('ssh_session_delete', { id });
}

View file

@ -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<string, unknown>,
): 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<string, unknown>) => telemetryLog('error', msg, ctx),
warn: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('warn', msg, ctx),
info: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('info', msg, ctx),
debug: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('debug', msg, ctx),
trace: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('trace', msg, ctx),
};

View file

@ -0,0 +1,669 @@
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,
mockAddNotification,
} = 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(),
mockAddNotification: 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<string, unknown>) => {
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),
addNotification: (...args: unknown[]) => mockAddNotification(...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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
});

364
src/lib/agent-dispatcher.ts Normal file
View file

@ -0,0 +1,364 @@
// 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, addNotification } from './stores/notifications.svelte';
import { classifyError } from './utils/error-classifier';
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';
import { indexMessage } from './adapters/search-bridge';
import { recordHeartbeat } from './adapters/btmsg-bridge';
import { logAuditEvent } from './adapters/audit-bridge';
import type { AgentId } from './types/ids';
// 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<void> {
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);
// Record heartbeat on any agent activity (best-effort, fire-and-forget)
const hbProjectId = getSessionProjectId(sessionId);
if (hbProjectId) {
recordHeartbeat(hbProjectId as unknown as AgentId).catch(() => {});
}
switch (msg.type) {
case 'agent_started':
updateAgentStatus(sessionId, 'running');
recordSessionStart(sessionId);
tel.info('agent_started', { sessionId });
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
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`);
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
break;
case 'agent_error': {
const errorMsg = msg.message ?? 'Unknown';
const classified = classifyError(errorMsg);
updateAgentStatus(sessionId, 'error', errorMsg);
tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type });
// Show type-specific toast
if (classified.type === 'rate_limit') {
notify('warning', `Rate limited. ${classified.retryDelaySec > 0 ? `Retrying in ~${classified.retryDelaySec}s...` : ''}`);
} else if (classified.type === 'auth') {
notify('error', 'API key invalid or expired. Check Settings.');
} else if (classified.type === 'quota') {
notify('error', 'API quota exceeded. Check your billing.');
} else if (classified.type === 'overloaded') {
notify('warning', 'API overloaded. Will retry shortly...');
} else if (classified.type === 'network') {
notify('error', 'Network error. Check your connection.');
} else {
notify('error', `Agent error: ${errorMsg}`);
}
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
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})...`);
addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system');
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<string, unknown>): 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<string, typeof messages>();
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) {
const costErrorMsg = cost.errors?.join('; ') ?? 'Unknown error';
const costClassified = classifyError(costErrorMsg);
updateAgentStatus(sessionId, 'error', costErrorMsg);
if (costClassified.type === 'rate_limit') {
notify('warning', `Rate limited. ${costClassified.retryDelaySec > 0 ? `Retrying in ~${costClassified.retryDelaySec}s...` : ''}`);
} else if (costClassified.type === 'auth') {
notify('error', 'API key invalid or expired. Check Settings.');
} else if (costClassified.type === 'quota') {
notify('error', 'API quota exceeded. Check your billing.');
} else {
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);
// Index searchable text content into FTS5 search database
for (const msg of mainMessages) {
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
indexMessage(sessionId, 'assistant', msg.content).catch(() => {});
}
}
}
// 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();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { buildAgentTree, subtreeCost, type AgentTreeNode } from '../../utils/agent-tree';
import type { AgentSession } from '../../stores/agents.svelte';
interface Props {
session: AgentSession;
onNodeClick?: (nodeId: string) => void;
}
let { session, onNodeClick }: Props = $props();
let tree = $derived(buildAgentTree(
session.id,
session.messages,
session.status,
session.costUsd,
session.inputTokens + session.outputTokens,
));
// Layout constants
const NODE_W = 100;
const NODE_H = 40;
const H_GAP = 24;
const V_GAP = 12;
interface LayoutNode {
node: AgentTreeNode;
x: number;
y: number;
children: LayoutNode[];
}
function layoutTree(node: AgentTreeNode, x: number, y: number): { layout: LayoutNode; height: number } {
if (node.children.length === 0) {
return {
layout: { node, x, y, children: [] },
height: NODE_H,
};
}
const childLayouts: LayoutNode[] = [];
let childY = y;
let totalHeight = 0;
for (const child of node.children) {
const result = layoutTree(child, x + NODE_W + H_GAP, childY);
childLayouts.push(result.layout);
childY += result.height + V_GAP;
totalHeight += result.height + V_GAP;
}
totalHeight -= V_GAP; // remove trailing gap
// Center parent vertically relative to children
const parentY = childLayouts.length > 0
? (childLayouts[0].y + childLayouts[childLayouts.length - 1].y) / 2
: y;
return {
layout: { node, x, y: parentY, children: childLayouts },
height: Math.max(NODE_H, totalHeight),
};
}
let layoutResult = $derived(layoutTree(tree, 8, 8));
let svgHeight = $derived(Math.max(80, layoutResult.height + 24));
let svgWidth = $derived(computeWidth(layoutResult.layout));
function computeWidth(layout: LayoutNode): number {
let maxX = layout.x + NODE_W;
for (const child of layout.children) {
maxX = Math.max(maxX, computeWidth(child));
}
return maxX + 16;
}
function statusColor(status: string): string {
switch (status) {
case 'running': return 'var(--ctp-blue)';
case 'done': return 'var(--ctp-green)';
case 'error': return 'var(--ctp-red)';
default: return 'var(--ctp-overlay1)';
}
}
function truncateLabel(text: string, maxLen: number): string {
return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
}
</script>
<div class="agent-tree">
<svg width={svgWidth} height={svgHeight}>
{#snippet renderNode(layout: LayoutNode)}
<!-- Edges to children -->
{#each layout.children as child}
<path
d="M {layout.x + NODE_W} {layout.y + NODE_H / 2}
C {layout.x + NODE_W + H_GAP / 2} {layout.y + NODE_H / 2},
{child.x - H_GAP / 2} {child.y + NODE_H / 2},
{child.x} {child.y + NODE_H / 2}"
fill="none"
stroke="var(--border)"
stroke-width="1.5"
/>
{/each}
<!-- Node rectangle -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<g
class="tree-node"
onclick={() => onNodeClick?.(layout.node.id)}
style="cursor: {onNodeClick ? 'pointer' : 'default'}"
>
<rect
x={layout.x}
y={layout.y}
width={NODE_W}
height={NODE_H}
rx="4"
fill="var(--bg-surface)"
stroke={statusColor(layout.node.status)}
stroke-width="1.5"
/>
<!-- Status dot -->
<circle
cx={layout.x + 10}
cy={layout.y + NODE_H / 2 - 4}
r="3"
fill={statusColor(layout.node.status)}
/>
<!-- Label -->
<text
x={layout.x + 18}
y={layout.y + NODE_H / 2 - 4}
fill="var(--text-primary)"
font-size="10"
font-family="var(--font-mono)"
dominant-baseline="middle"
>{truncateLabel(layout.node.label, 10)}</text>
<!-- Subtree cost -->
{#if subtreeCost(layout.node) > 0}
<text
x={layout.x + 18}
y={layout.y + NODE_H / 2 + 9}
fill="var(--ctp-yellow)"
font-size="8"
font-family="var(--font-mono)"
dominant-baseline="middle"
>${subtreeCost(layout.node).toFixed(4)}</text>
{/if}
</g>
<!-- Recurse children -->
{#each layout.children as child}
{@render renderNode(child)}
{/each}
{/snippet}
{@render renderNode(layoutResult.layout)}
</svg>
</div>
<style>
.agent-tree {
overflow: auto;
padding: 0.25rem;
background: var(--bg-primary);
}
.tree-node:hover rect {
fill: var(--bg-surface-hover, var(--ctp-surface1));
}
</style>

View file

@ -0,0 +1,146 @@
<script lang="ts">
interface Props {
inputTokens: number;
outputTokens: number;
contextLimit?: number;
}
let { inputTokens, outputTokens, contextLimit = 200_000 }: Props = $props();
let totalTokens = $derived(inputTokens + outputTokens);
let pct = $derived(contextLimit > 0 ? Math.min((totalTokens / contextLimit) * 100, 100) : 0);
let thresholdClass = $derived.by(() => {
if (pct >= 90) return 'critical';
if (pct >= 75) return 'high';
if (pct >= 50) return 'medium';
return 'low';
});
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
let showTooltip = $state(false);
</script>
{#if totalTokens > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="usage-meter"
class:critical={thresholdClass === 'critical'}
class:high={thresholdClass === 'high'}
class:medium={thresholdClass === 'medium'}
class:low={thresholdClass === 'low'}
onmouseenter={() => showTooltip = true}
onmouseleave={() => showTooltip = false}
>
<div class="meter-track">
<div class="meter-fill" style="width: {pct}%"></div>
</div>
<span class="meter-label">{formatTokens(totalTokens)}</span>
{#if showTooltip}
<div class="meter-tooltip">
<div class="tooltip-row">
<span class="tooltip-key">Input</span>
<span class="tooltip-val">{formatTokens(inputTokens)}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-key">Output</span>
<span class="tooltip-val">{formatTokens(outputTokens)}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-key">Total</span>
<span class="tooltip-val">{formatTokens(totalTokens)}</span>
</div>
<div class="tooltip-divider"></div>
<div class="tooltip-row">
<span class="tooltip-key">Limit</span>
<span class="tooltip-val">{formatTokens(contextLimit)}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-key">Used</span>
<span class="tooltip-val">{pct.toFixed(1)}%</span>
</div>
</div>
{/if}
</div>
{/if}
<style>
.usage-meter {
display: inline-flex;
align-items: center;
gap: 0.375rem;
position: relative;
}
.meter-track {
width: 3rem;
height: 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.1875rem;
overflow: hidden;
flex-shrink: 0;
}
.meter-fill {
height: 100%;
border-radius: 0.1875rem;
transition: width 0.3s ease, background 0.3s ease;
}
.low .meter-fill { background: var(--ctp-green); }
.medium .meter-fill { background: var(--ctp-yellow); }
.high .meter-fill { background: var(--ctp-peach); }
.critical .meter-fill { background: var(--ctp-red); }
.meter-label {
font-size: 0.625rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay1);
white-space: nowrap;
}
.meter-tooltip {
position: absolute;
bottom: calc(100% + 0.375rem);
left: 50%;
transform: translateX(-50%);
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
padding: 0.375rem 0.5rem;
min-width: 7.5rem;
z-index: 100;
box-shadow: 0 0.125rem 0.5rem color-mix(in srgb, var(--ctp-crust) 40%, transparent);
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.0625rem 0;
}
.tooltip-key {
font-size: 0.625rem;
color: var(--ctp-overlay0);
}
.tooltip-val {
font-size: 0.625rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-text);
}
.tooltip-divider {
height: 1px;
background: var(--ctp-surface1);
margin: 0.1875rem 0;
}
</style>

View file

@ -0,0 +1,396 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
ctxInitDb,
ctxRegisterProject,
ctxGetContext,
ctxGetShared,
ctxGetSummaries,
ctxSearch,
type CtxEntry,
type CtxSummary,
} from '../../adapters/ctx-bridge';
interface Props {
projectName: string;
projectCwd: string;
}
let { projectName, projectCwd }: Props = $props();
let entries = $state<CtxEntry[]>([]);
let sharedEntries = $state<CtxEntry[]>([]);
let summaries = $state<CtxSummary[]>([]);
let searchQuery = $state('');
let searchResults = $state<CtxEntry[]>([]);
let error = $state('');
let loading = $state(false);
let dbMissing = $state(false);
let initializing = $state(false);
async function loadProjectContext() {
loading = true;
try {
// Register project if not already (INSERT OR IGNORE)
await ctxRegisterProject(projectName, `Agent Orchestrator project: ${projectName}`, projectCwd);
const [ctx, shared, sums] = await Promise.all([
ctxGetContext(projectName),
ctxGetShared(),
ctxGetSummaries(projectName, 5),
]);
entries = ctx;
sharedEntries = shared;
summaries = sums;
error = '';
dbMissing = false;
} catch (e) {
error = `${e}`;
dbMissing = error.includes('not found'); // Coupled to Rust error text "ctx database not found"
} finally {
loading = false;
}
}
async function handleInitDb() {
initializing = true;
try {
await ctxInitDb();
await loadProjectContext();
} catch (e) {
error = `Failed to initialize database: ${e}`;
} finally {
initializing = false;
}
}
async function handleSearch() {
if (!searchQuery.trim()) {
searchResults = [];
return;
}
try {
searchResults = await ctxSearch(searchQuery);
} catch (e) {
error = `Search failed: ${e}`;
}
}
onMount(loadProjectContext);
</script>
<div class="context-pane">
<div class="ctx-header">
<h3>{projectName}</h3>
<input
type="text"
class="search-input"
placeholder="Search contexts..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
</div>
{#if error}
<div class="ctx-error-box">
<div class="ctx-error-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
{#if dbMissing}
<div class="ctx-error-text">Context database not found</div>
<div class="ctx-error-hint">
Create the database at <code>~/.claude-context/context.db</code> to get started.
</div>
<button class="init-btn" onclick={handleInitDb} disabled={initializing}>
{#if initializing}
Initializing...
{:else}
Initialize Database
{/if}
</button>
{:else}
<div class="ctx-error-text">{error}</div>
{/if}
</div>
{/if}
{#if !error}
<div class="ctx-body">
{#if loading}
<div class="loading">Loading...</div>
{:else if searchResults.length > 0}
<div class="section">
<h4>Search Results</h4>
{#each searchResults as result}
<div class="entry">
<div class="entry-header">
<span class="entry-project">{result.project}</span>
<span class="entry-key">{result.key}</span>
</div>
<pre class="entry-value">{result.value}</pre>
</div>
{/each}
<button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button>
</div>
{:else}
{#if entries.length > 0}
<div class="section">
<h4>Project Context</h4>
{#each entries as entry}
<div class="entry">
<div class="entry-header">
<span class="entry-key">{entry.key}</span>
<span class="entry-date">{entry.updated_at}</span>
</div>
<pre class="entry-value">{entry.value}</pre>
</div>
{/each}
</div>
{/if}
{#if sharedEntries.length > 0}
<div class="section">
<h4>Shared Context</h4>
{#each sharedEntries as entry}
<div class="entry">
<div class="entry-header">
<span class="entry-key">{entry.key}</span>
</div>
<pre class="entry-value">{entry.value}</pre>
</div>
{/each}
</div>
{/if}
{#if summaries.length > 0}
<div class="section">
<h4>Recent Sessions</h4>
{#each summaries as summary}
<div class="entry">
<div class="entry-header">
<span class="entry-date">{summary.created_at}</span>
</div>
<pre class="entry-value">{summary.summary}</pre>
</div>
{/each}
</div>
{/if}
{#if entries.length === 0 && sharedEntries.length === 0 && summaries.length === 0}
<div class="empty-state">
<p class="empty">No context stored yet.</p>
<p class="empty">Use <code>ctx set {projectName} &lt;key&gt; &lt;value&gt;</code> to add context entries.</p>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<style>
.context-pane {
display: flex;
flex-direction: column;
height: 100%;
background: var(--ctp-base);
color: var(--ctp-text);
font-size: 0.8rem;
}
.ctx-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
white-space: nowrap;
}
.ctx-header h3 {
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
color: var(--ctp-blue);
}
.search-input {
flex: 1;
min-width: 10em;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--term-font-family, monospace);
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
}
.search-input:focus {
outline: none;
border-color: var(--ctp-blue);
}
.ctx-error-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
text-align: center;
}
.ctx-error-icon {
color: var(--ctp-overlay0);
}
.ctx-error-text {
color: var(--ctp-red);
font-size: 0.75rem;
}
.ctx-error-hint {
color: var(--ctp-overlay1);
font-size: 0.7rem;
}
.ctx-error-hint code {
background: var(--ctp-surface0);
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-green);
}
.init-btn {
margin-top: 0.5rem;
padding: 0.375rem 1rem;
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.init-btn:hover:not(:disabled) {
opacity: 0.85;
}
.init-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ctx-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem;
}
h4 {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-mauve);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.375rem;
}
.section {
margin-bottom: 1rem;
}
.entry {
background: var(--ctp-surface0);
border-radius: 0.25rem;
padding: 0.375rem 0.5rem;
margin-bottom: 0.25rem;
}
.entry-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.entry-project {
font-size: 0.625rem;
color: var(--ctp-blue);
font-weight: 600;
}
.entry-key {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-green);
}
.entry-date {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.entry-value {
font-size: 0.6875rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--ctp-subtext0);
max-height: 12.5rem;
overflow-y: auto;
margin: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
gap: 0.25rem;
}
.empty {
color: var(--ctp-overlay0);
font-size: 0.6875rem;
font-style: italic;
margin: 0;
}
.empty code {
background: var(--ctp-surface0);
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-green);
font-style: normal;
}
.clear-btn {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface0);
color: var(--ctp-subtext0);
border-radius: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
cursor: pointer;
margin-top: 0.25rem;
}
.clear-btn:hover { color: var(--ctp-text); }
.loading {
color: var(--ctp-overlay0);
font-size: 0.75rem;
text-align: center;
padding: 1rem;
}
</style>

View file

@ -0,0 +1,428 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { marked, Renderer } from 'marked';
import { watchFile, unwatchFile, onFileChanged, type FileChangedPayload } from '../../adapters/file-bridge';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
interface Props {
filePath: string;
paneId: string;
onExit?: () => void;
onNavigate?: (absolutePath: string) => void;
}
let { filePath, paneId, onExit, onNavigate }: Props = $props();
let renderedHtml = $state('');
let error = $state('');
let unlisten: (() => void) | undefined;
let currentWatchPath = $state<string | null>(null);
let highlighterReady = $state(false);
const renderer = new Renderer();
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
if (lang) {
const highlighted = highlightCode(text, lang);
if (highlighted !== escapeHtml(text)) return highlighted;
}
return `<pre><code>${escapeHtml(text)}</code></pre>`;
};
function renderMarkdown(source: string): void {
try {
renderedHtml = marked.parse(source, { renderer, async: false }) as string;
error = '';
} catch (e) {
error = `Render error: ${e}`;
}
}
// React to filePath changes — re-watch the new file
$effect(() => {
if (!highlighterReady) return;
const path = filePath;
if (path === currentWatchPath) return;
// Unwatch previous file
if (currentWatchPath) {
unwatchFile(paneId).catch(() => {});
}
currentWatchPath = path;
watchFile(paneId, path)
.then(content => renderMarkdown(content))
.catch(e => { error = `Failed to open file: ${e}`; });
});
onMount(async () => {
try {
await getHighlighter();
highlighterReady = true;
unlisten = await onFileChanged((payload: FileChangedPayload) => {
if (payload.pane_id === paneId) {
renderMarkdown(payload.content);
}
});
} catch (e) {
error = `Failed to initialize: ${e}`;
}
});
onDestroy(() => {
unlisten?.();
unwatchFile(paneId).catch(() => {});
});
function handleLinkClick(event: MouseEvent) {
const anchor = (event.target as HTMLElement).closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
// Anchor links — scroll within page
if (href.startsWith('#')) return;
event.preventDefault();
// External URLs — open in system browser
if (/^https?:\/\//.test(href)) {
import('@tauri-apps/api/core').then(({ invoke }) => {
invoke('open_url', { url: href }).catch(() => {
// Fallback: do nothing (no shell plugin)
});
});
return;
}
// Relative file link — resolve against current file's directory
if (onNavigate) {
const dir = filePath.replace(/\/[^/]*$/, '');
const resolved = resolveRelativePath(dir, href);
onNavigate(resolved);
}
}
function resolveRelativePath(base: string, relative: string): string {
// Strip any anchor or query from the link
const cleanRelative = relative.split('#')[0].split('?')[0];
const parts = base.split('/');
for (const segment of cleanRelative.split('/')) {
if (segment === '..') {
parts.pop();
} else if (segment !== '.' && segment !== '') {
parts.push(segment);
}
}
return parts.join('/');
}
</script>
<div class="markdown-pane">
{#if error}
<div class="error">{error}</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="markdown-pane-scroll" onclick={handleLinkClick}>
<div class="markdown-body">
{@html renderedHtml}
</div>
</div>
{/if}
<div class="file-path">{filePath}</div>
</div>
<style>
.markdown-pane {
display: flex;
flex-direction: column;
height: 100%;
background: var(--ctp-base);
color: var(--ctp-text);
}
.markdown-pane-scroll {
flex: 1;
overflow-y: auto;
container-type: inline-size;
}
.markdown-body {
padding: 1.5rem var(--bterminal-pane-padding-inline, 2rem);
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 0.9rem;
line-height: 1.7;
color: var(--ctp-subtext1);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'cv01', 'cv02', 'cv03', 'cv04', 'ss01';
}
/* --- Headings --- */
.markdown-body :global(h1) {
font-size: 1.75em;
font-weight: 700;
line-height: 1.2;
margin: 1.5em 0 0.6em;
color: var(--ctp-lavender);
padding-bottom: 0.35em;
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface1) 60%, transparent);
letter-spacing: -0.01em;
}
.markdown-body :global(h1:first-child) {
margin-top: 0;
}
.markdown-body :global(h2) {
font-size: 1.4em;
font-weight: 650;
line-height: 1.25;
margin: 1.75em 0 0.5em;
color: var(--ctp-blue);
letter-spacing: -0.005em;
}
.markdown-body :global(h3) {
font-size: 1.15em;
font-weight: 600;
line-height: 1.3;
margin: 1.5em 0 0.4em;
color: var(--ctp-sapphire);
}
.markdown-body :global(h4) {
font-size: 1em;
font-weight: 600;
line-height: 1.4;
margin: 1.25em 0 0.35em;
color: var(--ctp-teal);
text-transform: none;
}
.markdown-body :global(h5) {
font-size: 0.875em;
font-weight: 600;
line-height: 1.4;
margin: 1.25em 0 0.3em;
color: var(--ctp-subtext1);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.markdown-body :global(h6) {
font-size: 0.8em;
font-weight: 600;
line-height: 1.4;
margin: 1em 0 0.25em;
color: var(--ctp-overlay2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* --- Prose --- */
.markdown-body :global(p) {
margin: 1.15em 0;
}
.markdown-body :global(strong) {
color: var(--ctp-text);
font-weight: 600;
}
.markdown-body :global(em) {
color: var(--ctp-subtext0);
font-style: italic;
}
/* --- Links --- */
.markdown-body :global(a) {
color: var(--ctp-blue);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--ctp-blue) 30%, transparent);
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
transition: text-decoration-color 0.2s ease;
}
.markdown-body :global(a:hover) {
text-decoration-color: var(--ctp-blue);
}
/* --- Inline code --- */
.markdown-body :global(code) {
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
padding: 0.175em 0.4em;
border-radius: 0.25em;
font-family: var(--term-font-family, 'JetBrains Mono', monospace);
font-size: 0.85em;
color: var(--ctp-green);
font-variant-ligatures: none;
}
/* --- Code blocks --- */
.markdown-body :global(pre) {
background: var(--ctp-mantle);
padding: 1rem 1.125rem;
border-radius: 0.375rem;
border: 1px solid var(--ctp-surface0);
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.6;
margin: 1.25em 0;
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ctp-surface1) 20%, transparent);
direction: ltr;
unicode-bidi: embed;
}
.markdown-body :global(pre code) {
background: none;
padding: 0;
color: var(--ctp-text);
font-size: inherit;
border-radius: 0;
}
.markdown-body :global(.shiki) {
background: var(--ctp-mantle) !important;
padding: 1rem 1.125rem;
border-radius: 0.375rem;
border: 1px solid var(--ctp-surface0);
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.6;
margin: 1.25em 0;
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ctp-surface1) 20%, transparent);
}
.markdown-body :global(.shiki code) {
background: none !important;
padding: 0;
}
/* --- Blockquote --- */
.markdown-body :global(blockquote) {
position: relative;
border-left: 3px solid var(--ctp-mauve);
margin: 1.5em 0;
padding: 0.5rem 1.125rem;
color: var(--ctp-overlay2);
background: color-mix(in srgb, var(--ctp-surface0) 20%, transparent);
border-radius: 0 0.25rem 0.25rem 0;
font-style: italic;
}
.markdown-body :global(blockquote p) {
margin: 0.5em 0;
}
.markdown-body :global(blockquote p:first-child) {
margin-top: 0;
}
.markdown-body :global(blockquote p:last-child) {
margin-bottom: 0;
}
/* --- Lists --- */
.markdown-body :global(ul), .markdown-body :global(ol) {
padding-left: 1.625em;
margin: 1em 0;
}
.markdown-body :global(li) {
margin: 0.35em 0;
}
.markdown-body :global(li::marker) {
color: var(--ctp-overlay1);
}
.markdown-body :global(ol > li::marker) {
color: var(--ctp-overlay2);
font-variant-numeric: tabular-nums;
}
.markdown-body :global(li > ul), .markdown-body :global(li > ol) {
margin: 0.25em 0;
}
/* --- Tables --- */
.markdown-body :global(table) {
border-collapse: collapse;
width: 100%;
margin: 1.5em 0;
font-size: 0.85em;
line-height: 1.5;
}
.markdown-body :global(th), .markdown-body :global(td) {
border: 1px solid var(--ctp-surface1);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markdown-body :global(th) {
background: color-mix(in srgb, var(--ctp-surface0) 60%, transparent);
font-weight: 600;
color: var(--ctp-subtext1);
font-size: 0.9em;
text-transform: none;
}
.markdown-body :global(tr:hover td) {
background: color-mix(in srgb, var(--ctp-surface0) 30%, transparent);
}
/* --- Horizontal rule --- */
.markdown-body :global(hr) {
border: none;
height: 1px;
margin: 2em 0;
background: linear-gradient(
to right,
transparent,
var(--ctp-surface1) 15%,
var(--ctp-surface1) 85%,
transparent
);
}
/* --- Images --- */
.markdown-body :global(img) {
max-width: 100%;
border-radius: 0.375rem;
margin: 1.25em 0;
}
/* --- Status bar --- */
.file-path {
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.75rem;
font-size: 0.65rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.error {
color: var(--ctp-red);
padding: 1.5rem 2rem;
font-size: 0.85rem;
font-family: 'Inter', system-ui, sans-serif;
}
</style>

View file

@ -0,0 +1,300 @@
<script lang="ts">
import {
getNotificationHistory,
getUnreadCount,
markRead,
markAllRead,
clearHistory,
type NotificationType,
} from '../../stores/notifications.svelte';
let history = $derived(getNotificationHistory());
let unreadCount = $derived(getUnreadCount());
let open = $state(false);
function toggle() {
open = !open;
}
function close() {
open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
close();
}
}
function handleClickNotification(id: string) {
markRead(id);
}
function typeIcon(type: NotificationType): string {
switch (type) {
case 'agent_complete': return '\u2713'; // checkmark
case 'agent_error': return '\u2715'; // x
case 'task_review': return '\u2691'; // flag
case 'wake_event': return '\u23F0'; // alarm
case 'conflict': return '\u26A0'; // warning
case 'system': return '\u2139'; // info
}
}
function typeColor(type: NotificationType): string {
switch (type) {
case 'agent_complete': return 'var(--ctp-green)';
case 'agent_error': return 'var(--ctp-red)';
case 'task_review': return 'var(--ctp-blue)';
case 'wake_event': return 'var(--ctp-teal)';
case 'conflict': return 'var(--ctp-yellow)';
case 'system': return 'var(--ctp-overlay1)';
}
}
function relativeTime(ts: number): string {
const diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="notification-center" data-testid="notification-center">
<button
class="bell-btn"
class:has-unread={unreadCount > 0}
onclick={toggle}
title="Notifications"
data-testid="notification-bell"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{#if unreadCount > 0}
<span class="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={close}></div>
<div class="panel" data-testid="notification-panel">
<div class="panel-header">
<span class="panel-title">Notifications</span>
<div class="panel-actions">
{#if unreadCount > 0}
<button class="action-btn" onclick={() => markAllRead()}>Mark all read</button>
{/if}
{#if history.length > 0}
<button class="action-btn" onclick={() => { clearHistory(); close(); }}>Clear</button>
{/if}
</div>
</div>
<div class="panel-list">
{#if history.length === 0}
<div class="empty">No notifications</div>
{:else}
{#each [...history].reverse() as item (item.id)}
<button
class="notification-item"
class:unread={!item.read}
onclick={() => handleClickNotification(item.id)}
>
<span class="notif-icon" style="color: {typeColor(item.type)}">{typeIcon(item.type)}</span>
<div class="notif-content">
<span class="notif-title">{item.title}</span>
<span class="notif-body">{item.body}</span>
</div>
<span class="notif-time">{relativeTime(item.timestamp)}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<style>
.notification-center {
position: relative;
display: flex;
align-items: center;
}
.bell-btn {
position: relative;
background: none;
border: none;
color: var(--ctp-overlay1);
cursor: pointer;
padding: 0.125rem;
display: flex;
align-items: center;
justify-content: center;
}
.bell-btn:hover {
color: var(--ctp-text);
}
.bell-btn.has-unread {
color: var(--ctp-peach);
}
.badge {
position: absolute;
top: -0.25rem;
right: -0.375rem;
background: var(--ctp-red);
color: var(--ctp-crust);
font-size: 0.5rem;
font-weight: 700;
min-width: 0.875rem;
height: 0.875rem;
border-radius: 0.4375rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.1875rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 199;
}
.panel {
position: absolute;
bottom: 1.75rem;
right: 0;
width: 20rem;
max-height: 25rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface1);
}
.panel-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
background: none;
border: none;
color: var(--ctp-blue);
font-size: 0.625rem;
cursor: pointer;
padding: 0;
}
.action-btn:hover {
color: var(--ctp-sapphire);
text-decoration: underline;
}
.panel-list {
overflow-y: auto;
flex: 1;
}
.empty {
padding: 1.5rem;
text-align: center;
font-size: 0.6875rem;
color: var(--ctp-overlay0);
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface1) 50%, transparent);
background: transparent;
width: 100%;
text-align: left;
cursor: pointer;
font: inherit;
color: var(--ctp-subtext0);
transition: background 0.1s;
}
.notification-item:hover {
background: color-mix(in srgb, var(--ctp-surface1) 40%, transparent);
}
.notification-item.unread {
background: color-mix(in srgb, var(--ctp-blue) 5%, transparent);
color: var(--ctp-text);
}
.notif-icon {
font-size: 0.75rem;
flex-shrink: 0;
width: 1rem;
text-align: center;
padding-top: 0.0625rem;
}
.notif-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.notif-title {
font-size: 0.6875rem;
font-weight: 600;
color: inherit;
}
.notif-body {
font-size: 0.625rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread .notif-body {
color: var(--ctp-overlay1);
}
.notif-time {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
white-space: nowrap;
flex-shrink: 0;
padding-top: 0.0625rem;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { getNotifications, dismissNotification } from '../../stores/notifications.svelte';
let toasts = $derived(getNotifications());
</script>
{#if toasts.length > 0}
<div class="toast-container">
{#each toasts as toast (toast.id)}
<div class="toast toast-{toast.type}" role="alert">
<span class="toast-icon">
{#if toast.type === 'success'}
{:else if toast.type === 'error'}
{:else if toast.type === 'warning'}!
{:else}i
{/if}
</span>
<span class="toast-message">{toast.message}</span>
<button class="toast-close" onclick={() => dismissNotification(toast.id)}>&times;</button>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
top: 0.75rem;
right: 0.75rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.375rem;
max-width: 22.5rem;
}
.toast {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius);
font-size: 0.75rem;
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slide-in 0.2s ease-out;
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.toast-icon {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
}
.toast-success .toast-icon { background: var(--ctp-green); color: var(--ctp-crust); }
.toast-error .toast-icon { background: var(--ctp-red); color: var(--ctp-crust); }
.toast-warning .toast-icon { background: var(--ctp-yellow); color: var(--ctp-crust); }
.toast-info .toast-icon { background: var(--ctp-blue); color: var(--ctp-crust); }
.toast-success { border-color: color-mix(in srgb, var(--ctp-green) 30%, transparent); }
.toast-error { border-color: color-mix(in srgb, var(--ctp-red) 30%, transparent); }
.toast-warning { border-color: color-mix(in srgb, var(--ctp-yellow) 30%, transparent); }
.toast-info { border-color: color-mix(in srgb, var(--ctp-blue) 30%, transparent); }
.toast-message {
flex: 1;
line-height: 1.3;
}
.toast-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.875rem;
padding: 0 0.125rem;
flex-shrink: 0;
}
.toast-close:hover { color: var(--text-primary); }
</style>

View file

@ -0,0 +1,138 @@
<script lang="ts">
import splashImg from '../../assets/splash.jpg';
interface Props {
steps: { label: string; done: boolean }[];
version?: string;
}
let { steps, version = 'v3' }: Props = $props();
let doneCount = $derived(steps.filter(s => s.done).length);
let progress = $derived(steps.length > 0 ? doneCount / steps.length : 0);
let currentStep = $derived(steps.find(s => !s.done)?.label ?? 'Ready');
</script>
<div class="splash">
<img src={splashImg} alt="" class="splash-bg" />
<div class="splash-overlay"></div>
<div class="splash-content">
<div class="splash-title">
<h1>Agent Orchestrator</h1>
<span class="splash-version">{version}</span>
<span class="splash-codename">Pandora's Box</span>
</div>
<div class="splash-progress">
<div class="progress-bar">
<div class="progress-fill" style:width="{progress * 100}%"></div>
</div>
<div class="progress-label">{currentStep}</div>
</div>
</div>
</div>
<style>
.splash {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #1e1e2e;
z-index: 9999;
}
.splash-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.4;
filter: blur(2px);
}
.splash-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(30, 30, 46, 0.3) 0%,
rgba(30, 30, 46, 0.6) 50%,
rgba(30, 30, 46, 0.95) 100%
);
}
.splash-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
width: min(480px, 90vw);
}
.splash-title {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.splash-title h1 {
font-size: 1.8rem;
font-weight: 700;
color: #cdd6f4;
margin: 0;
letter-spacing: 0.02em;
text-shadow: 0 2px 12px rgba(203, 166, 247, 0.3);
}
.splash-version {
font-size: 0.75rem;
color: #a6adc8;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.splash-codename {
font-size: 0.7rem;
color: #cba6f7;
font-style: italic;
opacity: 0.8;
}
.splash-progress {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-bar {
width: 100%;
height: 3px;
background: rgba(108, 112, 134, 0.3);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #cba6f7, #89b4fa);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-label {
font-size: 0.7rem;
color: #6c7086;
text-align: center;
min-height: 1em;
}
</style>

View file

@ -0,0 +1,375 @@
<script lang="ts">
import { getAgentSessions } from '../../stores/agents.svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
import { getTotalConflictCount } from '../../stores/conflicts.svelte';
import { onMount } from 'svelte';
import { checkForUpdates, installUpdate, type UpdateInfo } from '../../utils/updater';
import { message as dialogMessage, confirm } from '@tauri-apps/plugin-dialog';
import NotificationCenter from '../Notifications/NotificationCenter.svelte';
let agentSessions = $derived(getAgentSessions());
let activeGroup = $derived(getActiveGroup());
let enabledProjects = $derived(getEnabledProjects());
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
let projectCount = $derived(enabledProjects.length);
// Health-derived signals
let health = $derived(getHealthAggregates());
let attentionQueue = $derived(getAttentionQueue(5));
let totalConflicts = $derived(getTotalConflictCount());
let showAttention = $state(false);
// Auto-update state
let updateInfo = $state<UpdateInfo | null>(null);
let installing = $state(false);
onMount(() => {
// Check for updates 10s after startup
const timer = setTimeout(async () => {
const info = await checkForUpdates();
if (info.available) updateInfo = info;
}, 10_000);
return () => clearTimeout(timer);
});
async function handleUpdateClick() {
if (!updateInfo) return;
const notes = updateInfo.notes
? `Release notes:\n\n${updateInfo.notes}\n\nInstall and restart?`
: `Install v${updateInfo.version} and restart?`;
const confirmed = await confirm(notes, { title: `Update available: v${updateInfo.version}`, kind: 'info' });
if (confirmed) {
installing = true;
try {
await installUpdate();
} catch {
installing = false;
}
}
}
function projectName(projectId: string): string {
return enabledProjects.find(p => p.id === projectId)?.name ?? projectId.slice(0, 8);
}
function focusProject(projectId: string) {
setActiveProject(projectId);
showAttention = false;
}
function formatRate(rate: number): string {
if (rate < 0.01) return '$0/hr';
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
return `$${rate.toFixed(1)}/hr`;
}
function attentionColor(item: ProjectHealth): string {
if (item.attentionScore >= 90) return 'var(--ctp-red)';
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay1)';
}
</script>
<div class="status-bar" data-testid="status-bar">
<div class="left">
{#if activeGroup}
<span class="item group-name" title="Active group">{activeGroup.name}</span>
<span class="sep"></span>
{/if}
<span class="item" title="Enabled projects">{projectCount} projects</span>
<span class="sep"></span>
<!-- Agent states from health store -->
{#if health.running > 0}
<span class="item state-running" title="Running agents">
<span class="pulse"></span>
{health.running} running
</span>
<span class="sep"></span>
{/if}
{#if health.idle > 0}
<span class="item state-idle" title="Idle agents">{health.idle} idle</span>
<span class="sep"></span>
{/if}
{#if health.stalled > 0}
<span class="item state-stalled" title="Stalled agents (>15 min inactive)">
{health.stalled} stalled
</span>
<span class="sep"></span>
{/if}
{#if totalConflicts > 0}
<span class="item state-conflict" title="{totalConflicts} file conflict{totalConflicts > 1 ? 's' : ''} — multiple agents writing same file">
{totalConflicts} conflict{totalConflicts > 1 ? 's' : ''}
</span>
<span class="sep"></span>
{/if}
<!-- Attention queue toggle -->
{#if attentionQueue.length > 0}
<button
class="item attention-btn"
class:attention-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention — click to expand"
>
<span class="attention-dot"></span>
{attentionQueue.length} need attention
</button>
{/if}
</div>
<div class="right">
{#if health.totalBurnRatePerHour > 0}
<span class="item burn-rate" title="Total burn rate across active sessions">
{formatRate(health.totalBurnRatePerHour)}
</span>
<span class="sep"></span>
{/if}
{#if totalTokens > 0}
<span class="item tokens">{totalTokens.toLocaleString()} tok</span>
<span class="sep"></span>
{/if}
{#if totalCost > 0}
<span class="item cost">${totalCost.toFixed(4)}</span>
<span class="sep"></span>
{/if}
<NotificationCenter />
<span class="sep"></span>
{#if updateInfo?.available}
<button
class="item update-btn"
onclick={handleUpdateClick}
disabled={installing}
title="Click to install v{updateInfo.version}"
>
{#if installing}
Installing...
{:else}
Update v{updateInfo.version}
{/if}
</button>
<span class="sep"></span>
{/if}
<span class="item version">Agent Orchestrator v3</span>
</div>
</div>
<!-- Attention queue dropdown -->
{#if showAttention && attentionQueue.length > 0}
<div class="attention-panel">
{#each attentionQueue as item (item.projectId)}
<button
class="attention-card"
onclick={() => focusProject(item.projectId)}
>
<span class="card-name">{projectName(item.projectId)}</span>
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
{#if item.contextPressure !== null && item.contextPressure > 0.5}
<span class="card-ctx" title="Context usage">ctx {Math.round(item.contextPressure * 100)}%</span>
{/if}
</button>
{/each}
</div>
{/if}
<style>
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 1.5rem;
padding: 0 0.625rem;
background: var(--ctp-mantle);
border-top: 1px solid var(--ctp-surface0);
font-size: 0.6875rem;
color: var(--ctp-overlay1);
font-family: 'JetBrains Mono', monospace;
user-select: none;
flex-shrink: 0;
position: relative;
}
.left, .right {
display: flex;
align-items: center;
gap: 0.375rem;
}
.sep {
width: 1px;
height: 0.625rem;
background: var(--ctp-surface1);
}
.item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.group-name {
color: var(--ctp-blue);
font-weight: 600;
}
/* Agent state indicators */
.state-running {
color: var(--ctp-green);
}
.state-idle {
color: var(--ctp-overlay1);
}
.state-stalled {
color: var(--ctp-peach);
font-weight: 600;
}
.state-conflict {
color: var(--ctp-red);
font-weight: 600;
}
.pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-green);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Attention button */
.attention-btn {
background: none;
border: none;
color: var(--ctp-peach);
font: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0;
font-weight: 600;
}
.attention-btn:hover {
color: var(--ctp-red);
}
.attention-btn.attention-open {
color: var(--ctp-red);
}
.attention-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-peach);
animation: pulse 1.5s ease-in-out infinite;
}
.attention-btn.attention-open .attention-dot,
.attention-btn:hover .attention-dot {
background: var(--ctp-red);
}
/* Burn rate */
.burn-rate {
color: var(--ctp-mauve);
font-weight: 600;
}
.tokens { color: var(--ctp-overlay1); }
.cost { color: var(--ctp-yellow); }
.version { color: var(--ctp-overlay0); }
/* Update badge */
.update-btn {
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
border: 1px solid var(--ctp-green);
border-radius: 0.25rem;
color: var(--ctp-green);
font: inherit;
font-size: 0.625rem;
font-weight: 600;
padding: 0 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
line-height: 1.25rem;
}
.update-btn:hover {
background: color-mix(in srgb, var(--ctp-green) 25%, transparent);
}
.update-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Attention panel dropdown */
.attention-panel {
position: absolute;
bottom: 1.5rem;
left: 0;
right: 0;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface1);
display: flex;
gap: 1px;
padding: 0.25rem 0.5rem;
z-index: 100;
overflow-x: auto;
}
.attention-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.attention-card:hover {
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.card-name {
font-weight: 600;
color: var(--ctp-text);
}
.card-reason {
font-size: 0.625rem;
}
.card-ctx {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
padding: 0 0.25rem;
border-radius: 0.125rem;
}
</style>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
import { getAgentSession } from '../../stores/agents.svelte';
import type {
ToolCallContent,
ToolResultContent,
InitContent,
CostContent,
ErrorContent,
TextContent,
AgentMessage,
} from '../../adapters/claude-messages';
import '@xterm/xterm/css/xterm.css';
interface Props {
sessionId: string;
}
let { sessionId }: Props = $props();
let terminalEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null;
/** Track how many messages we've already rendered */
let renderedCount = 0;
let session = $derived(getAgentSession(sessionId));
// Watch for new messages and render them
$effect(() => {
if (!session || !term) return;
const msgs = session.messages;
if (msgs.length <= renderedCount) return;
const newMsgs = msgs.slice(renderedCount);
for (const msg of newMsgs) {
renderMessage(msg);
}
renderedCount = msgs.length;
});
// Reset when sessionId changes
$effect(() => {
// Access sessionId to track it
void sessionId;
renderedCount = 0;
if (term) {
term.clear();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
}
});
function renderMessage(msg: AgentMessage) {
switch (msg.type) {
case 'init': {
const c = msg.content as InitContent;
term.write(`\x1b[32m● Session started\x1b[0m \x1b[90m(${c.model})\x1b[0m\r\n`);
break;
}
case 'tool_call': {
const tc = msg.content as ToolCallContent;
if (tc.name === 'Bash') {
const cmd = (tc.input as { command?: string })?.command ?? '';
term.write(`\r\n\x1b[36m ${escapeForTerminal(cmd)}\x1b[0m\r\n`);
} else if (tc.name === 'Read' || tc.name === 'Write' || tc.name === 'Edit') {
const input = tc.input as { file_path?: string };
const path = input?.file_path ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(path)}\x1b[0m\r\n`);
} else if (tc.name === 'Grep' || tc.name === 'Glob') {
const input = tc.input as { pattern?: string };
const pattern = input?.pattern ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(pattern)}\x1b[0m\r\n`);
} else {
term.write(`\x1b[33m[${tc.name}]\x1b[0m\r\n`);
}
break;
}
case 'tool_result': {
const tr = msg.content as ToolResultContent;
const output = typeof tr.output === 'string'
? tr.output
: JSON.stringify(tr.output, null, 2);
if (output) {
// Truncate long outputs (show first 80 lines)
const lines = output.split('\n');
const truncated = lines.length > 80;
const display = truncated ? lines.slice(0, 80).join('\n') : output;
term.write(escapeForTerminal(display));
if (!display.endsWith('\n')) term.write('\r\n');
if (truncated) {
term.write(`\x1b[90m... (${lines.length - 80} more lines)\x1b[0m\r\n`);
}
}
break;
}
case 'text': {
const tc = msg.content as TextContent;
// Show brief text indicator (first line only)
const firstLine = tc.text.split('\n')[0].slice(0, 120);
term.write(`\x1b[37m${escapeForTerminal(firstLine)}\x1b[0m\r\n`);
break;
}
case 'error': {
const ec = msg.content as ErrorContent;
term.write(`\x1b[31m✗ ${escapeForTerminal(ec.message)}\x1b[0m\r\n`);
break;
}
case 'cost': {
const cc = msg.content as CostContent;
const cost = cc.totalCostUsd.toFixed(4);
const dur = (cc.durationMs / 1000).toFixed(1);
term.write(`\r\n\x1b[90m● Session complete ($${cost}, ${dur}s, ${cc.numTurns} turns)\x1b[0m\r\n`);
break;
}
// Skip thinking, status, unknown
}
}
/** Escape text for xterm — convert \n to \r\n */
function escapeForTerminal(text: string): string {
return text.replace(/\r?\n/g, '\r\n');
}
onMount(() => {
term = new Terminal({
theme: getXtermTheme(),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'underline',
scrollback: 10000,
allowProposedApi: true,
disableStdin: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
term.open(terminalEl);
fitAddon.fit();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
// If session already has messages, render them
const s = getAgentSession(sessionId);
if (s && s.messages.length > 0) {
for (const msg of s.messages) {
renderMessage(msg);
}
renderedCount = s.messages.length;
}
// Resize handling with debounce
let resizeTimer: ReturnType<typeof setTimeout>;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fitAddon.fit();
}, 100);
});
resizeObserver.observe(terminalEl);
// Hot-swap theme
unsubTheme = onThemeChange(() => {
term.options.theme = getXtermTheme();
});
});
onDestroy(() => {
resizeObserver?.disconnect();
unsubTheme?.();
term?.dispose();
});
</script>
<div class="agent-preview-container" bind:this={terminalEl}></div>
<style>
.agent-preview-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.agent-preview-container :global(.xterm) {
height: 100%;
padding: 0.25rem;
}
</style>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
import type { UnlistenFn } from '@tauri-apps/api/event';
import '@xterm/xterm/css/xterm.css';
interface Props {
shell?: string;
cwd?: string;
args?: string[];
onExit?: () => void;
}
let { shell, cwd, args, onExit }: Props = $props();
let terminalEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let ptyId: string | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null;
onMount(async () => {
term = new Terminal({
theme: getXtermTheme(),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowProposedApi: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
term.open(terminalEl);
fitAddon.fit();
const { cols, rows } = term;
// Spawn PTY
try {
ptyId = await spawnPty({ shell, cwd, args, cols, rows });
// Listen for PTY output
unlistenData = await onPtyData(ptyId, (data) => {
term.write(data);
});
unlistenExit = await onPtyExit(ptyId, () => {
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
onExit?.();
});
// Copy/paste via Ctrl+Shift+C/V
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.type === 'keydown') {
if (e.key === 'C') {
const selection = term.getSelection();
if (selection) navigator.clipboard.writeText(selection);
return false;
}
if (e.key === 'V') {
navigator.clipboard.readText().then(text => {
if (text && ptyId) writePty(ptyId, text);
});
return false;
}
}
return true;
});
// Forward keyboard input to PTY
term.onData((data) => {
if (ptyId) writePty(ptyId, data);
});
} catch (e) {
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
}
// Resize handling with debounce
let resizeTimer: ReturnType<typeof setTimeout>;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fitAddon.fit();
if (ptyId) {
const { cols, rows } = term;
resizePty(ptyId, cols, rows);
}
}, 100);
});
resizeObserver.observe(terminalEl);
// Hot-swap theme when flavor changes
unsubTheme = onThemeChange(() => {
term.options.theme = getXtermTheme();
});
});
onDestroy(async () => {
resizeObserver?.disconnect();
unsubTheme?.();
unlistenData?.();
unlistenExit?.();
if (ptyId) {
try { await killPty(ptyId); } catch { /* already dead */ }
}
term?.dispose();
});
</script>
<div class="terminal-container" bind:this={terminalEl}></div>
<style>
.terminal-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.terminal-container :global(.xterm) {
height: 100%;
padding: 0.25rem;
}
</style>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import type { AgentSession } from '../../stores/agents.svelte';
interface Props {
session: AgentSession;
onclick?: () => void;
}
let { session, onclick }: Props = $props();
let statusColor = $derived(
session.status === 'running' ? 'var(--ctp-green)' :
session.status === 'done' ? 'var(--ctp-blue)' :
session.status === 'error' ? 'var(--ctp-red)' :
'var(--ctp-overlay0)'
);
let truncatedPrompt = $derived(
session.prompt.length > 60
? session.prompt.slice(0, 60) + '...'
: session.prompt
);
</script>
<div class="agent-card" role="button" tabindex="0" {onclick} onkeydown={e => e.key === 'Enter' && onclick?.()}>
<div class="card-header">
<span class="status-dot" style="background: {statusColor}"></span>
<span class="agent-status">{session.status}</span>
{#if session.costUsd > 0}
<span class="agent-cost">${session.costUsd.toFixed(4)}</span>
{/if}
</div>
<div class="card-prompt">{truncatedPrompt}</div>
{#if session.status === 'running'}
<div class="card-progress">
<span class="turns">{session.numTurns} turns</span>
</div>
{/if}
</div>
<style>
.agent-card {
padding: 0.375rem 0.5rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.1s;
border-left: 2px solid transparent;
}
.agent-card:hover {
background: var(--ctp-surface1);
}
.card-header {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.1875rem;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-status {
font-size: 0.65rem;
color: var(--ctp-overlay1);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.agent-cost {
margin-left: auto;
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: monospace;
}
.card-prompt {
font-size: 0.72rem;
color: var(--ctp-subtext0);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-progress {
margin-top: 0.1875rem;
}
.turns {
font-size: 0.65rem;
color: var(--ctp-overlay0);
}
</style>

View file

@ -0,0 +1,382 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
import { generateAgentPrompt } from '../../utils/agent-prompts';
import { getActiveGroup } from '../../stores/workspace.svelte';
import { logAuditEvent } from '../../adapters/audit-bridge';
import type { AgentId } from '../../types/ids';
import {
loadProjectAgentState,
loadAgentMessages,
type ProjectAgentState,
type AgentMessageRecord,
} from '../../adapters/groups-bridge';
import { registerSessionProject } from '../../agent-dispatcher';
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
import { stopAgent } from '../../adapters/agent-bridge';
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
import {
createAgentSession,
appendAgentMessages,
updateAgentCost,
updateAgentStatus,
setAgentSdkSessionId,
getAgentSession,
} from '../../stores/agents.svelte';
import type { AgentMessage } from '../../adapters/claude-messages';
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import { getSecret } from '../../adapters/secrets-bridge';
import { getUnseenMessages, markMessagesSeen } from '../../adapters/btmsg-bridge';
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte';
/** How often to re-inject the system prompt (default 1 hour) */
const REINJECTION_INTERVAL_MS = 60 * 60 * 1000;
interface Props {
project: ProjectConfig;
onsessionid?: (id: string) => void;
}
let { project, onsessionid }: Props = $props();
let providerId = $derived(project.provider ?? getDefaultProviderId());
let providerMeta = $derived(getProvider(providerId));
let group = $derived(getActiveGroup());
// Build system prompt: full agent prompt for Tier 1, custom context for Tier 2
let agentPrompt = $derived.by(() => {
if (project.isAgent && project.agentRole && group) {
return generateAgentPrompt({
role: project.agentRole as GroupAgentRole,
agentId: project.id,
agentName: project.name,
group,
customPrompt: project.systemPrompt,
});
}
// Tier 2: include btmsg/bttask instructions + custom context
const tier2Parts: string[] = [];
tier2Parts.push(`You are a project agent working on "${project.name}".
Your agent ID is \`${project.id}\`. You communicate with other agents using CLI tools.
## Communication: btmsg
\`\`\`bash
btmsg inbox # Check for unread messages (DO THIS FIRST!)
btmsg send <agent-id> "message" # Send a message
btmsg reply <msg-id> "reply" # Reply to a message
btmsg contacts # See who you can message
\`\`\`
## Task Board: bttask
\`\`\`bash
bttask board # View task board
bttask show <task-id> # Task details
bttask status <task-id> progress # Mark as in progress
bttask status <task-id> done # Mark as done
bttask comment <task-id> "update" # Add a comment
\`\`\`
## Your Workflow
1. **Check inbox:** \`btmsg inbox\` — read and respond to messages
2. **Check tasks:** \`bttask board\` — see what's assigned to you
3. **Work:** Execute your assigned tasks in this project
4. **Update:** Report progress via \`bttask status\` and \`bttask comment\`
5. **Report:** Message the Manager when done or blocked`);
if (project.systemPrompt) {
tier2Parts.push(project.systemPrompt);
}
return tier2Parts.join('\n\n');
});
// Provider-specific API keys loaded from system keyring
let openrouterKey = $state<string | null>(null);
$effect(() => {
if (providerId === 'aider') {
getSecret('openrouter_api_key').then(key => {
openrouterKey = key;
}).catch(() => {});
} else {
openrouterKey = null;
}
});
// Inject BTMSG_AGENT_ID for all projects (Tier 1 and Tier 2) so they can use btmsg/bttask CLIs
// Manager agents also get CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS to enable subagent delegation
// Provider-specific API keys are injected from the system keyring
let agentEnv = $derived.by(() => {
const env: Record<string, string> = { BTMSG_AGENT_ID: project.id };
if (project.isAgent && project.agentRole === 'manager') {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
if (openrouterKey) {
env.OPENROUTER_API_KEY = openrouterKey;
}
return env;
});
// Periodic context re-injection timer
let lastPromptTime = $state(Date.now());
let contextRefreshPrompt = $state<string | undefined>(undefined);
let reinjectionTimer: ReturnType<typeof setInterval> | null = null;
function startReinjectionTimer() {
if (reinjectionTimer) clearInterval(reinjectionTimer);
lastPromptTime = Date.now();
reinjectionTimer = setInterval(() => {
const elapsed = Date.now() - lastPromptTime;
if (elapsed >= REINJECTION_INTERVAL_MS && !contextRefreshPrompt) {
const refreshMsg = project.isAgent
? '[Context Refresh] Review your role and available tools above. Check your inbox with `btmsg inbox` and review the task board with `bttask board`.'
: '[Context Refresh] Review the instructions above and continue your work.';
contextRefreshPrompt = refreshMsg;
// Audit: log prompt injection event
logAuditEvent(
project.id as unknown as AgentId,
'prompt_injection',
`Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`,
).catch(() => {});
}
}, 60_000); // Check every minute
}
function handleAutoPromptConsumed() {
contextRefreshPrompt = undefined;
lastPromptTime = Date.now();
}
// Listen for play-button start events from GroupAgentsPanel
const unsubAgentStart = onAgentStart((projectId) => {
if (projectId !== project.id) return;
// Only auto-start if not already running and no pending prompt
if (contextRefreshPrompt) return;
contextRefreshPrompt = 'Start your work. Check your inbox with `btmsg inbox` and review the task board with `bttask board`. Take action on any pending items.';
});
// Listen for stop-button events from GroupAgentsPanel
const unsubAgentStop = onAgentStop((projectId) => {
if (projectId !== project.id) return;
stopAgent(sessionId).catch(() => {});
});
// btmsg inbox polling — per-message acknowledgment wake mechanism
// Uses seen_messages table for per-session tracking instead of global unread count.
// Every unseen message triggers exactly one wake, regardless of timing.
let msgPollTimer: ReturnType<typeof setInterval> | null = null;
function startMsgPoll() {
if (msgPollTimer) clearInterval(msgPollTimer);
msgPollTimer = setInterval(async () => {
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
try {
const unseen = await getUnseenMessages(
project.id as unknown as AgentId,
sessionId,
);
if (unseen.length > 0) {
// Build a prompt with the actual message contents
const msgSummary = unseen.map(m =>
`From ${m.senderName ?? m.fromAgent} (${m.senderRole ?? 'unknown'}): ${m.content}`
).join('\n');
contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`;
// Mark as seen immediately to prevent re-injection
await markMessagesSeen(sessionId, unseen.map(m => m.id));
logAuditEvent(
project.id as unknown as AgentId,
'wake_event',
`Agent woken by ${unseen.length} btmsg message(s)`,
).catch(() => {});
}
} catch {
// btmsg not available, ignore
}
}, 10_000); // Check every 10s
}
// Start timer and clean up
startReinjectionTimer();
startMsgPoll();
onDestroy(() => {
if (reinjectionTimer) clearInterval(reinjectionTimer);
if (wakeCheckTimer) clearInterval(wakeCheckTimer);
if (msgPollTimer) clearInterval(msgPollTimer);
unsubAgentStart();
unsubAgentStop();
});
// Wake scheduler integration — poll for wake events (Manager agents only)
let wakeCheckTimer: ReturnType<typeof setInterval> | null = null;
const isManager = $derived(project.isAgent && project.agentRole === 'manager');
function startWakeCheck() {
if (wakeCheckTimer) clearInterval(wakeCheckTimer);
if (!isManager) return;
wakeCheckTimer = setInterval(() => {
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
const event = getWakeEvent(project.id);
if (!event) return;
if (event.mode === 'fresh') {
// On-demand / Smart: reset session, inject wake context as initial prompt
handleNewSession();
contextRefreshPrompt = buildWakePrompt(event.context.evaluation.summary);
} else {
// Persistent: resume existing session with wake context
contextRefreshPrompt = buildWakePrompt(event.context.evaluation.summary);
}
consumeWakeEvent(project.id);
}, 5_000); // Check every 5s
}
function buildWakePrompt(summary: string): string {
return `[Auto-Wake] You have been woken by the auto-wake scheduler. Here is the current fleet status:\n\n${summary}\n\nCheck your inbox with \`btmsg inbox\` and review the task board with \`bttask board\`. Take action on any urgent items above.`;
}
// Start wake check when component mounts (for managers)
$effect(() => {
if (isManager) {
startWakeCheck();
} else if (wakeCheckTimer) {
clearInterval(wakeCheckTimer);
wakeCheckTimer = null;
}
});
let sessionId = $state(SessionId(crypto.randomUUID()));
let lastState = $state<ProjectAgentState | null>(null);
let loading = $state(true);
let hasRestoredHistory = $state(false);
function handleNewSession() {
sessionId = SessionId(crypto.randomUUID());
hasRestoredHistory = false;
lastState = null;
lastPromptTime = Date.now();
contextRefreshPrompt = undefined;
registerSessionProject(sessionId, ProjectId(project.id), providerId);
trackProject(ProjectId(project.id), sessionId);
// Notify wake scheduler of new session ID
if (isManager) updateManagerSession(project.id, sessionId);
onsessionid?.(sessionId);
}
// Load previous session state when project changes
$effect(() => {
const pid = project.id;
loadPreviousState(pid);
});
async function loadPreviousState(projectId: string) {
loading = true;
hasRestoredHistory = false;
try {
const state = await loadProjectAgentState(projectId);
lastState = state;
if (state?.last_session_id) {
sessionId = SessionId(state.last_session_id);
// Restore cached messages into the agent store
const records = await loadAgentMessages(projectId);
if (records.length > 0) {
restoreMessagesFromRecords(sessionId, state, records);
hasRestoredHistory = true;
}
} else {
sessionId = SessionId(crypto.randomUUID());
}
} catch (e) {
console.warn('Failed to load project agent state:', e);
sessionId = SessionId(crypto.randomUUID());
} finally {
loading = false;
// Load persisted anchors for this project
loadAnchorsForProject(ProjectId(project.id));
// Register session -> project mapping for persistence + health tracking
registerSessionProject(sessionId, ProjectId(project.id), providerId);
trackProject(ProjectId(project.id), sessionId);
onsessionid?.(sessionId);
}
}
function restoreMessagesFromRecords(
sid: string,
state: ProjectAgentState,
records: AgentMessageRecord[],
) {
// Don't re-create if already exists
if (getAgentSession(sid)) return;
createAgentSession(sid, state.last_prompt ?? '');
if (state.sdk_session_id) {
setAgentSdkSessionId(sid, state.sdk_session_id);
}
// Convert records back to AgentMessage format
const messages: AgentMessage[] = records.map(r => ({
id: `restored-${r.id}`,
type: r.message_type as AgentMessage['type'],
content: JSON.parse(r.content),
parentId: r.parent_id ?? undefined,
timestamp: r.created_at ?? Date.now(),
}));
appendAgentMessages(sid, messages);
updateAgentCost(sid, {
costUsd: state.cost_usd,
inputTokens: state.input_tokens,
outputTokens: state.output_tokens,
numTurns: 0,
durationMs: 0,
});
// Mark as done (it's a restored completed session)
updateAgentStatus(sid, state.status === 'error' ? 'error' : 'done');
}
</script>
<div class="agent-session" data-testid="agent-session">
{#if loading}
<div class="loading-state">Loading session...</div>
{:else}
<AgentPane
{sessionId}
projectId={project.id}
cwd={project.cwd}
profile={project.profile || undefined}
provider={providerId}
capabilities={providerMeta?.capabilities}
useWorktrees={project.useWorktrees ?? false}
agentSystemPrompt={agentPrompt}
model={project.model}
extraEnv={agentEnv}
autonomousMode={project.autonomousMode}
autoPrompt={contextRefreshPrompt}
onautopromptconsumed={handleAutoPromptConsumed}
onExit={handleNewSession}
/>
{/if}
</div>
<style>
.agent-session {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.85rem;
}
</style>

View file

@ -0,0 +1,479 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry } from '../../adapters/files-bridge';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
/** Directory where .puml files are stored */
const ARCH_DIR = '.architecture';
let diagrams = $state<DirEntry[]>([]);
let selectedFile = $state<string | null>(null);
let pumlSource = $state('');
let svgUrl = $state<string | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let editing = $state(false);
// New diagram form
let showNewForm = $state(false);
let newName = $state('');
const DIAGRAM_TEMPLATES: Record<string, string> = {
'Class Diagram': `@startuml
title Class Diagram
class Service {
+start()
+stop()
}
class Database {
+query()
+connect()
}
Service --> Database : uses
@enduml`,
'Sequence Diagram': `@startuml
title Sequence Diagram
actor User
participant "Frontend" as FE
participant "Backend" as BE
participant "Database" as DB
User -> FE: action
FE -> BE: request
BE -> DB: query
DB --> BE: result
BE --> FE: response
FE --> User: display
@enduml`,
'State Diagram': `@startuml
title State Diagram
[*] --> Idle
Idle --> Running : start
Running --> Idle : stop
Running --> Error : failure
Error --> Idle : reset
@enduml`,
'Component Diagram': `@startuml
title Component Diagram
package "Frontend" {
[UI Components]
[State Store]
}
package "Backend" {
[API Server]
[Database]
}
[UI Components] --> [State Store]
[State Store] --> [API Server]
[API Server] --> [Database]
@enduml`,
};
let archPath = $derived(`${cwd}/${ARCH_DIR}`);
async function loadDiagrams() {
try {
const entries = await listDirectoryChildren(archPath);
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
} catch {
// Directory might not exist yet
diagrams = [];
}
}
onMount(() => {
loadDiagrams();
});
async function selectDiagram(filePath: string) {
selectedFile = filePath;
loading = true;
error = null;
editing = false;
try {
const content = await readFileContent(filePath);
if (content.type === 'Text') {
pumlSource = content.content;
renderPlantUml(content.content);
} else {
error = 'Not a text file';
}
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
function renderPlantUml(source: string) {
// Encode PlantUML source for the server renderer
const encoded = plantumlEncode(source);
svgUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
}
async function handleSave() {
if (!selectedFile) return;
try {
await writeFileContent(selectedFile, pumlSource);
renderPlantUml(pumlSource);
editing = false;
} catch (e) {
error = String(e);
}
}
async function handleCreate(template: string) {
if (!newName.trim()) return;
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
const filePath = `${archPath}/${fileName}.puml`;
try {
await writeFileContent(filePath, template);
showNewForm = false;
newName = '';
await loadDiagrams();
await selectDiagram(filePath);
} catch (e) {
error = String(e);
}
}
// PlantUML hex encoding — uses the ~h prefix supported by plantuml.com
// See: https://plantuml.com/text-encoding
function plantumlEncode(text: string): string {
const bytes = unescape(encodeURIComponent(text));
let hex = '~h';
for (let i = 0; i < bytes.length; i++) {
hex += bytes.charCodeAt(i).toString(16).padStart(2, '0');
}
return hex;
}
</script>
<div class="architecture-tab">
<div class="arch-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Diagrams</span>
<button class="btn-new" onclick={() => showNewForm = !showNewForm}>
{showNewForm ? '✕' : '+'}
</button>
</div>
{#if showNewForm}
<div class="new-form">
<input
class="new-name-input"
bind:value={newName}
placeholder="Diagram name"
/>
<div class="template-list">
{#each Object.entries(DIAGRAM_TEMPLATES) as [name, template]}
<button
class="template-btn"
onclick={() => handleCreate(template)}
disabled={!newName.trim()}
>{name}</button>
{/each}
</div>
</div>
{/if}
<div class="diagram-list">
{#each diagrams as file (file.path)}
<button
class="diagram-item"
class:active={selectedFile === file.path}
onclick={() => selectDiagram(file.path)}
>
<span class="diagram-icon">📐</span>
<span class="diagram-name">{file.name.replace(/\.(puml|plantuml)$/, '')}</span>
</button>
{/each}
{#if diagrams.length === 0 && !showNewForm}
<div class="empty-hint">
No diagrams yet. The Architect agent creates .puml files in <code>{ARCH_DIR}/</code>
</div>
{/if}
</div>
</div>
<div class="arch-content">
{#if !selectedFile}
<div class="empty-state">
Select a diagram or create a new one
</div>
{:else if loading}
<div class="empty-state">Loading...</div>
{:else if error}
<div class="empty-state error-text">{error}</div>
{:else}
<div class="content-header">
<span class="file-name">{selectedFile?.split('/').pop()}</span>
<button class="btn-toggle-edit" onclick={() => editing = !editing}>
{editing ? 'Preview' : 'Edit'}
</button>
{#if editing}
<button class="btn-save" onclick={handleSave}>Save</button>
{/if}
</div>
{#if editing}
<textarea
class="puml-editor"
bind:value={pumlSource}
></textarea>
{:else if svgUrl}
<div class="diagram-preview">
<img src={svgUrl} alt="PlantUML diagram" class="diagram-img" />
</div>
{/if}
{/if}
</div>
</div>
<style>
.architecture-tab {
display: flex;
height: 100%;
overflow: hidden;
}
.arch-sidebar {
width: 10rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.sidebar-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.btn-new {
padding: 0.125rem 0.375rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-size: 0.7rem;
cursor: pointer;
}
.btn-new:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.new-form {
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.new-name-input {
padding: 0.25rem 0.375rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.7rem;
}
.template-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.template-btn {
padding: 0.2rem 0.375rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.125rem;
color: var(--ctp-subtext0);
font-size: 0.6rem;
text-align: left;
cursor: pointer;
}
.template-btn:hover:not(:disabled) {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.template-btn:disabled {
opacity: 0.4;
cursor: default;
}
.diagram-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.diagram-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.7rem;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.diagram-item:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.diagram-item.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.diagram-icon { font-size: 0.8rem; }
.diagram-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-hint {
padding: 0.5rem;
font-size: 0.65rem;
color: var(--ctp-overlay0);
line-height: 1.4;
}
.empty-hint code {
background: var(--ctp-surface0);
padding: 0.0625rem 0.25rem;
border-radius: 0.125rem;
font-size: 0.6rem;
}
.arch-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
}
.error-text { color: var(--ctp-red); }
.content-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.file-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
flex: 1;
}
.btn-toggle-edit, .btn-save {
padding: 0.2rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-size: 0.65rem;
cursor: pointer;
}
.btn-toggle-edit:hover, .btn-save:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.btn-save {
background: var(--ctp-green);
color: var(--ctp-base);
border-color: var(--ctp-green);
}
.puml-editor {
flex: 1;
padding: 0.5rem;
background: var(--ctp-mantle);
border: none;
color: var(--ctp-text);
font-family: var(--term-font-family, monospace);
font-size: 0.75rem;
line-height: 1.5;
resize: none;
}
.puml-editor:focus { outline: none; }
.diagram-preview {
flex: 1;
overflow: auto;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.5rem;
background: var(--ctp-mantle);
}
.diagram-img {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
}
</style>

View file

@ -0,0 +1,300 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getAuditLog, type AuditEntry, type AuditEventType } from '../../adapters/audit-bridge';
import { getGroupAgents, type BtmsgAgent } from '../../adapters/btmsg-bridge';
import type { GroupId, AgentId } from '../../types/ids';
interface Props {
groupId: GroupId;
}
let { groupId }: Props = $props();
const EVENT_TYPES: AuditEventType[] = [
'prompt_injection',
'wake_event',
'btmsg_sent',
'btmsg_received',
'status_change',
'heartbeat_missed',
'dead_letter',
];
const EVENT_COLORS: Record<string, string> = {
prompt_injection: 'var(--ctp-mauve)',
wake_event: 'var(--ctp-peach)',
btmsg_sent: 'var(--ctp-blue)',
btmsg_received: 'var(--ctp-teal)',
status_change: 'var(--ctp-green)',
heartbeat_missed: 'var(--ctp-yellow)',
dead_letter: 'var(--ctp-red)',
};
const ROLE_COLORS: Record<string, string> = {
manager: 'var(--ctp-mauve)',
architect: 'var(--ctp-blue)',
tester: 'var(--ctp-green)',
reviewer: 'var(--ctp-peach)',
project: 'var(--ctp-text)',
admin: 'var(--ctp-overlay1)',
};
let entries = $state<AuditEntry[]>([]);
let agents = $state<BtmsgAgent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Filters
let enabledTypes = $state<Set<string>>(new Set(EVENT_TYPES));
let selectedAgent = $state<string>('all');
let filteredEntries = $derived.by(() => {
return entries
.filter(e => enabledTypes.has(e.eventType))
.filter(e => selectedAgent === 'all' || e.agentId === selectedAgent)
.slice(0, 200);
});
function agentName(agentId: string): string {
const agent = agents.find(a => a.id === agentId);
return agent?.name ?? agentId;
}
function agentRole(agentId: string): string {
const agent = agents.find(a => a.id === agentId);
return agent?.role ?? 'unknown';
}
function toggleType(type: string) {
const next = new Set(enabledTypes);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
enabledTypes = next;
}
function formatTime(createdAt: string): string {
try {
const d = new Date(createdAt + 'Z');
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return createdAt;
}
}
async function fetchData() {
try {
const [auditData, agentData] = await Promise.all([
getAuditLog(groupId, 200, 0),
getGroupAgents(groupId),
]);
entries = auditData;
agents = agentData;
error = null;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
onMount(() => {
fetchData();
pollTimer = setInterval(fetchData, 5_000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<div class="audit-log-tab">
<div class="audit-toolbar">
<div class="filter-types">
{#each EVENT_TYPES as type}
<button
class="type-chip"
class:active={enabledTypes.has(type)}
style="--chip-color: {EVENT_COLORS[type] ?? 'var(--ctp-overlay1)'}"
onclick={() => toggleType(type)}
>
{type.replace(/_/g, ' ')}
</button>
{/each}
</div>
<select
class="agent-select"
bind:value={selectedAgent}
>
<option value="all">All agents</option>
{#each agents.filter(a => a.id !== 'admin') as agent}
<option value={agent.id}>{agent.name} ({agent.role})</option>
{/each}
</select>
</div>
<div class="audit-entries">
{#if loading}
<div class="audit-empty">Loading audit log...</div>
{:else if error}
<div class="audit-empty audit-error">Error: {error}</div>
{:else if filteredEntries.length === 0}
<div class="audit-empty">No audit events yet</div>
{:else}
{#each filteredEntries as entry (entry.id)}
<div class="audit-entry">
<span class="entry-time">{formatTime(entry.createdAt)}</span>
<span
class="entry-agent"
style="color: {ROLE_COLORS[agentRole(entry.agentId)] ?? 'var(--ctp-text)'}"
>
{agentName(entry.agentId)}
</span>
<span
class="entry-type"
style="--badge-color: {EVENT_COLORS[entry.eventType] ?? 'var(--ctp-overlay1)'}"
>
{entry.eventType.replace(/_/g, ' ')}
</span>
<span class="entry-detail">{entry.detail}</span>
</div>
{/each}
{/if}
</div>
</div>
<style>
.audit-log-tab {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
font-size: 0.8rem;
flex: 1;
}
.audit-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
flex-wrap: wrap;
}
.filter-types {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
flex: 1;
}
.type-chip {
font-size: 0.6rem;
padding: 0.125rem 0.375rem;
border: 1px solid var(--chip-color);
border-radius: 0.25rem;
background: transparent;
color: var(--chip-color);
cursor: pointer;
text-transform: capitalize;
transition: background 0.12s, color 0.12s;
font-family: inherit;
opacity: 0.4;
}
.type-chip.active {
background: color-mix(in srgb, var(--chip-color) 15%, transparent);
opacity: 1;
}
.type-chip:hover {
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
opacity: 1;
}
.agent-select {
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
background: var(--ctp-surface0);
color: var(--ctp-text);
cursor: pointer;
font-family: inherit;
}
.audit-entries {
flex: 1;
overflow-y: auto;
padding: 0.25rem 0;
}
.audit-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
}
.audit-error {
color: var(--ctp-red);
}
.audit-entry {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.1875rem 0.5rem;
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
line-height: 1.4;
}
.audit-entry:hover {
background: var(--ctp-surface0);
}
.entry-time {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--font-mono, monospace);
white-space: nowrap;
flex-shrink: 0;
}
.entry-agent {
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
min-width: 5rem;
}
.entry-type {
font-size: 0.6rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
color: var(--badge-color);
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
text-transform: capitalize;
}
.entry-detail {
font-size: 0.7rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
</style>

View file

@ -0,0 +1,332 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection, crosshairCursor, dropCursor } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
interface Props {
content: string;
lang: string;
onchange?: (content: string) => void;
onsave?: () => void;
onblur?: () => void;
}
let { content, lang, onchange, onsave, onblur }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let view: EditorView | undefined = $state();
// Map lang hint to CodeMirror language extension
async function getLangExtension(lang: string) {
switch (lang) {
case 'javascript':
case 'jsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true });
}
case 'typescript':
case 'tsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true, typescript: true });
}
case 'html':
case 'svelte': {
const { html } = await import('@codemirror/lang-html');
return html();
}
case 'css':
case 'scss':
case 'less': {
const { css } = await import('@codemirror/lang-css');
return css();
}
case 'json': {
const { json } = await import('@codemirror/lang-json');
return json();
}
case 'markdown': {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
case 'python': {
const { python } = await import('@codemirror/lang-python');
return python();
}
case 'rust': {
const { rust } = await import('@codemirror/lang-rust');
return rust();
}
case 'xml': {
const { xml } = await import('@codemirror/lang-xml');
return xml();
}
case 'sql': {
const { sql } = await import('@codemirror/lang-sql');
return sql();
}
case 'yaml': {
const { yaml } = await import('@codemirror/lang-yaml');
return yaml();
}
case 'cpp':
case 'c':
case 'h': {
const { cpp } = await import('@codemirror/lang-cpp');
return cpp();
}
case 'java': {
const { java } = await import('@codemirror/lang-java');
return java();
}
case 'php': {
const { php } = await import('@codemirror/lang-php');
return php();
}
case 'go': {
const { go } = await import('@codemirror/lang-go');
return go();
}
default:
return null;
}
}
// Catppuccin Mocha-inspired theme that reads CSS custom properties
const catppuccinTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--ctp-base)',
color: 'var(--ctp-text)',
fontFamily: 'var(--term-font-family, "JetBrains Mono", monospace)',
fontSize: '0.775rem',
},
'.cm-content': {
caretColor: 'var(--ctp-rosewater)',
lineHeight: '1.55',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--ctp-rosewater)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 25%, transparent)',
},
'.cm-panels': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-text)',
},
'.cm-panels.cm-panels-top': {
borderBottom: '1px solid var(--ctp-surface0)',
},
'.cm-searchMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-yellow) 25%, transparent)',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'color-mix(in srgb, var(--ctp-peach) 30%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
},
'.cm-selectionMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-teal) 15%, transparent)',
},
'.cm-matchingBracket, .cm-nonmatchingBracket': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
outline: '1px solid color-mix(in srgb, var(--ctp-blue) 40%, transparent)',
},
'.cm-gutters': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-overlay0)',
border: 'none',
borderRight: '1px solid var(--ctp-surface0)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
color: 'var(--ctp-text)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--ctp-surface0)',
border: 'none',
color: 'var(--ctp-overlay1)',
},
'.cm-tooltip': {
backgroundColor: 'var(--ctp-surface0)',
color: 'var(--ctp-text)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.25rem',
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'var(--ctp-surface1)',
borderBottomColor: 'var(--ctp-surface1)',
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: 'var(--ctp-surface0)',
borderBottomColor: 'var(--ctp-surface0)',
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
color: 'var(--ctp-text)',
},
},
}, { dark: true });
async function createEditor() {
if (!container) return;
const langExt = await getLangExtension(lang);
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (langExt) extensions.push(langExt);
view = new EditorView({
state: EditorState.create({ doc: content, extensions }),
parent: container,
});
}
onMount(() => {
createEditor();
});
onDestroy(() => {
view?.destroy();
});
// When content prop changes externally (different file loaded), replace editor content
let lastContent = $state(content);
$effect(() => {
const c = content;
if (view && c !== lastContent) {
const currentDoc = view.state.doc.toString();
if (c !== currentDoc) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: c },
});
}
lastContent = c;
}
});
// When lang changes, recreate editor
let lastLang = $state(lang);
$effect(() => {
const l = lang;
if (l !== lastLang && view) {
lastLang = l;
const currentContent = view.state.doc.toString();
view.destroy();
// Small delay to let DOM settle
queueMicrotask(async () => {
const langExt = await getLangExtension(l);
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (langExt) extensions.push(langExt);
view = new EditorView({
state: EditorState.create({ doc: currentContent, extensions }),
parent: container!,
});
});
}
});
export function getContent(): string {
return view?.state.doc.toString() ?? content;
}
</script>
<div class="code-editor" bind:this={container}></div>
<style>
.code-editor {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.code-editor :global(.cm-editor) {
flex: 1;
overflow: hidden;
}
.code-editor :global(.cm-scroller) {
overflow: auto;
}
</style>

View file

@ -0,0 +1,539 @@
<script lang="ts">
import {
getAllGroups,
switchGroup,
getActiveGroupId,
getAllWorkItems,
getActiveProjectId,
setActiveProject,
setActiveTab,
triggerFocusFlash,
emitProjectTabSwitch,
emitTerminalToggle,
addTerminalTab,
} from '../../stores/workspace.svelte';
import { getPluginCommands } from '../../stores/plugins.svelte';
interface Props {
open: boolean;
onclose: () => void;
}
let { open, onclose }: Props = $props();
let query = $state('');
let inputEl: HTMLInputElement | undefined = $state();
let selectedIndex = $state(0);
let showShortcuts = $state(false);
// --- Command definitions ---
interface Command {
id: string;
label: string;
category: string;
shortcut?: string;
action: () => void;
}
let commands = $derived.by((): Command[] => {
const cmds: Command[] = [];
const groups = getAllGroups();
const projects = getAllWorkItems();
const activeGroupId = getActiveGroupId();
const activeProjectId = getActiveProjectId();
// Project focus commands
projects.forEach((p, i) => {
if (i < 5) {
cmds.push({
id: `focus-project-${i + 1}`,
label: `Focus Project ${i + 1}: ${p.name}`,
category: 'Navigation',
shortcut: `Alt+${i + 1}`,
action: () => {
setActiveProject(p.id);
triggerFocusFlash(p.id);
},
});
}
});
// Tab switching commands (for active project)
const tabNames: [string, number][] = [
['Model', 1], ['Docs', 2], ['Context', 3], ['Files', 4],
['SSH', 5], ['Memory', 6], ['Metrics', 7],
];
for (const [name, idx] of tabNames) {
cmds.push({
id: `tab-${name.toLowerCase()}`,
label: `Switch to ${name} Tab`,
category: 'Tabs',
shortcut: `Ctrl+Shift+${idx}`,
action: () => {
if (activeProjectId) {
emitProjectTabSwitch(activeProjectId, idx);
}
},
});
}
// Terminal toggle
cmds.push({
id: 'toggle-terminal',
label: 'Toggle Terminal Section',
category: 'Tabs',
shortcut: 'Ctrl+J',
action: () => {
if (activeProjectId) {
emitTerminalToggle(activeProjectId);
}
},
});
// New terminal tab
cmds.push({
id: 'new-terminal',
label: 'New Terminal Tab',
category: 'Terminal',
action: () => {
if (activeProjectId) {
addTerminalTab(activeProjectId, {
id: crypto.randomUUID(),
title: 'Terminal',
type: 'shell',
});
emitTerminalToggle(activeProjectId); // ensure terminal section is open
}
},
});
// Agent session commands
cmds.push({
id: 'focus-agent',
label: 'Focus Agent Pane',
category: 'Agent',
shortcut: 'Ctrl+Shift+K',
action: () => {
if (activeProjectId) {
emitProjectTabSwitch(activeProjectId, 1); // Model tab
}
},
});
// Group switching commands
for (const group of groups) {
cmds.push({
id: `group-${group.id}`,
label: `Switch Group: ${group.name}`,
category: 'Groups',
shortcut: group.id === activeGroupId ? '(active)' : undefined,
action: () => switchGroup(group.id),
});
}
// Settings toggle
cmds.push({
id: 'toggle-settings',
label: 'Toggle Settings',
category: 'UI',
shortcut: 'Ctrl+,',
action: () => {
setActiveTab('settings');
// Toggle is handled by App.svelte
},
});
// Vi navigation
cmds.push({
id: 'nav-prev-project',
label: 'Focus Previous Project',
category: 'Navigation',
shortcut: 'Ctrl+H',
action: () => {
const idx = projects.findIndex(p => p.id === activeProjectId);
if (idx > 0) {
setActiveProject(projects[idx - 1].id);
triggerFocusFlash(projects[idx - 1].id);
}
},
});
cmds.push({
id: 'nav-next-project',
label: 'Focus Next Project',
category: 'Navigation',
shortcut: 'Ctrl+L',
action: () => {
const idx = projects.findIndex(p => p.id === activeProjectId);
if (idx >= 0 && idx < projects.length - 1) {
setActiveProject(projects[idx + 1].id);
triggerFocusFlash(projects[idx + 1].id);
}
},
});
// Keyboard shortcuts help
cmds.push({
id: 'shortcuts-help',
label: 'Keyboard Shortcuts',
category: 'Help',
shortcut: '?',
action: () => { showShortcuts = true; },
});
// Plugin-registered commands
for (const pc of getPluginCommands()) {
cmds.push({
id: `plugin-${pc.pluginId}-${pc.label.toLowerCase().replace(/\s+/g, '-')}`,
label: pc.label,
category: 'Plugins',
action: pc.callback,
});
}
return cmds;
});
let filtered = $derived.by((): Command[] => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter(c =>
c.label.toLowerCase().includes(q) ||
c.category.toLowerCase().includes(q)
);
});
// Grouped for display
let grouped = $derived.by((): [string, Command[]][] => {
const map = new Map<string, Command[]>();
for (const cmd of filtered) {
const list = map.get(cmd.category) ?? [];
list.push(cmd);
map.set(cmd.category, list);
}
return [...map.entries()];
});
$effect(() => {
if (open) {
query = '';
selectedIndex = 0;
showShortcuts = false;
requestAnimationFrame(() => inputEl?.focus());
}
});
// Reset selection when filter changes
$effect(() => {
void filtered;
selectedIndex = 0;
});
function executeCommand(cmd: Command) {
cmd.action();
onclose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showShortcuts) {
showShortcuts = false;
e.stopPropagation();
} else {
onclose();
}
return;
}
if (showShortcuts) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
scrollToSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToSelected();
} else if (e.key === 'Enter' && filtered.length > 0) {
e.preventDefault();
executeCommand(filtered[selectedIndex]);
}
}
function scrollToSelected() {
requestAnimationFrame(() => {
const el = document.querySelector('.palette-item.selected');
el?.scrollIntoView({ block: 'nearest' });
});
}
// Track flat index across grouped display
function getFlatIndex(groupIdx: number, itemIdx: number): number {
let idx = 0;
for (let g = 0; g < groupIdx; g++) {
idx += grouped[g][1].length;
}
return idx + itemIdx;
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-backdrop" onclick={onclose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette" data-testid="command-palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
{#if showShortcuts}
<div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3>
<button class="shortcuts-close" onclick={() => showShortcuts = false}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="shortcuts-list">
<div class="shortcut-section">
<h4>Global</h4>
<div class="shortcut-row"><kbd>Ctrl+K</kbd><span>Command Palette</span></div>
<div class="shortcut-row"><kbd>Ctrl+,</kbd><span>Toggle Settings</span></div>
<div class="shortcut-row"><kbd>Ctrl+M</kbd><span>Toggle Messages</span></div>
<div class="shortcut-row"><kbd>Ctrl+B</kbd><span>Toggle Sidebar</span></div>
<div class="shortcut-row"><kbd>Escape</kbd><span>Close Panel / Palette</span></div>
</div>
<div class="shortcut-section">
<h4>Project Navigation</h4>
<div class="shortcut-row"><kbd>Alt+1</kbd> <kbd>Alt+5</kbd><span>Focus Project 15</span></div>
<div class="shortcut-row"><kbd>Ctrl+H</kbd><span>Previous Project</span></div>
<div class="shortcut-row"><kbd>Ctrl+L</kbd><span>Next Project</span></div>
<div class="shortcut-row"><kbd>Ctrl+J</kbd><span>Toggle Terminal</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+K</kbd><span>Focus Agent Pane</span></div>
</div>
<div class="shortcut-section">
<h4>Project Tabs</h4>
<div class="shortcut-row"><kbd>Ctrl+Shift+1</kbd><span>Model</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+2</kbd><span>Docs</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+3</kbd><span>Context</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+4</kbd><span>Files</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+5</kbd><span>SSH</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+6</kbd><span>Memory</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+7</kbd><span>Metrics</span></div>
</div>
</div>
{:else}
<input
bind:this={inputEl}
bind:value={query}
class="palette-input"
data-testid="palette-input"
placeholder="Type a command..."
onkeydown={handleKeydown}
/>
<ul class="palette-results">
{#each grouped as [category, items], gi}
<li class="palette-category">{category}</li>
{#each items as cmd, ci}
{@const flatIdx = getFlatIndex(gi, ci)}
<li>
<button
class="palette-item"
class:selected={flatIdx === selectedIndex}
onclick={() => executeCommand(cmd)}
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="cmd-label">{cmd.label}</span>
{#if cmd.shortcut}
<kbd class="cmd-shortcut">{cmd.shortcut}</kbd>
{/if}
</button>
</li>
{/each}
{/each}
{#if filtered.length === 0}
<li class="no-results">No commands match "{query}"</li>
{/if}
</ul>
{/if}
</div>
</div>
{/if}
<style>
.palette-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex;
justify-content: center;
padding-top: 12vh;
z-index: 1000;
}
.palette {
width: 32rem;
max-height: 28rem;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
align-self: flex-start;
}
.palette-input {
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
}
.palette-input::placeholder {
color: var(--ctp-overlay0);
}
.palette-results {
list-style: none;
margin: 0;
padding: 0.25rem;
overflow-y: auto;
}
.palette-category {
padding: 0.375rem 0.75rem 0.125rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
}
.palette-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.4rem 0.75rem;
background: transparent;
border: none;
color: var(--ctp-text);
font-size: 0.82rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background 0.08s;
font-family: inherit;
}
.palette-item:hover,
.palette-item.selected {
background: var(--ctp-surface0);
}
.palette-item.selected {
outline: 1px solid var(--ctp-blue);
outline-offset: -1px;
}
.cmd-label {
flex: 1;
text-align: left;
}
.cmd-shortcut {
font-size: 0.68rem;
color: var(--ctp-overlay1);
background: var(--ctp-surface1);
padding: 0.1rem 0.375rem;
border-radius: 0.1875rem;
font-family: var(--font-mono, monospace);
white-space: nowrap;
margin-left: 0.5rem;
}
.no-results {
padding: 0.75rem;
color: var(--ctp-overlay0);
font-size: 0.85rem;
text-align: center;
}
/* Shortcuts overlay */
.shortcuts-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.shortcuts-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--ctp-text);
}
.shortcuts-close {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
cursor: pointer;
}
.shortcuts-close:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.shortcuts-list {
padding: 0.5rem 1rem;
overflow-y: auto;
}
.shortcut-section {
margin-bottom: 0.75rem;
}
.shortcut-section h4 {
margin: 0 0 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8rem;
color: var(--ctp-subtext1);
}
.shortcut-row kbd {
font-size: 0.68rem;
color: var(--ctp-overlay1);
background: var(--ctp-surface1);
padding: 0.1rem 0.375rem;
border-radius: 0.1875rem;
font-family: var(--font-mono, monospace);
}
.shortcut-row span {
text-align: right;
}
</style>

View file

@ -0,0 +1,690 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
import {
type BtmsgAgent,
type BtmsgMessage,
type BtmsgFeedMessage,
type BtmsgChannel,
type BtmsgChannelMessage,
getGroupAgents,
getHistory,
getAllFeed,
sendMessage,
markRead,
ensureAdmin,
getChannels,
getChannelMessages,
sendChannelMessage,
createChannel,
setAgentStatus,
} from '../../adapters/btmsg-bridge';
const ADMIN_ID = 'admin';
const ROLE_ICONS: Record<string, string> = {
admin: '👤',
manager: '🎯',
architect: '🏗',
tester: '🧪',
reviewer: '🔍',
project: '📦',
};
type ViewMode =
| { type: 'feed' }
| { type: 'dm'; agentId: string; agentName: string }
| { type: 'channel'; channelId: string; channelName: string };
let agents = $state<BtmsgAgent[]>([]);
let channels = $state<BtmsgChannel[]>([]);
let currentView = $state<ViewMode>({ type: 'feed' });
let feedMessages = $state<BtmsgFeedMessage[]>([]);
let dmMessages = $state<BtmsgMessage[]>([]);
let channelMessages = $state<BtmsgChannelMessage[]>([]);
let messageInput = $state('');
let pollTimer: ReturnType<typeof setInterval> | null = null;
let messagesEl: HTMLElement | undefined = $state();
let newChannelName = $state('');
let showNewChannel = $state(false);
let group = $derived(getActiveGroup());
let groupId = $derived(group?.id ?? '');
async function loadData() {
if (!groupId) return;
try {
agents = await getGroupAgents(groupId);
channels = await getChannels(groupId);
} catch (e) {
console.error('[CommsTab] loadData failed:', e);
}
}
async function loadMessages() {
if (!groupId) return;
try {
if (currentView.type === 'feed') {
feedMessages = await getAllFeed(groupId, 100);
} else if (currentView.type === 'dm') {
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100);
await markRead(ADMIN_ID, currentView.agentId);
} else if (currentView.type === 'channel') {
channelMessages = await getChannelMessages(currentView.channelId, 100);
}
} catch (e) {
console.error('[CommsTab] loadMessages failed:', e);
}
}
function scrollToBottom() {
if (messagesEl) {
requestAnimationFrame(() => {
if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
}
$effect(() => {
void currentView;
loadMessages().then(scrollToBottom);
});
$effect(() => {
void groupId;
if (groupId) {
console.log('[CommsTab] groupId:', groupId);
ensureAdmin(groupId).catch((e) => console.error('[CommsTab] ensureAdmin failed:', e));
loadData();
}
});
onMount(() => {
pollTimer = setInterval(() => {
loadData();
loadMessages();
}, 3000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
function selectFeed() {
currentView = { type: 'feed' };
}
function selectDm(agent: BtmsgAgent) {
currentView = { type: 'dm', agentId: agent.id, agentName: agent.name };
}
function selectChannel(channel: BtmsgChannel) {
currentView = { type: 'channel', channelId: channel.id, channelName: channel.name };
}
async function handleSend() {
const text = messageInput.trim();
if (!text) return;
try {
if (currentView.type === 'dm') {
await sendMessage(ADMIN_ID, currentView.agentId, text);
// Auto-wake agent if stopped
const recipient = agents.find(a => a.id === currentView.agentId);
if (recipient && recipient.status !== 'active') {
await setAgentStatus(currentView.agentId, 'active');
emitAgentStart(currentView.agentId);
await pollBtmsg();
}
} else if (currentView.type === 'channel') {
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
} else {
return; // Can't send in feed view
}
messageInput = '';
await loadMessages();
scrollToBottom();
} catch (e) {
console.warn('Failed to send message:', e);
}
}
/** Refresh agent list (reused by poll and wake logic) */
async function pollBtmsg() {
await loadData();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
async function handleCreateChannel() {
const name = newChannelName.trim();
if (!name || !groupId) return;
try {
await createChannel(name, groupId, ADMIN_ID);
newChannelName = '';
showNewChannel = false;
await loadData();
} catch (e) {
console.warn('Failed to create channel:', e);
}
}
function formatTime(ts: string): string {
try {
const d = new Date(ts + 'Z');
return d.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
} catch {
return ts.slice(11, 16);
}
}
function getAgentIcon(role: string): string {
return ROLE_ICONS[role] ?? '🤖';
}
function isActive(view: ViewMode): boolean {
if (currentView.type !== view.type) return false;
if (view.type === 'dm' && currentView.type === 'dm') return view.agentId === currentView.agentId;
if (view.type === 'channel' && currentView.type === 'channel') return view.channelId === currentView.channelId;
return true;
}
</script>
<div class="comms-tab">
<!-- Conversation list -->
<div class="conv-list">
<div class="conv-header">
<span class="conv-header-title">Messages</span>
</div>
<!-- Activity Feed -->
<button
class="conv-item"
class:active={currentView.type === 'feed'}
onclick={selectFeed}
>
<span class="conv-icon">📡</span>
<span class="conv-name">Activity Feed</span>
</button>
<!-- Channels -->
{#if channels.length > 0 || showNewChannel}
<div class="conv-section-title">
<span>Channels</span>
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
</div>
{#each channels as channel (channel.id)}
<button
class="conv-item"
class:active={currentView.type === 'channel' && currentView.channelId === channel.id}
onclick={() => selectChannel(channel)}
>
<span class="conv-icon">#</span>
<span class="conv-name">{channel.name}</span>
<span class="conv-meta">{channel.memberCount}</span>
</button>
{/each}
{#if showNewChannel}
<div class="new-channel">
<input
type="text"
placeholder="channel name"
bind:value={newChannelName}
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
/>
<button onclick={handleCreateChannel}>OK</button>
</div>
{/if}
{:else}
<div class="conv-section-title">
<span>Channels</span>
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
</div>
{#if showNewChannel}
<div class="new-channel">
<input
type="text"
placeholder="channel name"
bind:value={newChannelName}
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
/>
<button onclick={handleCreateChannel}>OK</button>
</div>
{/if}
{/if}
<!-- Direct Messages -->
<div class="conv-section-title">
<span>Direct Messages</span>
</div>
{#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)}
{@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'}
<button
class="conv-item"
class:active={currentView.type === 'dm' && currentView.agentId === agent.id}
onclick={() => selectDm(agent)}
>
<span class="conv-icon">{getAgentIcon(agent.role)}</span>
<span class="conv-name">{agent.name}</span>
<span class="status-dot {statusClass}"></span>
{#if agent.unreadCount > 0}
<span class="unread-badge">{agent.unreadCount}</span>
{/if}
</button>
{/each}
</div>
<!-- Chat area -->
<div class="chat-area">
<div class="chat-header">
{#if currentView.type === 'feed'}
<span class="chat-title">📡 Activity Feed</span>
<span class="chat-subtitle">All agent communication</span>
{:else if currentView.type === 'dm'}
<span class="chat-title">DM with {currentView.agentName}</span>
{:else if currentView.type === 'channel'}
<span class="chat-title"># {currentView.channelName}</span>
{/if}
</div>
<div class="chat-messages" bind:this={messagesEl}>
{#if currentView.type === 'feed'}
{#if feedMessages.length === 0}
<div class="empty-state">No messages yet. Agents haven't started communicating.</div>
{:else}
{#each [...feedMessages].reverse() as msg (msg.id)}
<div class="message feed-message">
<div class="msg-header">
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
<span class="msg-sender">{msg.senderName}</span>
<span class="msg-arrow"></span>
<span class="msg-recipient">{msg.recipientName}</span>
<span class="msg-time">{formatTime(msg.createdAt)}</span>
</div>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{/if}
{:else if currentView.type === 'dm'}
{#if dmMessages.length === 0}
<div class="empty-state">No messages yet. Start the conversation!</div>
{:else}
{#each dmMessages as msg (msg.id)}
{@const isMe = msg.fromAgent === ADMIN_ID}
<div class="message" class:own={isMe}>
<div class="msg-header">
<span class="msg-sender">{isMe ? 'You' : (msg.senderName ?? msg.fromAgent)}</span>
<span class="msg-time">{formatTime(msg.createdAt)}</span>
</div>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{/if}
{:else if currentView.type === 'channel'}
{#if channelMessages.length === 0}
<div class="empty-state">No messages in this channel yet.</div>
{:else}
{#each channelMessages as msg (msg.id)}
{@const isMe = msg.fromAgent === ADMIN_ID}
<div class="message" class:own={isMe}>
<div class="msg-header">
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
<span class="msg-sender">{isMe ? 'You' : msg.senderName}</span>
<span class="msg-time">{formatTime(msg.createdAt)}</span>
</div>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{/if}
{/if}
</div>
{#if currentView.type !== 'feed'}
<div class="chat-input">
<textarea
placeholder={currentView.type === 'dm' ? `Message ${currentView.agentName}...` : `Message #${currentView.channelName}...`}
bind:value={messageInput}
onkeydown={handleKeydown}
rows="1"
></textarea>
<button class="send-btn" onclick={handleSend} disabled={!messageInput.trim()}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{/if}
</div>
</div>
<style>
.comms-tab {
display: flex;
min-width: 36rem;
height: 100%;
}
/* Conversation list */
.conv-list {
width: 13rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.conv-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.conv-header-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-subtext0);
}
.conv-section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
}
.add-btn {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.8rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.add-btn:hover {
color: var(--ctp-text);
}
.conv-item {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.75rem;
cursor: pointer;
text-align: left;
transition: background 0.1s, color 0.1s;
}
.conv-item:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.conv-item.active {
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
color: var(--ctp-text);
}
.conv-icon {
font-size: 0.8rem;
flex-shrink: 0;
width: 1.2rem;
text-align: center;
}
.conv-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-meta {
font-size: 0.55rem;
color: var(--ctp-overlay0);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active {
background: var(--ctp-green);
box-shadow: 0 0 4px var(--ctp-green);
}
.status-dot.sleeping {
background: var(--ctp-yellow);
}
.status-dot.stopped {
background: var(--ctp-overlay0);
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.5rem;
padding: 0 0.3rem;
font-size: 0.55rem;
font-weight: 700;
min-width: 0.9rem;
text-align: center;
flex-shrink: 0;
}
.new-channel {
display: flex;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
}
.new-channel input {
flex: 1;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
color: var(--ctp-text);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
outline: none;
}
.new-channel input:focus {
border-color: var(--ctp-blue);
}
.new-channel button {
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 0.2rem;
font-size: 0.6rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
/* Chat area */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.chat-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-text);
}
.chat-subtitle {
font-size: 0.65rem;
color: var(--ctp-overlay0);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.empty-state {
color: var(--ctp-overlay0);
font-size: 0.75rem;
text-align: center;
padding: 2rem 1rem;
}
.message {
padding: 0.4rem 0.6rem;
border-radius: 0.375rem;
background: var(--ctp-surface0);
max-width: 85%;
}
.message.own {
align-self: flex-end;
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
}
.message.feed-message {
max-width: 100%;
background: transparent;
border-left: 2px solid var(--ctp-surface1);
border-radius: 0;
padding: 0.3rem 0.6rem;
}
.msg-header {
display: flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.15rem;
}
.msg-icon {
font-size: 0.7rem;
}
.msg-sender {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-text);
}
.msg-arrow {
font-size: 0.6rem;
color: var(--ctp-overlay0);
}
.msg-recipient {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-subtext0);
}
.msg-time {
font-size: 0.55rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.msg-content {
font-size: 0.75rem;
color: var(--ctp-subtext0);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
.message.own .msg-content {
color: var(--ctp-text);
}
/* Input */
.chat-input {
display: flex;
gap: 0.4rem;
padding: 0.5rem;
border-top: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.chat-input textarea {
flex: 1;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.75rem;
font-family: inherit;
padding: 0.4rem 0.6rem;
resize: none;
outline: none;
line-height: 1.4;
}
.chat-input textarea:focus {
border-color: var(--ctp-blue);
}
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 0.375rem;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.1s;
}
.send-btn:disabled {
opacity: 0.4;
cursor: default;
}
.send-btn:not(:disabled):hover {
opacity: 0.9;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,253 @@
<script lang="ts">
interface Props {
content: string;
filename: string;
}
let { content, filename }: Props = $props();
/** Parse CSV with basic RFC 4180 quoting support */
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let i = 0;
const len = text.length;
while (i < len) {
const row: string[] = [];
while (i < len) {
let field = '';
if (text[i] === '"') {
// Quoted field
i++; // skip opening quote
while (i < len) {
if (text[i] === '"') {
if (i + 1 < len && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
i++; // skip closing quote
break;
}
} else {
field += text[i];
i++;
}
}
} else {
// Unquoted field
while (i < len && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r') {
field += text[i];
i++;
}
}
row.push(field);
if (i < len && text[i] === ',') {
i++; // skip comma, continue row
} else {
// End of row
if (i < len && text[i] === '\r') i++;
if (i < len && text[i] === '\n') i++;
break;
}
}
// Skip empty trailing rows
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
rows.push(row);
}
}
return rows;
}
/** Detect delimiter: comma vs semicolon vs tab */
function detectDelimiter(text: string): string {
const firstLine = text.split('\n')[0] ?? '';
const commas = (firstLine.match(/,/g) ?? []).length;
const semicolons = (firstLine.match(/;/g) ?? []).length;
const tabs = (firstLine.match(/\t/g) ?? []).length;
if (tabs > commas && tabs > semicolons) return '\t';
if (semicolons > commas) return ';';
return ',';
}
let parsed = $derived.by(() => {
// Normalize delimiter to comma before parsing
const delim = detectDelimiter(content);
const normalized = delim === ',' ? content : content.replaceAll(delim, ',');
return parseCsv(normalized);
});
let headers = $derived(parsed[0] ?? []);
let dataRows = $derived(parsed.slice(1));
let totalRows = $derived(dataRows.length);
// Column count from widest row
let colCount = $derived(Math.max(...parsed.map(r => r.length), 0));
// Sort state
let sortCol = $state<number | null>(null);
let sortAsc = $state(true);
let sortedRows = $derived.by(() => {
if (sortCol === null) return dataRows;
const col = sortCol;
const asc = sortAsc;
return [...dataRows].sort((a, b) => {
const va = a[col] ?? '';
const vb = b[col] ?? '';
// Try numeric comparison
const na = Number(va);
const nb = Number(vb);
if (!isNaN(na) && !isNaN(nb)) {
return asc ? na - nb : nb - na;
}
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
});
});
function toggleSort(col: number) {
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
}
function sortIndicator(col: number): string {
if (sortCol !== col) return '';
return sortAsc ? ' ▲' : ' ▼';
}
</script>
<div class="csv-table-wrapper">
<div class="csv-toolbar">
<span class="csv-info">
{totalRows} row{totalRows !== 1 ? 's' : ''} × {colCount} col{colCount !== 1 ? 's' : ''}
</span>
<span class="csv-filename">{filename}</span>
</div>
<div class="csv-scroll">
<table class="csv-table">
<thead>
<tr>
<th class="row-num">#</th>
{#each headers as header, i}
<th onclick={() => toggleSort(i)} class="sortable">
{header}{sortIndicator(i)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedRows as row, rowIdx (rowIdx)}
<tr>
<td class="row-num">{rowIdx + 1}</td>
{#each { length: colCount } as _, colIdx}
<td>{row[colIdx] ?? ''}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.csv-table-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-base);
}
.csv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.csv-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.csv-filename {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.csv-scroll {
flex: 1;
overflow: auto;
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.725rem;
font-family: var(--term-font-family, monospace);
white-space: nowrap;
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.csv-table th {
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
font-weight: 600;
text-align: left;
padding: 0.3125rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface1);
user-select: none;
}
.csv-table th.sortable {
cursor: pointer;
transition: background 0.12s;
}
.csv-table th.sortable:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.csv-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
}
.csv-table tbody tr:hover td {
background: color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
}
.row-num {
color: var(--ctp-overlay0);
font-size: 0.625rem;
text-align: right;
width: 2.5rem;
min-width: 2.5rem;
padding-right: 0.625rem;
border-right: 1px solid var(--ctp-surface0);
}
thead .row-num {
border-bottom: 1px solid var(--ctp-surface1);
}
</style>

View file

@ -0,0 +1,160 @@
<script lang="ts">
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
let files = $state<MdFileEntry[]>([]);
let selectedPath = $state<string | null>(null);
let loading = $state(false);
let activeProjectId = $derived(getActiveProjectId());
let activeGroup = $derived(getActiveGroup());
let activeProject = $derived(
activeGroup?.projects.find(p => p.id === activeProjectId),
);
$effect(() => {
const project = activeProject;
if (project) {
loadFiles(project.cwd);
} else {
files = [];
selectedPath = null;
}
});
async function loadFiles(cwd: string) {
loading = true;
try {
files = await discoverMarkdownFiles(cwd);
// Auto-select first priority file
const priority = files.find(f => f.priority);
selectedPath = priority?.path ?? files[0]?.path ?? null;
} catch (e) {
console.warn('Failed to discover markdown files:', e);
files = [];
} finally {
loading = false;
}
}
</script>
<div class="docs-tab">
<aside class="file-picker">
<h3 class="picker-title">
{activeProject?.name ?? 'No project'} — Docs
</h3>
{#if loading}
<div class="loading">Scanning...</div>
{:else if files.length === 0}
<div class="empty">No markdown files found</div>
{:else}
<ul class="file-list">
{#each files as file}
<li>
<button
class="file-btn"
class:active={selectedPath === file.path}
class:priority={file.priority}
onclick={() => (selectedPath = file.path)}
>
{file.name}
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<main class="doc-content">
{#if selectedPath}
<MarkdownPane paneId="docs-viewer" filePath={selectedPath} />
{:else}
<div class="no-selection">Select a document from the sidebar</div>
{/if}
</main>
</div>
<style>
.docs-tab {
display: flex;
height: 100%;
overflow: hidden;
min-width: 22em;
}
.file-picker {
width: 14em;
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 0.5rem 0;
}
.picker-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-subtext0);
padding: 0.25rem 0.75rem 0.5rem;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-btn {
display: block;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: color 0.1s, background 0.1s;
}
.file-btn:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.file-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.file-btn.priority {
color: var(--ctp-blue);
}
.file-btn.priority.active {
color: var(--ctp-blue);
}
.doc-content {
flex: 1;
overflow: auto;
}
.loading, .empty, .no-selection {
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.85rem;
padding: 1.25rem;
}
.no-selection {
height: 100%;
}
</style>

View file

@ -0,0 +1,700 @@
<script lang="ts">
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge';
import { getSetting } from '../../adapters/settings-bridge';
import { convertFileSrc } from '@tauri-apps/api/core';
import CodeEditor from './CodeEditor.svelte';
import PdfViewer from './PdfViewer.svelte';
import CsvTable from './CsvTable.svelte';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
// Tree state: expanded dirs and their children
interface TreeNode extends DirEntry {
children?: TreeNode[];
loading?: boolean;
depth: number;
}
// Open file tab
interface FileTab {
path: string;
name: string;
pinned: boolean;
content: FileContent | null;
dirty: boolean;
editContent: string; // current editor content (may differ from saved)
}
let roots = $state<TreeNode[]>([]);
let expandedPaths = $state<Set<string>>(new Set());
// Tab state: open file tabs + active tab
let fileTabs = $state<FileTab[]>([]);
let activeTabPath = $state<string | null>(null);
let fileLoading = $state(false);
// Sidebar state
let sidebarCollapsed = $state(false);
let sidebarWidth = $state(14); // rem
let resizing = $state(false);
// Settings
let saveOnBlur = $state(false);
// Derived: active tab's content
let activeTab = $derived(fileTabs.find(t => t.path === activeTabPath) ?? null);
// Load root directory + settings
$effect(() => {
const dir = cwd;
loadDirectory(dir).then(entries => {
roots = entries.map(e => ({ ...e, depth: 0 }));
});
getSetting('files_save_on_blur').then(v => {
saveOnBlur = v === 'true';
});
});
async function loadDirectory(path: string): Promise<DirEntry[]> {
try {
return await listDirectoryChildren(path);
} catch (e) {
console.warn('Failed to list directory:', e);
return [];
}
}
async function toggleDir(node: TreeNode) {
const path = node.path;
if (expandedPaths.has(path)) {
const next = new Set(expandedPaths);
next.delete(path);
expandedPaths = next;
} else {
if (!node.children) {
node.loading = true;
const entries = await loadDirectory(path);
node.children = entries.map(e => ({ ...e, depth: node.depth + 1 }));
node.loading = false;
}
expandedPaths = new Set([...expandedPaths, path]);
}
}
/** Single click: preview file (replaces existing preview tab) */
async function previewFile(node: TreeNode) {
if (node.is_dir) {
toggleDir(node);
return;
}
// If already open as pinned tab, just focus it
const existing = fileTabs.find(t => t.path === node.path);
if (existing?.pinned) {
activeTabPath = node.path;
return;
}
// Replace any existing preview (unpinned) tab
const previewIdx = fileTabs.findIndex(t => !t.pinned);
const tab: FileTab = {
path: node.path,
name: node.name,
pinned: false,
content: null,
dirty: false,
editContent: '',
};
if (existing) {
// Already the preview tab, just refocus
activeTabPath = node.path;
return;
}
if (previewIdx >= 0) {
fileTabs[previewIdx] = tab;
} else {
fileTabs = [...fileTabs, tab];
}
activeTabPath = node.path;
// Load content — must look up from reactive array, not local reference
fileLoading = true;
try {
const content = await readFileContent(node.path);
const target = fileTabs.find(t => t.path === node.path);
if (target) {
target.content = content;
target.editContent = content.type === 'Text' ? content.content : '';
}
} catch (e) {
const target = fileTabs.find(t => t.path === node.path);
if (target) target.content = { type: 'Binary', message: `Error: ${e}` };
} finally {
fileLoading = false;
}
}
/** Double click: pin the file as a permanent tab */
function pinFile(node: TreeNode) {
if (node.is_dir) return;
const existing = fileTabs.find(t => t.path === node.path);
if (existing) {
existing.pinned = true;
activeTabPath = node.path;
} else {
// Open and pin directly
previewFile(node).then(() => {
const tab = fileTabs.find(t => t.path === node.path);
if (tab) tab.pinned = true;
});
}
}
function closeTab(path: string) {
const tab = fileTabs.find(t => t.path === path);
if (tab?.dirty) {
// Save before closing if dirty
saveTab(tab);
}
fileTabs = fileTabs.filter(t => t.path !== path);
if (activeTabPath === path) {
activeTabPath = fileTabs[fileTabs.length - 1]?.path ?? null;
}
}
function flattenTree(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.is_dir && expandedPaths.has(node.path) && node.children) {
result.push(...flattenTree(node.children));
}
}
return result;
}
let flatNodes = $derived(flattenTree(roots));
function fileIcon(node: TreeNode): string {
if (node.is_dir) return expandedPaths.has(node.path) ? '📂' : '📁';
const ext = node.ext;
if (['ts', 'tsx'].includes(ext)) return '🟦';
if (['js', 'jsx', 'mjs'].includes(ext)) return '🟨';
if (ext === 'rs') return '🦀';
if (ext === 'py') return '🐍';
if (ext === 'svelte') return '🟧';
if (['md', 'markdown'].includes(ext)) return '📝';
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '⚙️';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(ext)) return '🖼️';
if (ext === 'pdf') return '📕';
if (ext === 'csv') return '📊';
if (['css', 'scss', 'less'].includes(ext)) return '🎨';
if (['html', 'htm'].includes(ext)) return '🌐';
return '📄';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isImageExt(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext);
}
function isPdfExt(path: string): boolean {
return path.split('.').pop()?.toLowerCase() === 'pdf';
}
function isCsvLang(lang: string): boolean {
return lang === 'csv';
}
// Editor change handler
function handleEditorChange(tabPath: string, newContent: string) {
const tab = fileTabs.find(t => t.path === tabPath);
if (!tab || tab.content?.type !== 'Text') return;
tab.editContent = newContent;
tab.dirty = newContent !== tab.content.content;
}
// Save a tab to disk
async function saveTab(tab: FileTab) {
if (!tab.dirty || tab.content?.type !== 'Text') return;
try {
await writeFileContent(tab.path, tab.editContent);
// Update the saved content reference
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
tab.dirty = false;
} catch (e) {
console.warn('Failed to save file:', e);
}
}
// Save active tab
function saveActiveTab() {
if (activeTab?.dirty) saveTab(activeTab);
}
// Blur handler: save if setting enabled
function handleEditorBlur(tabPath: string) {
if (!saveOnBlur) return;
const tab = fileTabs.find(t => t.path === tabPath);
if (tab?.dirty) saveTab(tab);
}
// Drag-resize sidebar
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
const startX = e.clientX;
const startWidth = sidebarWidth;
function onMove(ev: MouseEvent) {
const delta = ev.clientX - startX;
const newWidth = startWidth + delta / 16; // convert px to rem (approx)
sidebarWidth = Math.max(8, Math.min(30, newWidth));
}
function onUp() {
resizing = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
</script>
<div class="files-tab">
{#if !sidebarCollapsed}
<aside class="tree-sidebar" style="width: {sidebarWidth}rem">
<div class="tree-header">
<span class="tree-title">Explorer</span>
<button class="collapse-btn" onclick={() => sidebarCollapsed = true} title="Collapse sidebar">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M8 2L4 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="tree-list">
{#each flatNodes as node (node.path)}
<button
class="tree-row"
class:selected={activeTabPath === node.path}
class:dir={node.is_dir}
style="padding-left: {0.5 + node.depth * 1}rem"
onclick={() => previewFile(node)}
ondblclick={() => pinFile(node)}
>
<span class="tree-icon">{fileIcon(node)}</span>
<span class="tree-name">{node.name}</span>
{#if !node.is_dir}
<span class="tree-size">{formatSize(node.size)}</span>
{/if}
{#if node.loading}
<span class="tree-loading"></span>
{/if}
</button>
{/each}
{#if flatNodes.length === 0}
<div class="tree-empty">No files</div>
{/if}
</div>
</aside>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" class:active={resizing} onmousedown={startResize}></div>
{:else}
<button class="expand-btn" onclick={() => sidebarCollapsed = false} title="Show explorer">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
<main class="file-viewer">
<!-- File tabs bar -->
{#if fileTabs.length > 0}
<div class="file-tab-bar">
{#each fileTabs as tab (tab.path)}
<div
class="file-tab"
class:active={activeTabPath === tab.path}
class:preview={!tab.pinned}
onclick={() => activeTabPath = tab.path}
ondblclick={() => { tab.pinned = true; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activeTabPath = tab.path; } }}
role="tab"
tabindex="0"
>
<span class="file-tab-name" class:italic={!tab.pinned}>
{tab.name}{#if tab.dirty}<span class="dirty-dot"></span>{/if}
</span>
<button class="file-tab-close" onclick={(e) => { e.stopPropagation(); closeTab(tab.path); }}>×</button>
</div>
{/each}
</div>
{/if}
<!-- Content area -->
{#if fileLoading && activeTabPath && !activeTab?.content}
<div class="viewer-state">Loading…</div>
{:else if !activeTab}
<div class="viewer-state">Select a file to view</div>
{:else if activeTab.content?.type === 'TooLarge'}
<div class="viewer-state">
<span class="viewer-warning">File too large</span>
<span class="viewer-detail">{formatSize(activeTab.content.size)}</span>
</div>
{:else if activeTab.content?.type === 'Binary'}
{#if isPdfExt(activeTab.path)}
{#key activeTabPath}
<PdfViewer filePath={activeTab.path} />
{/key}
{:else if isImageExt(activeTab.path)}
<div class="viewer-image">
<img src={convertFileSrc(activeTab.path)} alt={activeTab.name} />
</div>
{:else}
<div class="viewer-state">{activeTab.content.message}</div>
{/if}
{:else if activeTab.content?.type === 'Text'}
{#if isCsvLang(activeTab.content.lang)}
{#key activeTabPath}
<CsvTable content={activeTab.editContent} filename={activeTab.name} />
{/key}
{:else}
{#key activeTabPath}
<CodeEditor
content={activeTab.editContent}
lang={activeTab.content.lang}
onchange={(c) => handleEditorChange(activeTab!.path, c)}
onsave={saveActiveTab}
onblur={() => handleEditorBlur(activeTab!.path)}
/>
{/key}
{/if}
{/if}
{#if activeTab}
<div class="viewer-path">
{activeTab.path}
{#if activeTab.dirty}
<span class="path-dirty">(unsaved)</span>
{/if}
</div>
{/if}
</main>
</div>
<style>
.files-tab {
display: flex;
height: 100%;
overflow: hidden;
flex: 1;
}
/* --- Sidebar --- */
.tree-sidebar {
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 8rem;
max-width: 30rem;
}
.tree-header {
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.tree-title {
font-size: 0.675rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay1);
}
.collapse-btn, .expand-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--ctp-overlay1);
cursor: pointer;
padding: 0.125rem;
border-radius: 0.1875rem;
transition: color 0.12s, background 0.12s;
}
.collapse-btn:hover, .expand-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.expand-btn {
flex-shrink: 0;
width: 1.5rem;
height: 100%;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
border-radius: 0;
padding: 0;
}
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
flex-shrink: 0;
transition: background 0.15s;
margin-left: -2px;
margin-right: -2px;
z-index: 1;
}
.resize-handle:hover, .resize-handle.active {
background: var(--ctp-blue);
}
.tree-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
height: 1.5rem;
border: none;
background: transparent;
color: var(--ctp-subtext0);
font-size: 0.7rem;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s;
padding-right: 0.375rem;
}
.tree-row:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.tree-row.selected {
background: color-mix(in srgb, var(--accent, var(--ctp-blue)) 15%, transparent);
color: var(--ctp-text);
}
.tree-row.dir {
font-weight: 500;
}
.tree-icon {
font-size: 0.65rem;
flex-shrink: 0;
width: 1rem;
text-align: center;
}
.tree-name {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.tree-size {
font-size: 0.575rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
margin-left: auto;
}
.tree-loading {
color: var(--ctp-overlay0);
font-size: 0.6rem;
}
.tree-empty {
color: var(--ctp-overlay0);
font-size: 0.7rem;
padding: 1rem;
text-align: center;
}
/* --- File tab bar --- */
.file-tab-bar {
display: flex;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.file-tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.375rem 0.25rem 0.625rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.675rem;
cursor: pointer;
white-space: nowrap;
transition: color 0.1s, background 0.1s;
max-width: 10rem;
}
.file-tab:hover {
background: var(--ctp-surface0);
color: var(--ctp-subtext1);
}
.file-tab.active {
background: var(--ctp-base);
color: var(--ctp-text);
border-bottom-color: var(--accent, var(--ctp-blue));
}
.file-tab-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.file-tab-name.italic {
font-style: italic;
}
.dirty-dot {
display: inline-block;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--ctp-peach);
flex-shrink: 0;
}
.file-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: none;
background: transparent;
color: var(--ctp-overlay0);
font-size: 0.75rem;
cursor: pointer;
border-radius: 0.125rem;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, background 0.1s;
}
.file-tab:hover .file-tab-close,
.file-tab.active .file-tab-close {
opacity: 1;
}
.file-tab-close:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
/* --- Viewer --- */
.file-viewer {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ctp-base);
}
.viewer-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
color: var(--ctp-overlay0);
font-size: 0.8rem;
}
.viewer-warning {
color: var(--ctp-yellow);
font-weight: 600;
}
.viewer-detail {
font-size: 0.7rem;
}
.viewer-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
}
.viewer-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0.25rem;
}
.viewer-path {
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.75rem;
font-size: 0.65rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay0);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.path-dirty {
color: var(--ctp-peach);
font-size: 0.6rem;
}
</style>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { getActiveTab, setActiveTab, type WorkspaceTab } from '../../stores/workspace.svelte';
interface Props {
expanded?: boolean;
ontoggle?: () => void;
}
let { expanded = false, ontoggle }: Props = $props();
const settingsIcon = 'M10.3 2L9.9 4.4a7 7 0 0 0-1.8 1l-2.2-.9-1.7 3 1.8 1.5a7 7 0 0 0 0 2l-1.8 1.5 1.7 3 2.2-.9a7 7 0 0 0 1.8 1L10.3 18h3.4l.4-2.4a7 7 0 0 0 1.8-1l2.2.9 1.7-3-1.8-1.5a7 7 0 0 0 0-2l1.8-1.5-1.7-3-2.2.9a7 7 0 0 0-1.8-1L13.7 2h-3.4zM12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8z';
function handleTabClick(tab: WorkspaceTab) {
if (getActiveTab() === tab && expanded) {
ontoggle?.();
} else {
setActiveTab(tab);
if (!expanded) ontoggle?.();
}
}
</script>
<nav class="sidebar-rail" data-testid="sidebar-rail">
<button
class="rail-btn"
class:active={getActiveTab() === 'comms' && expanded}
onclick={() => handleTabClick('comms')}
title="Messages (Ctrl+M)"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</button>
<div class="rail-spacer"></div>
<button
class="rail-btn"
class:active={getActiveTab() === 'settings' && expanded}
onclick={() => handleTabClick('settings')}
title="Settings (Ctrl+,)"
data-testid="settings-btn"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d={settingsIcon}
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</button>
</nav>
<style>
.sidebar-rail {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.375rem;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
flex-shrink: 0;
width: 2.75rem;
}
.rail-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: transparent;
border: none;
border-radius: 0.375rem;
color: var(--ctp-subtext0);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.rail-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.rail-btn.active {
color: var(--ctp-blue);
background: var(--ctp-surface0);
}
.rail-spacer {
flex: 1;
}
</style>

View file

@ -0,0 +1,433 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
import type { AgentId } from '../../types/ids';
/** Runtime agent status from btmsg database */
let btmsgAgents = $state<BtmsgAgent[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let group = $derived(getActiveGroup());
let agents = $derived(group?.agents ?? []);
let projects = $derived(getEnabledProjects());
let hasAgents = $derived(agents.length > 0 || projects.length > 0);
let collapsed = $state(false);
const ROLE_ICONS: Record<string, string> = {
manager: '🎯',
architect: '🏗',
tester: '🧪',
reviewer: '🔍',
};
const ROLE_LABELS: Record<string, string> = {
manager: 'Manager',
architect: 'Architect',
tester: 'Tester',
reviewer: 'Reviewer',
};
async function pollBtmsg() {
if (!group) return;
try {
btmsgAgents = await getGroupAgents(group.id);
} catch {
// btmsg.db might not exist yet
}
}
onMount(() => {
pollBtmsg();
pollTimer = setInterval(pollBtmsg, 5000); // Poll every 5 seconds
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
function getStatus(agentId: AgentId): GroupAgentStatus {
const btAgent = btmsgAgents.find(a => a.id === agentId);
return (btAgent?.status as GroupAgentStatus) ?? 'stopped';
}
function getUnread(agentId: AgentId): number {
const btAgent = btmsgAgents.find(a => a.id === agentId);
return btAgent?.unreadCount ?? 0;
}
async function toggleAgent(agent: GroupAgentConfig) {
const current = getStatus(agent.id);
const newStatus = current === 'stopped' ? 'active' : 'stopped';
try {
await setAgentStatus(agent.id, newStatus);
await pollBtmsg(); // Refresh immediately
if (newStatus === 'active') {
emitAgentStart(agent.id);
} else {
emitAgentStop(agent.id);
}
} catch (e) {
console.warn('Failed to set agent status:', e);
}
}
</script>
{#if hasAgents}
<div class="group-agents-panel" class:collapsed>
<button
class="panel-header"
onclick={() => collapsed = !collapsed}
>
<span class="header-left">
<span class="header-icon">{collapsed ? '▸' : '▾'}</span>
<span class="header-title">Agents</span>
<span class="agent-count">{agents.length + projects.length}</span>
</span>
<span class="header-right">
{#each agents as agent (agent.id)}
{@const status = getStatus(agent.id)}
<span
class="status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
title="{ROLE_LABELS[agent.role] ?? agent.role}: {status}"
></span>
{/each}
{#if agents.length > 0 && projects.length > 0}
<span class="tier-separator-dot"></span>
{/if}
{#each projects as project (project.id)}
{@const status = getStatus(project.id)}
<span
class="status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
title="{project.name}: {status}"
></span>
{/each}
</span>
</button>
{#if !collapsed}
{#if agents.length > 0}
<div class="tier-label">
<span class="tier-text">Tier 1 — Management</span>
</div>
<div class="agents-grid">
{#each agents as agent (agent.id)}
{@const status = getStatus(agent.id)}
<div
class="agent-card"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
onclick={() => setActiveProject(agent.id)}
role="button"
tabindex="0"
>
<div class="card-top">
<span class="agent-icon">{ROLE_ICONS[agent.role] ?? '🤖'}</span>
<span class="agent-name">{agent.name}</span>
<span
class="card-status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
></span>
</div>
<div class="card-meta">
<span class="agent-role">{ROLE_LABELS[agent.role] ?? agent.role}</span>
{#if agent.model}
<span class="agent-model">{agent.model}</span>
{/if}
{#if getUnread(agent.id) > 0}
<span class="unread-badge">{getUnread(agent.id)}</span>
{/if}
</div>
<div class="card-actions">
<button
class="action-btn"
class:start={status === 'stopped'}
class:stop={status !== 'stopped'}
onclick={(e: MouseEvent) => { e.stopPropagation(); toggleAgent(agent); }}
title={status === 'stopped' ? 'Start agent' : 'Stop agent'}
>
{status === 'stopped' ? '▶' : '■'}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if projects.length > 0}
<div class="tier-divider"></div>
<div class="tier-label">
<span class="tier-text">Tier 2 — Execution</span>
</div>
<div class="agents-grid">
{#each projects as project (project.id)}
{@const status = getStatus(project.id)}
<div
class="agent-card tier2"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
onclick={() => setActiveProject(project.id)}
role="button"
tabindex="0"
>
<div class="card-top">
<span class="agent-icon">{project.icon}</span>
<span class="agent-name">{project.name}</span>
<span
class="card-status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
></span>
</div>
<div class="card-meta">
<span class="agent-role">Project</span>
{#if getUnread(project.id) > 0}
<span class="unread-badge">{getUnread(project.id)}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
<style>
.group-agents-panel {
flex-shrink: 0;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.7rem;
cursor: pointer;
transition: color 0.1s;
}
.panel-header:hover {
color: var(--ctp-text);
}
.header-left {
display: flex;
align-items: center;
gap: 0.3rem;
}
.header-icon {
font-size: 0.6rem;
width: 0.6rem;
}
.header-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.6rem;
}
.agent-count {
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
border-radius: 0.5rem;
padding: 0 0.3rem;
font-size: 0.55rem;
font-weight: 600;
}
.header-right {
display: flex;
gap: 0.25rem;
}
.status-dot, .card-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.status-dot.active, .card-status-dot.active {
background: var(--ctp-green);
box-shadow: 0 0 4px var(--ctp-green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.sleeping, .card-status-dot.sleeping {
background: var(--ctp-yellow);
animation: pulse 3s ease-in-out infinite;
}
.status-dot.stopped, .card-status-dot.stopped {
background: var(--ctp-overlay0);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.tier-label {
padding: 0.1rem 0.5rem;
}
.tier-text {
font-size: 0.5rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ctp-overlay0);
}
.tier-divider {
height: 1px;
margin: 0.15rem 0.5rem;
background: var(--ctp-surface0);
}
.tier-separator-dot {
width: 1px;
height: 6px;
background: var(--ctp-surface1);
margin: 0 0.1rem;
}
.agents-grid {
display: flex;
gap: 0.25rem;
padding: 0.1rem 0.5rem 0.25rem;
overflow-x: auto;
}
.agent-card {
flex: 0 0 auto;
min-width: 7rem;
padding: 0.3rem 0.4rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
transition: border-color 0.15s, background 0.15s;
cursor: pointer;
}
.agent-card:hover {
border-color: var(--ctp-surface1);
}
.agent-card.active {
border-color: var(--ctp-green);
background: color-mix(in srgb, var(--ctp-green) 5%, var(--ctp-base));
}
.agent-card.sleeping {
border-color: var(--ctp-yellow);
background: color-mix(in srgb, var(--ctp-yellow) 5%, var(--ctp-base));
}
.card-top {
display: flex;
align-items: center;
gap: 0.25rem;
}
.agent-icon {
font-size: 0.75rem;
}
.agent-name {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
display: flex;
gap: 0.3rem;
margin-top: 0.15rem;
}
.agent-role {
font-size: 0.55rem;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.agent-model {
font-size: 0.55rem;
color: var(--ctp-overlay0);
font-family: monospace;
}
.card-actions {
margin-top: 0.2rem;
display: flex;
justify-content: flex-end;
}
.action-btn {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
border-radius: 0.15rem;
cursor: pointer;
transition: all 0.1s;
}
.action-btn.start:hover {
background: var(--ctp-green);
color: var(--ctp-base);
border-color: var(--ctp-green);
}
.action-btn.stop:hover {
background: var(--ctp-red);
color: var(--ctp-base);
border-color: var(--ctp-red);
}
.agent-card.tier2 {
min-width: 6rem;
}
.agent-card.tier2 .card-actions {
display: none;
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.5rem;
padding: 0 0.25rem;
font-size: 0.5rem;
font-weight: 700;
min-width: 0.75rem;
text-align: center;
}
</style>

View file

@ -0,0 +1,375 @@
<script lang="ts">
import { getDefaultAdapter, getAvailableAdapters, type MemoryAdapter, type MemoryNode } from '../../adapters/memory-adapter';
let adapter = $state<MemoryAdapter | undefined>(undefined);
let adapterName = $state('');
let nodes = $state<MemoryNode[]>([]);
let searchQuery = $state('');
let loading = $state(false);
let error = $state('');
let total = $state(0);
let selectedNode = $state<MemoryNode | null>(null);
$effect(() => {
adapter = getDefaultAdapter();
adapterName = adapter?.name ?? '';
if (adapter) {
loadNodes();
}
});
async function loadNodes() {
if (!adapter) return;
loading = true;
error = '';
try {
const result = await adapter.list({ limit: 50 });
nodes = result.nodes;
total = result.total;
} catch (e) {
error = `Failed to load: ${e}`;
nodes = [];
} finally {
loading = false;
}
}
async function handleSearch() {
if (!adapter || !searchQuery.trim()) {
if (adapter) loadNodes();
return;
}
loading = true;
error = '';
try {
const result = await adapter.search(searchQuery.trim(), { limit: 50 });
nodes = result.nodes;
total = result.total;
} catch (e) {
error = `Search failed: ${e}`;
} finally {
loading = false;
}
}
function selectNode(node: MemoryNode) {
selectedNode = selectedNode?.id === node.id ? null : node;
}
function clearSearch() {
searchQuery = '';
selectedNode = null;
loadNodes();
}
</script>
<div class="memories-tab">
{#if !adapter}
<div class="no-adapter">
<div class="no-adapter-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 8v4m0 4h.01"></path>
</svg>
</div>
<p class="no-adapter-title">No memory adapter configured</p>
<p class="no-adapter-hint">Register a memory adapter (e.g. Memora) to browse knowledge here.</p>
</div>
{:else}
<div class="mem-header">
<h3>{adapterName}</h3>
<span class="mem-count">{total} memories</span>
<div class="mem-adapters">
{#each getAvailableAdapters() as a (a.name)}
<button
class="adapter-btn"
class:active={a.name === adapterName}
onclick={() => { adapter = a; adapterName = a.name; loadNodes(); }}
>{a.name}</button>
{/each}
</div>
</div>
<div class="mem-search">
<input
type="text"
class="search-input"
placeholder="Search memories…"
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
{#if searchQuery}
<button class="clear-btn" onclick={clearSearch}>Clear</button>
{/if}
</div>
{#if error}
<div class="mem-error">{error}</div>
{/if}
<div class="mem-list">
{#if loading}
<div class="mem-state">Loading…</div>
{:else if nodes.length === 0}
<div class="mem-state">No memories found</div>
{:else}
{#each nodes as node (node.id)}
<button class="mem-card" class:expanded={selectedNode?.id === node.id} onclick={() => selectNode(node)}>
<div class="mem-card-header">
<span class="mem-id">#{node.id}</span>
<div class="mem-tags">
{#each node.tags.slice(0, 4) as tag}
<span class="mem-tag">{tag}</span>
{/each}
{#if node.tags.length > 4}
<span class="mem-tag-more">+{node.tags.length - 4}</span>
{/if}
</div>
</div>
<div class="mem-card-content" class:truncated={selectedNode?.id !== node.id}>
{node.content}
</div>
{#if selectedNode?.id === node.id && node.metadata}
<div class="mem-card-meta">
<pre>{JSON.stringify(node.metadata, null, 2)}</pre>
</div>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.memories-tab {
display: flex;
flex-direction: column;
height: 100%;
background: var(--ctp-base);
color: var(--ctp-text);
overflow: hidden;
}
.no-adapter {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
.no-adapter-icon {
color: var(--ctp-overlay0);
}
.no-adapter-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--ctp-subtext1);
margin: 0;
}
.no-adapter-hint {
font-size: 0.7rem;
color: var(--ctp-overlay0);
margin: 0;
}
.mem-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.mem-header h3 {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-blue);
margin: 0;
text-transform: capitalize;
}
.mem-count {
font-size: 0.625rem;
color: var(--ctp-overlay0);
}
.mem-adapters {
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.adapter-btn {
padding: 0.125rem 0.375rem;
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.6rem;
cursor: pointer;
transition: all 0.12s;
}
.adapter-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
border-color: var(--ctp-blue);
}
.mem-search {
display: flex;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
}
.search-input:focus {
outline: none;
border-color: var(--ctp-blue);
}
.clear-btn {
background: var(--ctp-surface0);
border: none;
color: var(--ctp-subtext0);
font-size: 0.65rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
.clear-btn:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.mem-error {
padding: 0.5rem 0.75rem;
color: var(--ctp-red);
font-size: 0.7rem;
flex-shrink: 0;
}
.mem-list {
flex: 1;
overflow-y: auto;
padding: 0.375rem 0.5rem;
}
.mem-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
.mem-card {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
background: var(--ctp-surface0);
border: 1px solid transparent;
border-radius: 0.25rem;
padding: 0.375rem 0.5rem;
margin-bottom: 0.25rem;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.mem-card:hover {
background: var(--ctp-surface1);
}
.mem-card.expanded {
border-color: var(--ctp-blue);
}
.mem-card-header {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.mem-id {
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
flex-shrink: 0;
}
.mem-tags {
display: flex;
gap: 0.1875rem;
flex-wrap: wrap;
overflow: hidden;
}
.mem-tag {
font-size: 0.55rem;
padding: 0.0625rem 0.25rem;
border-radius: 0.1875rem;
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
color: var(--ctp-blue);
}
.mem-tag-more {
font-size: 0.55rem;
color: var(--ctp-overlay0);
}
.mem-card-content {
font-size: 0.7rem;
line-height: 1.4;
color: var(--ctp-subtext0);
white-space: pre-wrap;
word-break: break-word;
}
.mem-card-content.truncated {
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.mem-card-meta {
margin-top: 0.375rem;
padding-top: 0.375rem;
border-top: 1px solid var(--ctp-surface1);
}
.mem-card-meta pre {
font-size: 0.6rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay1);
white-space: pre-wrap;
margin: 0;
max-height: 10rem;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,808 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { ProjectConfig } from '../../types/groups';
import type { ProjectHealth } from '../../stores/health.svelte';
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
import { getAgentSession } from '../../stores/agents.svelte';
import { listTasks, type Task } from '../../adapters/bttask-bridge';
import { invoke } from '@tauri-apps/api/core';
interface Props {
project: ProjectConfig;
groupId?: GroupId;
}
let { project, groupId }: Props = $props();
// --- View toggle ---
type MetricsView = 'live' | 'history';
let activeView = $state<MetricsView>('live');
// --- Live view state ---
let taskCounts = $state<Record<string, number>>({ todo: 0, progress: 0, review: 0, done: 0, blocked: 0 });
let taskPollTimer: ReturnType<typeof setInterval> | null = null;
// --- History view state ---
interface MetricPoint {
endTime: number;
costUsd: number;
peakTokens: number;
turnCount: number;
toolCallCount: number;
durationMin: number;
}
let historyData = $state<MetricPoint[]>([]);
let historyLoading = $state(false);
type HistoryMetric = 'cost' | 'tokens' | 'turns' | 'tools' | 'duration';
let selectedHistoryMetric = $state<HistoryMetric>('cost');
// --- Derived live data ---
let health = $derived(getProjectHealth(project.id));
let aggregates = $derived(getHealthAggregates());
let allHealth = $derived(getAllProjectHealth());
let session = $derived.by(() => {
if (!health?.sessionId) return undefined;
return getAgentSession(health.sessionId);
});
// --- Task polling ---
async function fetchTaskCounts() {
if (!groupId) return;
try {
const tasks = await listTasks(groupId);
const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 };
for (const t of tasks) {
if (counts[t.status] !== undefined) counts[t.status]++;
}
taskCounts = counts;
} catch {
// bttask db may not exist yet
}
}
// --- History loading ---
async function loadHistory() {
historyLoading = true;
try {
const metrics = await invoke<Array<{
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;
}>>('session_metrics_load', { projectId: project.id, limit: 50 });
historyData = metrics.reverse().map(m => ({
endTime: m.end_time,
costUsd: m.cost_usd,
peakTokens: m.peak_tokens,
turnCount: m.turn_count,
toolCallCount: m.tool_call_count,
durationMin: Math.max(0.1, (m.end_time - m.start_time) / 60_000),
}));
} catch (e) {
console.warn('Failed to load metrics history:', e);
historyData = [];
}
historyLoading = false;
}
// --- SVG sparkline helpers ---
function sparklinePath(points: number[], width: number, height: number): string {
if (points.length < 2) return '';
const max = Math.max(...points, 0.001);
const step = width / (points.length - 1);
return points
.map((v, i) => {
const x = i * step;
const y = height - (v / max) * height;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
}
function getHistoryValues(metric: HistoryMetric): number[] {
switch (metric) {
case 'cost': return historyData.map(d => d.costUsd);
case 'tokens': return historyData.map(d => d.peakTokens);
case 'turns': return historyData.map(d => d.turnCount);
case 'tools': return historyData.map(d => d.toolCallCount);
case 'duration': return historyData.map(d => d.durationMin);
}
}
function formatMetricValue(metric: HistoryMetric, value: number): string {
switch (metric) {
case 'cost': return `$${value.toFixed(4)}`;
case 'tokens': return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : `${value}`;
case 'turns': return `${value}`;
case 'tools': return `${value}`;
case 'duration': return `${value.toFixed(1)}m`;
}
}
const METRIC_LABELS: Record<HistoryMetric, string> = {
cost: 'Cost (USD)',
tokens: 'Peak Tokens',
turns: 'Turns',
tools: 'Tool Calls',
duration: 'Duration',
};
const METRIC_COLORS: Record<HistoryMetric, string> = {
cost: 'var(--ctp-yellow)',
tokens: 'var(--ctp-blue)',
turns: 'var(--ctp-green)',
tools: 'var(--ctp-mauve)',
duration: 'var(--ctp-peach)',
};
// --- Formatting helpers ---
function fmtBurnRate(rate: number): string {
if (rate === 0) return '$0/hr';
if (rate < 0.01) return `$${(rate * 100).toFixed(1)}c/hr`;
return `$${rate.toFixed(2)}/hr`;
}
function fmtPressure(p: number | null): string {
if (p === null) return '—';
return `${Math.round(p * 100)}%`;
}
function pressureColor(p: number | null): string {
if (p === null) return 'var(--ctp-overlay0)';
if (p > 0.9) return 'var(--ctp-red)';
if (p > 0.75) return 'var(--ctp-peach)';
if (p > 0.5) return 'var(--ctp-yellow)';
return 'var(--ctp-green)';
}
function stateColor(state: string): string {
switch (state) {
case 'running': return 'var(--ctp-green)';
case 'idle': return 'var(--ctp-overlay1)';
case 'stalled': return 'var(--ctp-peach)';
default: return 'var(--ctp-overlay0)';
}
}
function fmtIdle(ms: number): string {
if (ms === 0) return '—';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
return `${Math.floor(min / 60)}h ${min % 60}m`;
}
// --- Lifecycle ---
onMount(() => {
fetchTaskCounts();
taskPollTimer = setInterval(fetchTaskCounts, 10_000);
});
onDestroy(() => {
if (taskPollTimer) clearInterval(taskPollTimer);
});
// Load history when switching to history view
$effect(() => {
if (activeView === 'history' && historyData.length === 0) {
loadHistory();
}
});
</script>
<div class="metrics-panel">
<!-- View tabs -->
<div class="view-tabs">
<button
class="vtab"
class:active={activeView === 'live'}
onclick={() => activeView = 'live'}
>Live</button>
<button
class="vtab"
class:active={activeView === 'history'}
onclick={() => activeView = 'history'}
>History</button>
</div>
{#if activeView === 'live'}
<div class="live-view">
<!-- Aggregates bar -->
<div class="agg-bar">
<div class="agg-item">
<span class="agg-label">Fleet</span>
<span class="agg-badges">
{#if aggregates.running > 0}
<span class="agg-badge" style="color: var(--ctp-green)">{aggregates.running} running</span>
{/if}
{#if aggregates.idle > 0}
<span class="agg-badge" style="color: var(--ctp-overlay1)">{aggregates.idle} idle</span>
{/if}
{#if aggregates.stalled > 0}
<span class="agg-badge" style="color: var(--ctp-peach)">{aggregates.stalled} stalled</span>
{/if}
</span>
</div>
<div class="agg-item">
<span class="agg-label">Burn</span>
<span class="agg-value" style="color: var(--ctp-mauve)">{fmtBurnRate(aggregates.totalBurnRatePerHour)}</span>
</div>
</div>
<!-- This project's health -->
{#if health}
<div class="section-header">This Project</div>
<div class="health-grid">
<div class="health-card">
<span class="hc-label">Status</span>
<span class="hc-value" style="color: {stateColor(health.activityState)}">
{health.activityState}
{#if health.activeTool}
<span class="hc-tool">({health.activeTool})</span>
{/if}
</span>
</div>
<div class="health-card">
<span class="hc-label">Burn Rate</span>
<span class="hc-value" style="color: var(--ctp-mauve)">{fmtBurnRate(health.burnRatePerHour)}</span>
</div>
<div class="health-card">
<span class="hc-label">Context</span>
<span class="hc-value" style="color: {pressureColor(health.contextPressure)}">{fmtPressure(health.contextPressure)}</span>
</div>
<div class="health-card">
<span class="hc-label">Idle</span>
<span class="hc-value">{fmtIdle(health.idleDurationMs)}</span>
</div>
{#if session}
<div class="health-card">
<span class="hc-label">Tokens</span>
<span class="hc-value" style="color: var(--ctp-blue)">{(session.inputTokens + session.outputTokens).toLocaleString()}</span>
</div>
<div class="health-card">
<span class="hc-label">Cost</span>
<span class="hc-value" style="color: var(--ctp-yellow)">${session.costUsd.toFixed(4)}</span>
</div>
<div class="health-card">
<span class="hc-label">Turns</span>
<span class="hc-value">{session.numTurns}</span>
</div>
<div class="health-card">
<span class="hc-label">Model</span>
<span class="hc-value hc-model">{session.model ?? '—'}</span>
</div>
{/if}
{#if health.fileConflictCount > 0}
<div class="health-card health-warn">
<span class="hc-label">Conflicts</span>
<span class="hc-value" style="color: var(--ctp-red)">{health.fileConflictCount}</span>
</div>
{/if}
{#if health.externalConflictCount > 0}
<div class="health-card health-warn">
<span class="hc-label">External</span>
<span class="hc-value" style="color: var(--ctp-peach)">{health.externalConflictCount}</span>
</div>
{/if}
{#if health.attentionScore > 0}
<div class="health-card health-attention">
<span class="hc-label">Attention</span>
<span class="hc-value" style="color: {health.attentionScore >= 90 ? 'var(--ctp-red)' : health.attentionScore >= 70 ? 'var(--ctp-peach)' : 'var(--ctp-yellow)'}">{health.attentionScore}</span>
{#if health.attentionReason}
<span class="hc-reason">{health.attentionReason}</span>
{/if}
</div>
{/if}
</div>
{:else}
<div class="empty-state">No health data — start an agent session</div>
{/if}
<!-- Task board summary -->
{#if groupId}
<div class="section-header">Task Board</div>
<div class="task-summary">
{#each ['todo', 'progress', 'review', 'done', 'blocked'] as status}
<div class="task-col" class:task-col-blocked={status === 'blocked' && taskCounts[status] > 0}>
<span class="tc-count" class:tc-zero={taskCounts[status] === 0}>{taskCounts[status]}</span>
<span class="tc-label">{status === 'progress' ? 'In Prog' : status === 'todo' ? 'To Do' : status.charAt(0).toUpperCase() + status.slice(1)}</span>
</div>
{/each}
</div>
{/if}
<!-- Attention queue (cross-project) -->
{#if allHealth.filter(h => h.attentionScore > 0).length > 0}
<div class="section-header">Attention Queue</div>
<div class="attention-list">
{#each allHealth.filter(h => h.attentionScore > 0).slice(0, 5) as item}
<div class="attention-row">
<span class="ar-score" style="color: {item.attentionScore >= 90 ? 'var(--ctp-red)' : item.attentionScore >= 70 ? 'var(--ctp-peach)' : 'var(--ctp-yellow)'}">{item.attentionScore}</span>
<span class="ar-id">{item.projectId.slice(0, 8)}</span>
<span class="ar-reason">{item.attentionReason ?? '—'}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- History view -->
<div class="history-view">
{#if historyLoading}
<div class="empty-state">Loading history...</div>
{:else if historyData.length === 0}
<div class="empty-state">No session history for this project</div>
{:else}
<!-- Metric selector -->
<div class="metric-tabs">
{#each (['cost', 'tokens', 'turns', 'tools', 'duration'] as const) as metric}
<button
class="mtab"
class:active={selectedHistoryMetric === metric}
onclick={() => selectedHistoryMetric = metric}
style={selectedHistoryMetric === metric ? `border-bottom-color: ${METRIC_COLORS[metric]}` : ''}
>{METRIC_LABELS[metric]}</button>
{/each}
</div>
<!-- Sparkline chart -->
{@const values = getHistoryValues(selectedHistoryMetric)}
{@const maxVal = Math.max(...values, 0.001)}
{@const minVal = Math.min(...values)}
{@const lastVal = values[values.length - 1] ?? 0}
{@const avgVal = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0}
<div class="sparkline-container">
<svg viewBox="0 0 400 120" class="sparkline-svg" preserveAspectRatio="none">
<!-- Grid lines -->
<line x1="0" y1="30" x2="400" y2="30" stroke="var(--ctp-surface0)" stroke-width="0.5" />
<line x1="0" y1="60" x2="400" y2="60" stroke="var(--ctp-surface0)" stroke-width="0.5" />
<line x1="0" y1="90" x2="400" y2="90" stroke="var(--ctp-surface0)" stroke-width="0.5" />
<!-- Area fill -->
<path
d="{sparklinePath(values, 400, 110)} L400,110 L0,110 Z"
fill={METRIC_COLORS[selectedHistoryMetric]}
opacity="0.08"
/>
<!-- Line -->
<path
d={sparklinePath(values, 400, 110)}
fill="none"
stroke={METRIC_COLORS[selectedHistoryMetric]}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Last point dot -->
{#if values.length > 0}
{@const lastX = 400}
{@const lastY = 110 - (lastVal / maxVal) * 110}
<circle cx={lastX} cy={lastY} r="3" fill={METRIC_COLORS[selectedHistoryMetric]} />
{/if}
</svg>
</div>
<!-- Stats row -->
<div class="stats-row">
<div class="stat">
<span class="stat-label">Last</span>
<span class="stat-value" style="color: {METRIC_COLORS[selectedHistoryMetric]}">{formatMetricValue(selectedHistoryMetric, lastVal)}</span>
</div>
<div class="stat">
<span class="stat-label">Avg</span>
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, avgVal)}</span>
</div>
<div class="stat">
<span class="stat-label">Max</span>
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, maxVal)}</span>
</div>
<div class="stat">
<span class="stat-label">Min</span>
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, minVal)}</span>
</div>
<div class="stat">
<span class="stat-label">Sessions</span>
<span class="stat-value">{historyData.length}</span>
</div>
</div>
<!-- Session table -->
<div class="section-header">Recent Sessions</div>
<div class="session-table">
<div class="st-header">
<span class="st-col st-col-time">Time</span>
<span class="st-col st-col-dur">Dur</span>
<span class="st-col st-col-cost">Cost</span>
<span class="st-col st-col-tok">Tokens</span>
<span class="st-col st-col-turns">Turns</span>
<span class="st-col st-col-tools">Tools</span>
</div>
{#each historyData.slice(-10).reverse() as row}
<div class="st-row">
<span class="st-col st-col-time">{new Date(row.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span class="st-col st-col-dur">{row.durationMin.toFixed(0)}m</span>
<span class="st-col st-col-cost" style="color: var(--ctp-yellow)">${row.costUsd.toFixed(3)}</span>
<span class="st-col st-col-tok">{row.peakTokens >= 1000 ? `${(row.peakTokens / 1000).toFixed(0)}K` : row.peakTokens}</span>
<span class="st-col st-col-turns">{row.turnCount}</span>
<span class="st-col st-col-tools">{row.toolCallCount}</span>
</div>
{/each}
</div>
<button class="refresh-btn" onclick={loadHistory}>Refresh</button>
{/if}
</div>
{/if}
</div>
<style>
.metrics-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.8rem;
color: var(--ctp-text);
}
/* --- View tabs --- */
.view-tabs {
display: flex;
gap: 0;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.vtab {
flex: 1;
padding: 0.375rem 0;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.vtab:hover { color: var(--ctp-subtext1); }
.vtab.active {
color: var(--ctp-text);
border-bottom-color: var(--accent, var(--ctp-blue));
font-weight: 600;
}
/* --- Live view --- */
.live-view {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.agg-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
background: var(--ctp-mantle);
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
}
.agg-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.agg-label {
color: var(--ctp-overlay0);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agg-badges {
display: flex;
gap: 0.375rem;
}
.agg-badge {
font-size: 0.7rem;
font-weight: 500;
}
.agg-value {
font-size: 0.75rem;
font-weight: 600;
}
.section-header {
color: var(--ctp-overlay0);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.25rem 0 0.125rem;
font-weight: 600;
}
/* --- Health grid --- */
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
gap: 0.375rem;
}
.health-card {
padding: 0.375rem 0.5rem;
background: var(--ctp-mantle);
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.health-warn { border-color: color-mix(in srgb, var(--ctp-peach) 30%, var(--ctp-surface0)); }
.health-attention { border-color: color-mix(in srgb, var(--ctp-yellow) 30%, var(--ctp-surface0)); }
.hc-label {
font-size: 0.6rem;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hc-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-text);
}
.hc-tool {
font-size: 0.65rem;
font-weight: 400;
color: var(--ctp-overlay1);
}
.hc-model {
font-size: 0.6rem;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hc-reason {
font-size: 0.6rem;
color: var(--ctp-subtext0);
font-weight: 400;
}
/* --- Task summary --- */
.task-summary {
display: flex;
gap: 0.25rem;
}
.task-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.375rem 0.25rem;
background: var(--ctp-mantle);
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
}
.task-col-blocked {
border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface0));
}
.tc-count {
font-size: 1rem;
font-weight: 700;
color: var(--ctp-text);
line-height: 1;
}
.tc-zero { color: var(--ctp-overlay0); }
.tc-label {
font-size: 0.55rem;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* --- Attention queue --- */
.attention-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.attention-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-mantle);
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
font-size: 0.7rem;
}
.ar-score { font-weight: 700; min-width: 1.5rem; }
.ar-id { color: var(--ctp-overlay1); font-family: monospace; font-size: 0.65rem; }
.ar-reason { color: var(--ctp-subtext0); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- History view --- */
.history-view {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.metric-tabs {
display: flex;
gap: 0;
flex-shrink: 0;
}
.mtab {
flex: 1;
padding: 0.25rem 0;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.6rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
cursor: pointer;
transition: color 0.12s;
}
.mtab:hover { color: var(--ctp-subtext1); }
.mtab.active { color: var(--ctp-text); font-weight: 600; }
/* --- Sparkline --- */
.sparkline-container {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
padding: 0.5rem;
}
.sparkline-svg {
width: 100%;
height: 7.5rem;
}
/* --- Stats row --- */
.stats-row {
display: flex;
gap: 0.25rem;
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.0625rem;
padding: 0.25rem;
background: var(--ctp-mantle);
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
}
.stat-label {
font-size: 0.55rem;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-subtext0);
}
/* --- Session table --- */
.session-table {
display: flex;
flex-direction: column;
gap: 1px;
font-size: 0.65rem;
font-family: monospace;
}
.st-header {
display: flex;
gap: 0;
padding: 0.25rem 0;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.55rem;
font-family: system-ui, sans-serif;
}
.st-row {
display: flex;
gap: 0;
padding: 0.1875rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface0) 40%, transparent);
color: var(--ctp-subtext0);
}
.st-row:hover { background: color-mix(in srgb, var(--ctp-surface0) 30%, transparent); }
.st-col { text-align: right; padding: 0 0.25rem; }
.st-col-time { flex: 1.2; text-align: left; }
.st-col-dur { flex: 0.8; }
.st-col-cost { flex: 1; }
.st-col-tok { flex: 1; }
.st-col-turns { flex: 0.7; }
.st-col-tools { flex: 0.7; }
/* --- Misc --- */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
.refresh-btn {
align-self: center;
padding: 0.25rem 0.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-size: 0.65rem;
cursor: pointer;
transition: background 0.12s;
}
.refresh-btn:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
</style>

View file

@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
// Test the pure utility functions used in MetricsPanel
// These are extracted for testability since the component uses them internally
// --- Sparkline path generator (same logic as in MetricsPanel.svelte) ---
function sparklinePath(points: number[], width: number, height: number): string {
if (points.length < 2) return '';
const max = Math.max(...points, 0.001);
const step = width / (points.length - 1);
return points
.map((v, i) => {
const x = i * step;
const y = height - (v / max) * height;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
}
// --- Format helpers (same logic as in MetricsPanel.svelte) ---
type HistoryMetric = 'cost' | 'tokens' | 'turns' | 'tools' | 'duration';
function formatMetricValue(metric: HistoryMetric, value: number): string {
switch (metric) {
case 'cost': return `$${value.toFixed(4)}`;
case 'tokens': return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : `${value}`;
case 'turns': return `${value}`;
case 'tools': return `${value}`;
case 'duration': return `${value.toFixed(1)}m`;
}
}
function fmtBurnRate(rate: number): string {
if (rate === 0) return '$0/hr';
if (rate < 0.01) return `$${(rate * 100).toFixed(1)}c/hr`;
return `$${rate.toFixed(2)}/hr`;
}
function fmtPressure(p: number | null): string {
if (p === null) return '—';
return `${Math.round(p * 100)}%`;
}
function fmtIdle(ms: number): string {
if (ms === 0) return '—';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
return `${Math.floor(min / 60)}h ${min % 60}m`;
}
function pressureColor(p: number | null): string {
if (p === null) return 'var(--ctp-overlay0)';
if (p > 0.9) return 'var(--ctp-red)';
if (p > 0.75) return 'var(--ctp-peach)';
if (p > 0.5) return 'var(--ctp-yellow)';
return 'var(--ctp-green)';
}
function stateColor(state: string): string {
switch (state) {
case 'running': return 'var(--ctp-green)';
case 'idle': return 'var(--ctp-overlay1)';
case 'stalled': return 'var(--ctp-peach)';
default: return 'var(--ctp-overlay0)';
}
}
describe('MetricsPanel — sparklinePath', () => {
it('returns empty string for fewer than 2 points', () => {
expect(sparklinePath([], 400, 120)).toBe('');
expect(sparklinePath([5], 400, 120)).toBe('');
});
it('generates valid SVG path for 2 points', () => {
const path = sparklinePath([0, 10], 400, 120);
expect(path).toMatch(/^M0\.0,120\.0 L400\.0,0\.0$/);
});
it('generates path with correct number of segments', () => {
const path = sparklinePath([1, 2, 3, 4, 5], 400, 100);
const segments = path.split(' ');
expect(segments).toHaveLength(5);
expect(segments[0]).toMatch(/^M/);
expect(segments[1]).toMatch(/^L/);
});
it('scales Y axis to max value', () => {
const path = sparklinePath([50, 100], 400, 100);
// Point 1: x=0, y=100 - (50/100)*100 = 50
// Point 2: x=400, y=100 - (100/100)*100 = 0
expect(path).toBe('M0.0,50.0 L400.0,0.0');
});
it('handles all-zero values without division by zero', () => {
const path = sparklinePath([0, 0, 0], 400, 100);
expect(path).not.toBe('');
expect(path).not.toContain('NaN');
});
});
describe('MetricsPanel — formatMetricValue', () => {
it('formats cost with 4 decimals', () => {
expect(formatMetricValue('cost', 1.2345)).toBe('$1.2345');
expect(formatMetricValue('cost', 0)).toBe('$0.0000');
});
it('formats tokens with K suffix for large values', () => {
expect(formatMetricValue('tokens', 150000)).toBe('150.0K');
expect(formatMetricValue('tokens', 1500)).toBe('1.5K');
expect(formatMetricValue('tokens', 500)).toBe('500');
});
it('formats turns as integer', () => {
expect(formatMetricValue('turns', 42)).toBe('42');
});
it('formats tools as integer', () => {
expect(formatMetricValue('tools', 7)).toBe('7');
});
it('formats duration with minutes suffix', () => {
expect(formatMetricValue('duration', 5.3)).toBe('5.3m');
});
});
describe('MetricsPanel — fmtBurnRate', () => {
it('shows $0/hr for zero rate', () => {
expect(fmtBurnRate(0)).toBe('$0/hr');
});
it('shows cents format for tiny rates', () => {
expect(fmtBurnRate(0.005)).toBe('$0.5c/hr');
});
it('shows dollar format for normal rates', () => {
expect(fmtBurnRate(2.5)).toBe('$2.50/hr');
});
});
describe('MetricsPanel — fmtPressure', () => {
it('shows dash for null', () => {
expect(fmtPressure(null)).toBe('—');
});
it('formats as percentage', () => {
expect(fmtPressure(0.75)).toBe('75%');
expect(fmtPressure(0.5)).toBe('50%');
expect(fmtPressure(1)).toBe('100%');
});
});
describe('MetricsPanel — fmtIdle', () => {
it('shows dash for zero', () => {
expect(fmtIdle(0)).toBe('—');
});
it('shows seconds for short durations', () => {
expect(fmtIdle(5000)).toBe('5s');
expect(fmtIdle(30000)).toBe('30s');
});
it('shows minutes for medium durations', () => {
expect(fmtIdle(120_000)).toBe('2m');
expect(fmtIdle(3_599_000)).toBe('59m');
});
it('shows hours and minutes for long durations', () => {
expect(fmtIdle(3_600_000)).toBe('1h 0m');
expect(fmtIdle(5_400_000)).toBe('1h 30m');
});
});
describe('MetricsPanel — pressureColor', () => {
it('returns overlay0 for null', () => {
expect(pressureColor(null)).toBe('var(--ctp-overlay0)');
});
it('returns red for critical pressure', () => {
expect(pressureColor(0.95)).toBe('var(--ctp-red)');
});
it('returns peach for high pressure', () => {
expect(pressureColor(0.8)).toBe('var(--ctp-peach)');
});
it('returns yellow for moderate pressure', () => {
expect(pressureColor(0.6)).toBe('var(--ctp-yellow)');
});
it('returns green for low pressure', () => {
expect(pressureColor(0.3)).toBe('var(--ctp-green)');
});
});
describe('MetricsPanel — stateColor', () => {
it('maps activity states to correct colors', () => {
expect(stateColor('running')).toBe('var(--ctp-green)');
expect(stateColor('idle')).toBe('var(--ctp-overlay1)');
expect(stateColor('stalled')).toBe('var(--ctp-peach)');
expect(stateColor('inactive')).toBe('var(--ctp-overlay0)');
expect(stateColor('unknown')).toBe('var(--ctp-overlay0)');
});
});

View file

@ -0,0 +1,292 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { convertFileSrc } from '@tauri-apps/api/core';
import * as pdfjsLib from 'pdfjs-dist';
// Worker copied to public/ — Vite serves it as a static asset.
// Avoids Vite/Rollup resolution issues with pdfjs worker imports.
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
interface Props {
filePath: string;
}
let { filePath }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let pageCount = $state(0);
let currentScale = $state(1.0);
let loading = $state(true);
let error = $state<string | null>(null);
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let observer: IntersectionObserver | null = null;
// Track which pages have been rendered and which are pending
let renderedPages = new Set<number>();
let renderingPages = new Set<number>();
const SCALE_STEP = 0.25;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3.0;
async function loadPdf(path: string) {
loading = true;
error = null;
cleanup();
try {
const assetUrl = convertFileSrc(path);
const loadingTask = pdfjsLib.getDocument(assetUrl);
pdfDoc = await loadingTask.promise;
pageCount = pdfDoc.numPages;
createPlaceholders();
} catch (e) {
error = `Failed to load PDF: ${e}`;
console.warn('PDF load error:', e);
} finally {
loading = false;
}
}
/** Create placeholder divs for each page, observed for lazy rendering */
function createPlaceholders() {
if (!pdfDoc || !container) return;
// Clean existing
container.innerHTML = '';
renderedPages.clear();
renderingPages.clear();
// Stop old observer
observer?.disconnect();
observer = new IntersectionObserver(onIntersect, {
root: container,
rootMargin: '200px 0px', // pre-render 200px ahead
});
for (let i = 1; i <= pdfDoc.numPages; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'pdf-page-slot';
placeholder.dataset.page = String(i);
// Estimate height from first page viewport (or fallback)
placeholder.style.width = '100%';
placeholder.style.minHeight = '20rem';
container.appendChild(placeholder);
observer.observe(placeholder);
}
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const pageNum = Number((entry.target as HTMLElement).dataset.page);
if (!pageNum || renderedPages.has(pageNum) || renderingPages.has(pageNum)) continue;
renderPage(pageNum, entry.target as HTMLElement);
}
}
async function renderPage(pageNum: number, slot: HTMLElement) {
if (!pdfDoc) return;
renderingPages.add(pageNum);
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio });
const displayViewport = page.getViewport({ scale: currentScale });
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
// Replace placeholder content with canvas
slot.innerHTML = '';
slot.style.minHeight = '';
slot.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const task = page.render({ canvasContext: ctx, viewport });
await task.promise;
renderedPages.add(pageNum);
// Stop observing once rendered
observer?.unobserve(slot);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
console.warn(`Failed to render page ${pageNum}:`, e);
}
} finally {
renderingPages.delete(pageNum);
}
}
function rerender() {
renderedPages.clear();
renderingPages.clear();
createPlaceholders();
}
function zoomIn() {
if (currentScale >= MAX_SCALE) return;
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
rerender();
}
function zoomOut() {
if (currentScale <= MIN_SCALE) return;
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
rerender();
}
function resetZoom() {
currentScale = 1.0;
rerender();
}
function cleanup() {
observer?.disconnect();
observer = null;
renderedPages.clear();
renderingPages.clear();
if (container) container.innerHTML = '';
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
}
onMount(() => {
loadPdf(filePath);
});
// React to filePath changes
let lastPath = $state(filePath);
$effect(() => {
const p = filePath;
if (p !== lastPath) {
lastPath = p;
loadPdf(p);
}
});
onDestroy(() => {
cleanup();
});
</script>
<div class="pdf-viewer">
<div class="pdf-toolbar">
<span class="pdf-info">
{#if loading}
Loading…
{:else if error}
Error
{:else}
{pageCount} page{pageCount !== 1 ? 's' : ''}
{/if}
</span>
<div class="pdf-zoom-controls">
<button class="zoom-btn" onclick={zoomOut} disabled={currentScale <= MIN_SCALE} title="Zoom out"></button>
<button class="zoom-label" onclick={resetZoom} title="Reset zoom">{Math.round(currentScale * 100)}%</button>
<button class="zoom-btn" onclick={zoomIn} disabled={currentScale >= MAX_SCALE} title="Zoom in">+</button>
</div>
</div>
{#if error}
<div class="pdf-error">{error}</div>
{:else}
<div class="pdf-pages" bind:this={container}></div>
{/if}
</div>
<style>
.pdf-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-crust);
}
.pdf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.pdf-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
}
.pdf-zoom-controls {
display: flex;
align-items: center;
gap: 0.125rem;
}
.zoom-btn, .zoom-label {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.1875rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.zoom-btn:hover:not(:disabled), .zoom-label:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.zoom-btn:disabled {
opacity: 0.4;
cursor: default;
}
.zoom-label {
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.pdf-pages {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
}
.pdf-pages :global(.pdf-page-slot) {
display: flex;
justify-content: center;
}
.pdf-pages :global(.pdf-page-canvas) {
box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
border-radius: 2px;
}
.pdf-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
</style>

View file

@ -0,0 +1,542 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
import ProjectHeader from './ProjectHeader.svelte';
import AgentSession from './AgentSession.svelte';
import TerminalTabs from './TerminalTabs.svelte';
import TeamAgentsPanel from './TeamAgentsPanel.svelte';
import ProjectFiles from './ProjectFiles.svelte';
import ContextTab from './ContextTab.svelte';
import FilesTab from './FilesTab.svelte';
import SshTab from './SshTab.svelte';
import MemoriesTab from './MemoriesTab.svelte';
import TaskBoardTab from './TaskBoardTab.svelte';
import ArchitectureTab from './ArchitectureTab.svelte';
import TestingTab from './TestingTab.svelte';
import MetricsPanel from './MetricsPanel.svelte';
import AuditLogTab from './AuditLogTab.svelte';
import {
getTerminalTabs, getActiveGroup,
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
} from '../../stores/workspace.svelte';
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
import { recordExternalWrite } from '../../stores/conflicts.svelte';
import { ProjectId, type AgentId, type GroupId } from '../../types/ids';
import { notify, dismissNotification } from '../../stores/notifications.svelte';
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
import { setReviewQueueDepth } from '../../stores/health.svelte';
import { reviewQueueCount } from '../../adapters/bttask-bridge';
import { getStaleAgents } from '../../adapters/btmsg-bridge';
interface Props {
project: ProjectConfig;
slotIndex: number;
active: boolean;
onactivate: () => void;
}
let { project, slotIndex, active, onactivate }: Props = $props();
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
let mainSessionId = $state<string | null>(null);
let terminalExpanded = $state(false);
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests' | 'audit';
let activeTab = $state<ProjectTab>('model');
let activeGroup = $derived(getActiveGroup());
let agentRole = $derived(project.agentRole);
let isAgent = $derived(project.isAgent ?? false);
// Heartbeat status for Tier 1 agents
let heartbeatStatus = $state<'healthy' | 'stale' | 'dead' | null>(null);
// PERSISTED-LAZY: track which tabs have been activated at least once
let everActivated = $state<Record<string, boolean>>({});
let termTabs = $derived(getTerminalTabs(project.id));
let projectHealth = $derived(getProjectHealth(project.id));
let termTabCount = $derived(termTabs.length);
// Focus flash animation (triggered by keyboard quick-jump)
let flashProjectId = $derived(getFocusFlashProjectId());
let isFlashing = $derived(flashProjectId === project.id);
// Tab name -> index mapping for keyboard switching
const TAB_INDEX_MAP: ProjectTab[] = [
'model', // 1
'docs', // 2
'context', // 3
'files', // 4
'ssh', // 5
'memories', // 6
'metrics', // 7
'tasks', // 8
'architecture',// 9
];
/** Activate a tab — for lazy tabs, mark as ever-activated */
function switchTab(tab: ProjectTab) {
activeTab = tab;
if (!everActivated[tab]) {
everActivated = { ...everActivated, [tab]: true };
}
}
function toggleTerminal() {
terminalExpanded = !terminalExpanded;
}
// Listen for keyboard-driven tab switches
$effect(() => {
const unsubTab = onProjectTabSwitch((pid, tabIndex) => {
if (pid !== project.id) return;
const tabName = TAB_INDEX_MAP[tabIndex - 1];
if (tabName) switchTab(tabName);
});
const unsubTerm = onTerminalToggle((pid) => {
if (pid !== project.id) return;
terminalExpanded = !terminalExpanded;
});
return () => {
unsubTab();
unsubTerm();
};
});
// Sync per-project stall threshold to health store
$effect(() => {
setStallThreshold(project.id, project.stallThresholdMin ?? null);
});
// Register Manager agents with the wake scheduler
$effect(() => {
if (!(project.isAgent && project.agentRole === 'manager')) return;
const groupId = activeGroup?.id;
if (!groupId || !mainSessionId) return;
// Find the agent config to get wake settings
const agentConfig = activeGroup?.agents?.find(a => a.id === project.id);
const strategy = agentConfig?.wakeStrategy ?? 'smart';
const intervalMin = agentConfig?.wakeIntervalMin ?? 3;
const threshold = agentConfig?.wakeThreshold ?? 0.5;
registerManager(
project.id as unknown as AgentId,
groupId as unknown as GroupId,
mainSessionId,
strategy,
intervalMin,
threshold,
);
return () => {
unregisterManager(project.id);
};
});
// Poll review queue depth for reviewer agents (feeds into attention scoring)
$effect(() => {
if (!(project.isAgent && project.agentRole === 'reviewer')) return;
const groupId = activeGroup?.id;
if (!groupId) return;
const pollReviewQueue = () => {
reviewQueueCount(groupId)
.then(count => setReviewQueueDepth(project.id, count))
.catch(() => {}); // best-effort
};
pollReviewQueue(); // immediate first poll
const timer = setInterval(pollReviewQueue, 10_000); // 10s poll
return () => clearInterval(timer);
});
// Heartbeat monitoring for Tier 1 agents
$effect(() => {
if (!project.isAgent) return;
const groupId = activeGroup?.id;
if (!groupId) return;
const pollHeartbeat = () => {
// 300s = healthy threshold, 600s = dead threshold
getStaleAgents(groupId as unknown as GroupId, 300)
.then(staleIds => {
if (staleIds.includes(project.id)) {
// Check if truly dead (>10 min)
getStaleAgents(groupId as unknown as GroupId, 600)
.then(deadIds => {
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
})
.catch(() => { heartbeatStatus = 'stale'; });
} else {
heartbeatStatus = 'healthy';
}
})
.catch(() => { heartbeatStatus = null; });
};
pollHeartbeat();
const timer = setInterval(pollHeartbeat, 15_000); // 15s poll
return () => clearInterval(timer);
});
// S-1 Phase 2: start filesystem watcher for this project's CWD
$effect(() => {
const cwd = project.cwd;
const projectId = project.id;
if (!cwd) return;
// Start watching, then check inotify capacity
// Show scanning toast only if status check takes >300ms
let scanToastId: string | null = null;
const scanTimer = setTimeout(() => {
scanToastId = notify('info', 'Scanning project directories…');
}, 300);
fsWatchProject(projectId, cwd)
.then(() => fsWatcherStatus())
.then((status) => {
clearTimeout(scanTimer);
if (scanToastId) dismissNotification(scanToastId);
if (status.warning) {
notify('warning', status.warning);
}
})
.catch(e => {
clearTimeout(scanTimer);
if (scanToastId) dismissNotification(scanToastId);
console.warn(`Failed to start fs watcher for ${projectId}:`, e);
});
// Listen for fs write events (filter to this project)
let unlisten: (() => void) | null = null;
onFsWriteDetected((event) => {
if (event.project_id !== projectId) return;
const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms);
if (isNew) {
const shortName = event.file_path.split('/').pop() ?? event.file_path;
notify('warning', `External write: ${shortName} — file also modified by agent`);
}
}).then(fn => { unlisten = fn; });
return () => {
// Cleanup: stop watching on unmount or project change
fsUnwatchProject(projectId).catch(() => {});
unlisten?.();
};
});
</script>
<div
class="project-box"
class:active
class:focus-flash={isFlashing}
style="--accent: var({accentVar})"
data-testid="project-box"
data-project-id={project.id}
>
<ProjectHeader
{project}
{slotIndex}
{active}
health={projectHealth}
{heartbeatStatus}
onclick={onactivate}
/>
<div class="project-tabs" data-testid="project-tabs">
<button
class="ptab"
class:active={activeTab === 'model'}
onclick={() => switchTab('model')}
>Model</button>
<button
class="ptab"
class:active={activeTab === 'docs'}
onclick={() => switchTab('docs')}
>Docs</button>
<button
class="ptab"
class:active={activeTab === 'context'}
onclick={() => switchTab('context')}
>Context</button>
<button
class="ptab"
class:active={activeTab === 'files'}
onclick={() => switchTab('files')}
>Files</button>
<button
class="ptab"
class:active={activeTab === 'ssh'}
onclick={() => switchTab('ssh')}
>SSH</button>
<button
class="ptab"
class:active={activeTab === 'memories'}
onclick={() => switchTab('memories')}
>Memory</button>
<button
class="ptab"
class:active={activeTab === 'metrics'}
onclick={() => switchTab('metrics')}
>Metrics</button>
{#if isAgent && agentRole === 'manager'}
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
{/if}
{#if isAgent && agentRole === 'architect'}
<button class="ptab ptab-role" class:active={activeTab === 'architecture'} onclick={() => switchTab('architecture')}>Arch</button>
{/if}
{#if isAgent && agentRole === 'reviewer'}
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
{/if}
{#if isAgent && agentRole === 'tester'}
<button class="ptab ptab-role" class:active={activeTab === 'selenium'} onclick={() => switchTab('selenium')}>Selenium</button>
<button class="ptab ptab-role" class:active={activeTab === 'tests'} onclick={() => switchTab('tests')}>Tests</button>
{/if}
{#if isAgent && agentRole === 'manager'}
<button class="ptab ptab-role" class:active={activeTab === 'audit'} onclick={() => switchTab('audit')}>Audit</button>
{/if}
</div>
<div class="project-content-area">
<!-- PERSISTED-EAGER: always mounted, toggled via display -->
<div class="content-pane" style:display={activeTab === 'model' ? 'flex' : 'none'}>
<AgentSession {project} onsessionid={(id) => mainSessionId = id} />
{#if mainSessionId}
<TeamAgentsPanel {mainSessionId} />
{/if}
</div>
<div class="content-pane" style:display={activeTab === 'docs' ? 'flex' : 'none'}>
<ProjectFiles cwd={project.cwd} projectName={project.name} />
</div>
<div class="content-pane" style:display={activeTab === 'context' ? 'flex' : 'none'}>
<ContextTab sessionId={mainSessionId} projectId={project.id} anchorBudgetScale={project.anchorBudgetScale} />
</div>
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->
{#if everActivated['files']}
<div class="content-pane" style:display={activeTab === 'files' ? 'flex' : 'none'}>
<FilesTab cwd={project.cwd} />
</div>
{/if}
{#if everActivated['ssh']}
<div class="content-pane" style:display={activeTab === 'ssh' ? 'flex' : 'none'}>
<SshTab projectId={project.id} />
</div>
{/if}
{#if everActivated['memories']}
<div class="content-pane" style:display={activeTab === 'memories' ? 'flex' : 'none'}>
<MemoriesTab />
</div>
{/if}
{#if everActivated['metrics']}
<div class="content-pane" style:display={activeTab === 'metrics' ? 'flex' : 'none'}>
<MetricsPanel {project} groupId={activeGroup?.id} />
</div>
{/if}
{#if everActivated['tasks'] && activeGroup}
<div class="content-pane" style:display={activeTab === 'tasks' ? 'flex' : 'none'}>
<TaskBoardTab groupId={activeGroup.id} projectId={project.id} />
</div>
{/if}
{#if everActivated['architecture']}
<div class="content-pane" style:display={activeTab === 'architecture' ? 'flex' : 'none'}>
<ArchitectureTab cwd={project.cwd} />
</div>
{/if}
{#if everActivated['selenium']}
<div class="content-pane" style:display={activeTab === 'selenium' ? 'flex' : 'none'}>
<TestingTab cwd={project.cwd} mode="selenium" />
</div>
{/if}
{#if everActivated['tests']}
<div class="content-pane" style:display={activeTab === 'tests' ? 'flex' : 'none'}>
<TestingTab cwd={project.cwd} mode="tests" />
</div>
{/if}
{#if everActivated['audit'] && activeGroup}
<div class="content-pane" style:display={activeTab === 'audit' ? 'flex' : 'none'}>
<AuditLogTab groupId={activeGroup.id} />
</div>
{/if}
</div>
<div class="terminal-section" style:display={activeTab === 'model' ? 'flex' : 'none'}>
<button class="terminal-toggle" data-testid="terminal-toggle" onclick={toggleTerminal}>
<span class="toggle-chevron" class:expanded={terminalExpanded}>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M3 2l4 3-4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="toggle-label">Terminal</span>
{#if termTabCount > 0}
<span class="toggle-count">{termTabCount}</span>
{/if}
</button>
{#if terminalExpanded}
<div class="project-terminal-area">
<TerminalTabs {project} agentSessionId={mainSessionId} />
</div>
{/if}
</div>
</div>
<style>
.project-box {
display: grid;
grid-template-rows: auto auto 1fr auto;
min-width: 30rem;
/* scroll-snap-align removed: see ProjectGrid */
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 0.375rem;
overflow: hidden;
transition: border-color 0.15s;
}
.project-box.active {
border-color: var(--accent);
}
.project-box.focus-flash {
animation: focus-flash 0.4s ease-out;
}
@keyframes focus-flash {
0% {
border-color: var(--ctp-blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ctp-blue) 40%, transparent);
}
100% {
border-color: var(--accent);
box-shadow: none;
}
}
.project-tabs {
display: flex;
gap: 0;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.ptab {
display: inline-flex;
align-items: center;
padding: 0.3125em 0.875em;
border: none;
border-top: 2px solid transparent;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.725rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
.ptab:hover {
color: var(--ctp-subtext1);
background: var(--ctp-surface0);
}
.ptab:focus-visible {
outline: 1px solid var(--ctp-blue);
outline-offset: -1px;
}
.ptab.active {
background: var(--ctp-base);
color: var(--ctp-text);
font-weight: 600;
border-bottom-color: var(--accent);
margin-bottom: -1px;
}
.ptab-role {
color: var(--ctp-mauve);
}
.ptab-role:hover {
color: var(--ctp-text);
}
.project-content-area {
overflow: hidden;
position: relative;
min-height: 0;
}
.content-pane {
display: flex;
height: 100%;
overflow: hidden;
}
.terminal-section {
border-top: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
}
.terminal-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border: none;
color: var(--ctp-overlay1);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: color 0.12s, background 0.12s;
flex-shrink: 0;
}
.terminal-toggle:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.toggle-chevron {
display: flex;
align-items: center;
transition: transform 0.15s ease;
}
.toggle-chevron.expanded {
transform: rotate(90deg);
}
.toggle-label {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toggle-count {
font-size: 0.6rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0 0.3rem;
border-radius: 0.5rem;
line-height: 1.4;
min-width: 1rem;
text-align: center;
}
.project-terminal-area {
height: 16rem;
min-height: 8rem;
}
</style>

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
interface Props {
cwd: string;
projectName: string;
}
let { cwd, projectName }: Props = $props();
let files = $state<MdFileEntry[]>([]);
let selectedPath = $state<string | null>(null);
let loading = $state(false);
$effect(() => {
loadFiles(cwd);
});
function handleNavigate(absolutePath: string) {
// If the file is in our discovered list, select it directly
const match = files.find(f => f.path === absolutePath);
if (match) {
selectedPath = absolutePath;
} else {
// File not in sidebar — set it directly (MarkdownPane handles loading)
selectedPath = absolutePath;
}
}
async function loadFiles(dir: string) {
loading = true;
try {
files = await discoverMarkdownFiles(dir);
const priority = files.find(f => f.priority);
selectedPath = priority?.path ?? files[0]?.path ?? null;
} catch (e) {
console.warn('Failed to discover markdown files:', e);
files = [];
} finally {
loading = false;
}
}
</script>
<div class="project-files">
<aside class="file-picker">
{#if loading}
<div class="state-msg">Scanning...</div>
{:else if files.length === 0}
<div class="state-msg">No files found</div>
{:else}
<ul class="file-list">
{#each files as file}
<li>
<button
class="file-btn"
class:active={selectedPath === file.path}
class:priority={file.priority}
onclick={() => (selectedPath = file.path)}
>
{file.name}
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<main class="doc-content">
{#if selectedPath}
<MarkdownPane paneId="pf-{projectName}" filePath={selectedPath} onNavigate={handleNavigate} />
{:else}
<div class="state-msg full">Select a file</div>
{/if}
</main>
</div>
<style>
.project-files {
display: flex;
height: 100%;
overflow: hidden;
flex: 1;
}
.file-picker {
width: 10rem;
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 0.25rem 0;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-btn {
display: block;
width: 100%;
padding: 0.2rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.72rem;
text-align: left;
cursor: pointer;
transition: color 0.1s, background 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-btn:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.file-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.file-btn.priority {
color: var(--ctp-blue);
}
.doc-content {
flex: 1;
overflow: auto;
min-width: 0;
}
.state-msg {
color: var(--ctp-overlay0);
font-size: 0.75rem;
padding: 0.75rem 0.5rem;
text-align: center;
}
.state-msg.full {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { getAllWorkItems, getActiveProjectId, setActiveProject } from '../../stores/workspace.svelte';
import ProjectBox from './ProjectBox.svelte';
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
let projects = $derived(getAllWorkItems());
let activeProjectId = $derived(getActiveProjectId());
let visibleCount = $derived(
Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))),
);
// Track slot elements for auto-scroll
let slotEls = $state<Record<string, HTMLElement>>({});
// Auto-scroll to active project only when activeProjectId changes
// Uses direct scrollLeft instead of scrollIntoView to avoid bubbling to parent containers
$effect(() => {
const id = activeProjectId;
if (!id || !containerEl) return;
untrack(() => {
const el = slotEls[id];
if (!el) return;
// Only scroll if the slot is not already visible
const cRect = containerEl!.getBoundingClientRect();
const eRect = el.getBoundingClientRect();
if (eRect.left >= cRect.left && eRect.right <= cRect.right) return;
containerEl!.scrollTo({
left: el.offsetLeft - containerEl!.offsetLeft,
behavior: 'smooth',
});
});
});
let observer: ResizeObserver | undefined;
onMount(() => {
if (containerEl) {
containerWidth = containerEl.clientWidth;
observer = new ResizeObserver(entries => {
for (const entry of entries) {
containerWidth = entry.contentRect.width;
}
});
observer.observe(containerEl);
}
});
onDestroy(() => {
observer?.disconnect();
});
</script>
<div
class="project-grid"
bind:this={containerEl}
style="--visible-count: {visibleCount}"
>
{#each projects as project, i (project.id)}
<div class="project-slot" bind:this={slotEls[project.id]}>
<ProjectBox
{project}
slotIndex={i}
active={activeProjectId === project.id}
onactivate={() => setActiveProject(project.id)}
/>
</div>
{/each}
{#if projects.length === 0}
<div class="empty-state">
No enabled projects in this group. Go to Settings to add projects.
</div>
{/if}
</div>
<style>
.project-grid {
display: flex;
gap: 0.25rem;
height: 100%;
overflow-x: auto;
/* scroll-snap disabled: was causing horizontal jumps when agents auto-scroll */
padding: 0.25rem;
}
.project-slot {
flex: 0 0 calc((100% - (var(--visible-count) - 1) * 0.25rem) / var(--visible-count));
min-width: 30rem;
max-width: calc(100vh * var(--project-max-aspect, 1));
display: flex;
}
.project-slot > :global(*) {
flex: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
color: var(--ctp-overlay0);
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,321 @@
<script lang="ts">
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
import type { ProjectHealth } from '../../stores/health.svelte';
import { acknowledgeConflicts } from '../../stores/conflicts.svelte';
import { ProjectId } from '../../types/ids';
interface Props {
project: ProjectConfig;
slotIndex: number;
active: boolean;
health: ProjectHealth | null;
/** Heartbeat status for Tier 1 agents: 'healthy' | 'stale' | 'dead' | null */
heartbeatStatus?: 'healthy' | 'stale' | 'dead' | null;
onclick: () => void;
}
let { project, slotIndex, active, health, heartbeatStatus = null, onclick }: Props = $props();
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
/** Shorten home dir for display */
let displayCwd = $derived(() => {
const home = '/home/';
const cwd = project.cwd || '~';
if (cwd.startsWith(home)) {
const afterHome = cwd.slice(home.length);
const slashIdx = afterHome.indexOf('/');
if (slashIdx >= 0) return '~' + afterHome.slice(slashIdx);
return '~';
}
return cwd;
});
let statusDotClass = $derived(() => {
if (!health) return 'dot-inactive';
switch (health.activityState) {
case 'running': return 'dot-running';
case 'idle': return 'dot-idle';
case 'stalled': return 'dot-stalled';
default: return 'dot-inactive';
}
});
let statusTooltip = $derived(() => {
if (!health) return 'No active session';
switch (health.activityState) {
case 'running': return health.activeTool ? `Running: ${health.activeTool}` : 'Running';
case 'idle': {
const secs = Math.floor(health.idleDurationMs / 1000);
return secs < 60 ? `Idle (${secs}s)` : `Idle (${Math.floor(secs / 60)}m ${secs % 60}s)`;
}
case 'stalled': {
const mins = Math.floor(health.idleDurationMs / 60_000);
return `Stalled — ${mins} min since last activity`;
}
default: return 'Inactive';
}
});
let contextPct = $derived(health?.contextPressure !== null && health?.contextPressure !== undefined
? Math.round(health.contextPressure * 100)
: null);
let ctxColor = $derived(() => {
if (contextPct === null) return '';
if (contextPct > 90) return 'var(--ctp-red)';
if (contextPct > 75) return 'var(--ctp-peach)';
if (contextPct > 50) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay0)';
});
</script>
<button
class="project-header"
class:active
style="--accent: var({accentVar})"
{onclick}
>
<div class="header-main">
<span class="status-dot {statusDotClass()}" title={statusTooltip()}></span>
<span class="project-icon">{project.icon || '📁'}</span>
<span class="project-name">{project.name}</span>
<span class="project-id">({project.identifier})</span>
</div>
<div class="header-info">
{#if heartbeatStatus && project.isAgent}
<span
class="info-heartbeat"
class:hb-healthy={heartbeatStatus === 'healthy'}
class:hb-stale={heartbeatStatus === 'stale'}
class:hb-dead={heartbeatStatus === 'dead'}
title={heartbeatStatus === 'healthy' ? 'Agent healthy' : heartbeatStatus === 'stale' ? 'Agent stale — no heartbeat recently' : 'Agent dead — no heartbeat'}
>
{heartbeatStatus === 'healthy' ? '♥' : heartbeatStatus === 'stale' ? '♥' : '♡'}
</span>
<span class="info-sep">·</span>
{/if}
{#if health && health.externalConflictCount > 0}
<button
class="info-conflict info-conflict-external"
title="{health.externalConflictCount} external write{health.externalConflictCount > 1 ? 's' : ''} — files modified outside agent — click to dismiss"
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(ProjectId(project.id)); }}
>
{health.externalConflictCount} ext write{health.externalConflictCount > 1 ? 's' : ''}
</button>
<span class="info-sep">·</span>
{/if}
{#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0}
<button
class="info-conflict"
title="{health.fileConflictCount - (health.externalConflictCount ?? 0)} agent conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''} — click to dismiss"
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(ProjectId(project.id)); }}
>
{health.fileConflictCount - (health.externalConflictCount ?? 0)} conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''}
</button>
<span class="info-sep">·</span>
{/if}
{#if contextPct !== null && contextPct > 0}
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
<span class="info-sep">·</span>
{/if}
{#if health && health.burnRatePerHour > 0.01}
<span class="info-rate" title="Burn rate">
${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr
</span>
<span class="info-sep">·</span>
{/if}
<span class="info-cwd" title={project.cwd}>{displayCwd()}</span>
{#if project.profile}
<span class="info-sep">·</span>
<span class="info-profile" title={project.profile}>{project.profile}</span>
{/if}
</div>
</button>
<style>
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-mantle);
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-size: 0.78rem;
cursor: pointer;
flex-shrink: 0;
width: 100%;
text-align: left;
transition: color 0.15s, border-color 0.15s;
}
.project-header:hover {
color: var(--ctp-text);
}
.project-header.active {
color: var(--ctp-text);
border-bottom-color: var(--accent);
}
.header-main {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
flex-shrink: 0;
}
/* Status dot */
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-inactive {
background: var(--ctp-surface2);
}
.dot-running {
background: var(--ctp-green);
animation: pulse 1.5s ease-in-out infinite;
}
.dot-idle {
background: var(--ctp-overlay0);
}
.dot-stalled {
background: var(--ctp-peach);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.project-icon {
font-size: 0.85rem;
line-height: 1;
flex-shrink: 0;
}
.project-name {
font-weight: 600;
white-space: nowrap;
}
.project-id {
color: var(--ctp-overlay0);
font-size: 0.7rem;
white-space: nowrap;
}
.header-info {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
flex-shrink: 1;
overflow: hidden;
}
.info-ctx {
font-size: 0.6rem;
font-weight: 600;
font-family: var(--font-mono, monospace);
white-space: nowrap;
}
.info-rate {
font-size: 0.6rem;
color: var(--ctp-mauve);
font-family: var(--font-mono, monospace);
white-space: nowrap;
}
.info-cwd {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--font-mono, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
max-width: 12rem;
}
.info-sep {
color: var(--ctp-surface2);
font-size: 0.6rem;
flex-shrink: 0;
}
.info-conflict {
font-size: 0.6rem;
color: var(--ctp-red);
font-weight: 600;
white-space: nowrap;
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.info-conflict:hover {
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
}
.info-conflict-external {
color: var(--ctp-peach);
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
}
.info-conflict-external:hover {
background: color-mix(in srgb, var(--ctp-peach) 25%, transparent);
}
.info-heartbeat {
font-size: 0.65rem;
font-weight: 600;
white-space: nowrap;
}
.hb-healthy {
color: var(--ctp-green);
}
.hb-stale {
color: var(--ctp-yellow);
animation: pulse 1.5s ease-in-out infinite;
}
.hb-dead {
color: var(--ctp-red);
}
.info-profile {
font-size: 0.65rem;
color: var(--ctp-blue);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
background: color-mix(in srgb, var(--ctp-blue) 10%, transparent);
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
}
</style>

View file

@ -0,0 +1,350 @@
<script lang="ts">
import { onMount } from 'svelte';
import { searchAll, type SearchResult } from '../../adapters/search-bridge';
import { setActiveProject } from '../../stores/workspace.svelte';
interface Props {
open: boolean;
onclose: () => void;
}
let { open, onclose }: Props = $props();
let query = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Group results by type
let groupedResults = $derived(() => {
const groups = new Map<string, SearchResult[]>();
for (const r of results) {
const key = r.resultType;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(r);
}
return groups;
});
const TYPE_LABELS: Record<string, string> = {
message: 'Messages',
task: 'Tasks',
btmsg: 'Communications',
};
const TYPE_ICONS: Record<string, string> = {
message: '\u{1F4AC}', // speech balloon
task: '\u{2611}', // ballot box with check
btmsg: '\u{1F4E8}', // incoming envelope
};
$effect(() => {
if (open && inputEl) {
// Auto-focus when opened
requestAnimationFrame(() => inputEl?.focus());
}
if (!open) {
query = '';
results = [];
loading = false;
}
});
function handleInput(e: Event) {
query = (e.target as HTMLInputElement).value;
if (debounceTimer) clearTimeout(debounceTimer);
if (!query.trim()) {
results = [];
loading = false;
return;
}
loading = true;
debounceTimer = setTimeout(async () => {
try {
results = await searchAll(query, 30);
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onclose();
}
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('search-backdrop')) {
onclose();
}
}
function handleResultClick(result: SearchResult) {
// Navigate based on result type
if (result.resultType === 'message') {
// result.id is session_id — focus the project that owns it
setActiveProject(result.id);
} else if (result.resultType === 'task') {
// result.id is task_id — no direct project mapping, but close overlay
} else if (result.resultType === 'btmsg') {
// result.id is message_id — no direct navigation, but close overlay
}
onclose();
}
function highlightSnippet(snippet: string): string {
// The Rust backend wraps matches in <b>...</b>
// We sanitize everything else but preserve <b> tags
return snippet
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/&lt;b&gt;/g, '<mark>')
.replace(/&lt;\/b&gt;/g, '</mark>');
}
function formatScore(score: number): string {
return score.toFixed(1);
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-backdrop" onclick={handleBackdropClick}>
<div class="search-overlay" onkeydown={handleKeydown}>
<div class="search-input-row">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input
bind:this={inputEl}
class="search-input"
type="text"
value={query}
oninput={handleInput}
placeholder="Search across sessions, tasks, and messages..."
spellcheck="false"
/>
{#if loading}
<div class="search-spinner"></div>
{/if}
<kbd class="search-kbd">Esc</kbd>
</div>
<div class="search-results">
{#if results.length === 0 && !loading && query.trim()}
<div class="search-empty">No results for "{query}"</div>
{:else if results.length === 0 && !loading}
<div class="search-empty">Search across sessions, tasks, and messages</div>
{:else}
{#each [...groupedResults()] as [type, items] (type)}
<div class="result-group">
<div class="result-group-header">
<span class="group-icon">{TYPE_ICONS[type] ?? '?'}</span>
<span class="group-label">{TYPE_LABELS[type] ?? type}</span>
<span class="group-count">{items.length}</span>
</div>
{#each items as item (item.id + item.snippet)}
<button class="result-item" onclick={() => handleResultClick(item)}>
<div class="result-main">
<span class="result-title">{item.title}</span>
<span class="result-snippet">{@html highlightSnippet(item.snippet)}</span>
</div>
<span class="result-score">{formatScore(item.score)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.search-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
}
.search-overlay {
width: 37.5rem;
max-height: 60vh;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.75rem;
box-shadow: 0 1.5rem 4rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.search-icon {
color: var(--ctp-overlay1);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--ctp-text);
font-size: 0.9375rem;
font-family: inherit;
}
.search-input::placeholder {
color: var(--ctp-overlay0);
}
.search-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--ctp-surface2);
border-top-color: var(--ctp-blue);
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.search-kbd {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--ctp-surface0);
color: var(--ctp-overlay1);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
font-family: inherit;
flex-shrink: 0;
}
.search-results {
overflow-y: auto;
flex: 1;
padding: 0.25rem 0;
}
.search-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
}
.result-group {
padding: 0.25rem 0;
}
.result-group-header {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 1rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.group-icon {
font-size: 0.75rem;
}
.group-count {
margin-left: auto;
font-size: 0.625rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0 0.375rem;
border-radius: 0.625rem;
}
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 1rem;
background: transparent;
border: none;
color: var(--ctp-text);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.result-item:hover {
background: var(--ctp-surface0);
}
.result-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.result-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ctp-text);
font-size: 0.8125rem;
}
.result-snippet {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ctp-subtext0);
font-size: 0.75rem;
}
.result-snippet :global(mark) {
background: color-mix(in srgb, var(--ctp-yellow) 25%, transparent);
color: var(--ctp-yellow);
border-radius: 0.125rem;
padding: 0 0.125rem;
}
.result-score {
font-size: 0.625rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
flex-shrink: 0;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,425 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listSshSessions, saveSshSession, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge';
import { addTerminalTab } from '../../stores/workspace.svelte';
interface Props {
projectId: string;
}
let { projectId }: Props = $props();
let sessions = $state<SshSession[]>([]);
let loading = $state(true);
let editing = $state<SshSession | null>(null);
let showForm = $state(false);
// Form fields
let formName = $state('');
let formHost = $state('');
let formPort = $state(22);
let formUsername = $state('');
let formKeyFile = $state('');
let formFolder = $state('');
onMount(loadSessions);
async function loadSessions() {
loading = true;
try {
sessions = await listSshSessions();
} catch (e) {
console.warn('Failed to load SSH sessions:', e);
} finally {
loading = false;
}
}
function resetForm() {
formName = '';
formHost = '';
formPort = 22;
formUsername = '';
formKeyFile = '';
formFolder = '';
editing = null;
showForm = false;
}
function editSession(session: SshSession) {
formName = session.name;
formHost = session.host;
formPort = session.port;
formUsername = session.username;
formKeyFile = session.key_file;
formFolder = session.folder;
editing = session;
showForm = true;
}
function startNew() {
resetForm();
showForm = true;
}
async function saveForm() {
if (!formName.trim() || !formHost.trim()) return;
const session: SshSession = {
id: editing?.id ?? crypto.randomUUID(),
name: formName.trim(),
host: formHost.trim(),
port: formPort,
username: formUsername.trim() || 'root',
key_file: formKeyFile.trim(),
folder: formFolder.trim(),
color: editing?.color ?? '',
created_at: editing?.created_at ?? Date.now(),
last_used_at: Date.now(),
};
try {
await saveSshSession(session);
await loadSessions();
resetForm();
} catch (e) {
console.warn('Failed to save SSH session:', e);
}
}
async function removeSession(id: string) {
try {
await deleteSshSession(id);
await loadSessions();
} catch (e) {
console.warn('Failed to delete SSH session:', e);
}
}
function launchSession(session: SshSession) {
addTerminalTab(projectId, {
id: `ssh-${session.id}-${Date.now()}`,
title: `SSH: ${session.name}`,
type: 'ssh',
sshSessionId: session.id,
});
}
</script>
<div class="ssh-tab">
<div class="ssh-header">
<h3>SSH Connections</h3>
<button class="add-btn" onclick={startNew}>+ New</button>
</div>
{#if showForm}
<div class="ssh-form">
<div class="form-title">{editing ? 'Edit Connection' : 'New Connection'}</div>
<div class="form-grid">
<label class="form-label">
<span>Name</span>
<input type="text" bind:value={formName} placeholder="My Server" />
</label>
<label class="form-label">
<span>Host</span>
<input type="text" bind:value={formHost} placeholder="192.168.1.100" />
</label>
<label class="form-label">
<span>Port</span>
<input type="number" bind:value={formPort} min="1" max="65535" />
</label>
<label class="form-label">
<span>Username</span>
<input type="text" bind:value={formUsername} placeholder="root" />
</label>
<label class="form-label">
<span>Key File</span>
<input type="text" bind:value={formKeyFile} placeholder="~/.ssh/id_ed25519" />
</label>
<label class="form-label">
<span>Remote Folder</span>
<input type="text" bind:value={formFolder} placeholder="/home/user" />
</label>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick={resetForm}>Cancel</button>
<button class="btn-save" onclick={saveForm} disabled={!formName.trim() || !formHost.trim()}>
{editing ? 'Update' : 'Save'}
</button>
</div>
</div>
{/if}
<div class="ssh-list">
{#if loading}
<div class="ssh-empty">Loading…</div>
{:else if sessions.length === 0 && !showForm}
<div class="ssh-empty">
<p>No SSH connections configured.</p>
<p>Add a connection to launch it as a terminal in the Model tab.</p>
</div>
{:else}
{#each sessions as session (session.id)}
<div class="ssh-card">
<div class="ssh-card-info">
<span class="ssh-card-name">{session.name}</span>
<span class="ssh-card-detail">{session.username}@{session.host}:{session.port}</span>
{#if session.folder}
<span class="ssh-card-folder">{session.folder}</span>
{/if}
</div>
<div class="ssh-card-actions">
<button class="ssh-btn launch" onclick={() => launchSession(session)} title="Launch in terminal">
</button>
<button class="ssh-btn edit" onclick={() => editSession(session)} title="Edit">
</button>
<button class="ssh-btn delete" onclick={() => removeSession(session.id)} title="Delete">
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
<style>
.ssh-tab {
display: flex;
flex-direction: column;
height: 100%;
background: var(--ctp-base);
color: var(--ctp-text);
overflow: hidden;
}
.ssh-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.ssh-header h3 {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-blue);
margin: 0;
}
.add-btn {
background: var(--ctp-surface0);
border: none;
color: var(--ctp-text);
font-size: 0.7rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.12s;
}
.add-btn:hover {
background: var(--ctp-surface1);
}
.ssh-form {
padding: 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.form-title {
font-size: 0.725rem;
font-weight: 600;
color: var(--ctp-subtext1);
margin-bottom: 0.5rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.375rem;
}
.form-label {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.form-label span {
font-size: 0.625rem;
font-weight: 500;
color: var(--ctp-overlay1);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-label input {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.725rem;
padding: 0.25rem 0.375rem;
font-family: var(--term-font-family, monospace);
}
.form-label input:focus {
outline: none;
border-color: var(--ctp-blue);
}
.form-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-cancel, .btn-save {
padding: 0.25rem 0.625rem;
border: none;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.12s;
}
.btn-cancel {
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
}
.btn-cancel:hover {
background: var(--ctp-surface1);
}
.btn-save {
background: var(--ctp-blue);
color: var(--ctp-base);
}
.btn-save:hover:not(:disabled) {
opacity: 0.85;
}
.btn-save:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ssh-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.ssh-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.75rem;
text-align: center;
gap: 0.25rem;
}
.ssh-empty p {
margin: 0;
}
.ssh-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
margin-bottom: 0.375rem;
transition: background 0.12s;
}
.ssh-card:hover {
background: var(--ctp-surface1);
}
.ssh-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ssh-card-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
}
.ssh-card-detail {
font-size: 0.65rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-subtext0);
}
.ssh-card-folder {
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.ssh-card-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.ssh-btn {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
font-size: 0.7rem;
transition: background 0.12s, color 0.12s;
}
.ssh-btn.launch {
color: var(--ctp-green);
}
.ssh-btn.launch:hover {
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
}
.ssh-btn.edit {
color: var(--ctp-blue);
}
.ssh-btn.edit:hover {
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
}
.ssh-btn.delete {
color: var(--ctp-red);
}
.ssh-btn.delete:hover {
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
}
</style>

View file

@ -0,0 +1,582 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listTasks, updateTaskStatus, createTask, deleteTask, addTaskComment, type Task, type TaskComment, getTaskComments } from '../../adapters/bttask-bridge';
import type { GroupId } from '../../types/ids';
import { AgentId } from '../../types/ids';
interface Props {
groupId: GroupId;
projectId?: string;
}
let { groupId, projectId }: Props = $props();
const STATUSES = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
const STATUS_LABELS: Record<string, string> = {
todo: 'To Do',
progress: 'In Progress',
review: 'Review',
done: 'Done',
blocked: 'Blocked',
};
const STATUS_ICONS: Record<string, string> = {
todo: '○',
progress: '◐',
review: '◑',
done: '●',
blocked: '✗',
};
const PRIORITY_LABELS: Record<string, string> = {
critical: 'CRIT',
high: 'HIGH',
medium: 'MED',
low: 'LOW',
};
let tasks = $state<Task[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// New task form
let showAddForm = $state(false);
let newTitle = $state('');
let newDesc = $state('');
let newPriority = $state('medium');
// Expanded task detail
let expandedTaskId = $state<string | null>(null);
let taskComments = $state<TaskComment[]>([]);
let newComment = $state('');
let tasksByStatus = $derived.by(() => {
const map: Record<string, Task[]> = {};
for (const s of STATUSES) map[s] = [];
for (const t of tasks) {
if (map[t.status]) map[t.status].push(t);
}
return map;
});
let pendingCount = $derived(
tasks.filter(t => t.status !== 'done').length
);
async function loadTasks() {
try {
tasks = await listTasks(groupId);
error = null;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
onMount(() => {
loadTasks();
pollTimer = setInterval(loadTasks, 5000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
async function handleStatusChange(taskId: string, newStatus: string) {
try {
const task = tasks.find(t => t.id === taskId);
const version = task?.version ?? 1;
await updateTaskStatus(taskId, newStatus, version);
await loadTasks();
} catch (e: any) {
const msg = e?.message ?? String(e);
if (msg.includes('version conflict')) {
console.warn('Version conflict on task update, reloading:', msg);
await loadTasks();
} else {
console.warn('Failed to update task status:', e);
}
}
}
async function handleAddTask() {
if (!newTitle.trim()) return;
try {
await createTask(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
newTitle = '';
newDesc = '';
newPriority = 'medium';
showAddForm = false;
await loadTasks();
} catch (e) {
console.warn('Failed to create task:', e);
}
}
async function handleDelete(taskId: string) {
try {
await deleteTask(taskId);
if (expandedTaskId === taskId) expandedTaskId = null;
await loadTasks();
} catch (e) {
console.warn('Failed to delete task:', e);
}
}
async function toggleExpand(taskId: string) {
if (expandedTaskId === taskId) {
expandedTaskId = null;
return;
}
expandedTaskId = taskId;
try {
taskComments = await getTaskComments(taskId);
} catch {
taskComments = [];
}
}
async function handleAddComment() {
if (!expandedTaskId || !newComment.trim()) return;
try {
await addTaskComment(expandedTaskId, AgentId('admin'), newComment.trim());
newComment = '';
taskComments = await getTaskComments(expandedTaskId);
} catch (e) {
console.warn('Failed to add comment:', e);
}
}
</script>
<div class="task-board-tab">
<div class="board-header">
<span class="board-title">Task Board</span>
<span class="pending-badge" class:all-done={pendingCount === 0}>
{pendingCount === 0 ? 'All done' : `${pendingCount} pending`}
</span>
<button class="btn-add" onclick={() => showAddForm = !showAddForm}>
{showAddForm ? '✕' : '+ Task'}
</button>
</div>
{#if showAddForm}
<div class="add-task-form">
<input
class="task-title-input"
bind:value={newTitle}
placeholder="Task title"
onkeydown={e => { if (e.key === 'Enter') handleAddTask(); }}
/>
<textarea
class="task-desc-input"
bind:value={newDesc}
placeholder="Description (optional)"
rows="2"
></textarea>
<div class="form-row">
<select class="priority-select" bind:value={newPriority}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button class="btn-create" onclick={handleAddTask} disabled={!newTitle.trim()}>Create</button>
</div>
</div>
{/if}
{#if loading}
<div class="loading">Loading tasks...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
<div class="kanban">
{#each STATUSES as status}
<div class="kanban-column">
<div class="column-header">
<span class="column-icon">{STATUS_ICONS[status]}</span>
<span class="column-title">{STATUS_LABELS[status]}</span>
<span class="column-count">{tasksByStatus[status].length}</span>
</div>
<div class="column-cards">
{#each tasksByStatus[status] as task (task.id)}
<div
class="task-card"
class:expanded={expandedTaskId === task.id}
class:critical={task.priority === 'critical'}
class:high={task.priority === 'high'}
>
<button class="task-card-body" onclick={() => toggleExpand(task.id)}>
<span class="task-priority priority-{task.priority}">{PRIORITY_LABELS[task.priority]}</span>
<span class="task-title">{task.title}</span>
{#if task.assignedTo}
<span class="task-assignee">{task.assignedTo}</span>
{/if}
</button>
{#if expandedTaskId === task.id}
<div class="task-detail">
{#if task.description}
<p class="task-description">{task.description}</p>
{/if}
<div class="status-actions">
{#each STATUSES as s}
<button
class="status-btn"
class:active={task.status === s}
onclick={() => handleStatusChange(task.id, s)}
>{STATUS_ICONS[s]} {STATUS_LABELS[s]}</button>
{/each}
</div>
{#if taskComments.length > 0}
<div class="comments-list">
{#each taskComments as comment}
<div class="comment">
<span class="comment-agent">{comment.agentId}</span>
<span class="comment-text">{comment.content}</span>
</div>
{/each}
</div>
{/if}
<div class="comment-form">
<input
class="comment-input"
bind:value={newComment}
placeholder="Add comment..."
onkeydown={e => { if (e.key === 'Enter') handleAddComment(); }}
/>
</div>
<button class="btn-delete" onclick={() => handleDelete(task.id)}>Delete</button>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.task-board-tab {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding: 0.5rem;
gap: 0.5rem;
}
.board-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.board-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-text);
}
.pending-badge {
font-size: 0.65rem;
padding: 0.125rem 0.375rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent);
color: var(--ctp-yellow);
font-weight: 600;
}
.pending-badge.all-done {
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green);
}
.btn-add {
margin-left: auto;
padding: 0.2rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-size: 0.7rem;
cursor: pointer;
}
.btn-add:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.add-task-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem;
background: var(--ctp-surface0);
border-radius: 0.375rem;
flex-shrink: 0;
}
.task-title-input, .task-desc-input, .comment-input {
padding: 0.3125rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.75rem;
}
.task-desc-input {
resize: vertical;
min-height: 2rem;
font-family: var(--ui-font-family, sans-serif);
}
.form-row {
display: flex;
gap: 0.375rem;
align-items: center;
}
.priority-select {
padding: 0.25rem 0.375rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.7rem;
}
.btn-create {
padding: 0.25rem 0.625rem;
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
}
.btn-create:disabled {
opacity: 0.4;
cursor: default;
}
.loading, .error {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 0.8rem;
color: var(--ctp-overlay0);
}
.error { color: var(--ctp-red); }
.kanban {
display: flex;
gap: 0.375rem;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.kanban-column {
flex: 1;
min-width: 8rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.column-header {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.375rem;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ctp-overlay0);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.column-icon { font-size: 0.7rem; }
.column-count {
margin-left: auto;
font-size: 0.55rem;
background: var(--ctp-surface0);
padding: 0 0.25rem;
border-radius: 0.5rem;
}
.column-cards {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.25rem 0;
}
.task-card {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
transition: border-color 0.15s;
}
.task-card:hover {
border-color: var(--ctp-surface2);
}
.task-card.critical {
border-left: 2px solid var(--ctp-red);
}
.task-card.high {
border-left: 2px solid var(--ctp-yellow);
}
.task-card-body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.375rem;
background: transparent;
border: none;
color: var(--ctp-text);
font-size: 0.7rem;
text-align: left;
cursor: pointer;
width: 100%;
}
.task-priority {
font-size: 0.5rem;
font-weight: 700;
padding: 0.0625rem 0.25rem;
border-radius: 0.125rem;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.priority-critical { background: var(--ctp-red); color: var(--ctp-base); }
.priority-high { background: var(--ctp-yellow); color: var(--ctp-base); }
.priority-medium { background: var(--ctp-surface1); color: var(--ctp-subtext0); }
.priority-low { background: var(--ctp-surface0); color: var(--ctp-overlay0); }
.task-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-assignee {
font-size: 0.55rem;
color: var(--ctp-overlay0);
background: var(--ctp-base);
padding: 0.0625rem 0.25rem;
border-radius: 0.125rem;
}
.task-detail {
padding: 0.375rem;
border-top: 1px solid var(--ctp-surface1);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.task-description {
margin: 0;
font-size: 0.7rem;
color: var(--ctp-subtext0);
line-height: 1.4;
}
.status-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.status-btn {
padding: 0.125rem 0.375rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-overlay0);
font-size: 0.6rem;
cursor: pointer;
}
.status-btn.active {
background: var(--ctp-surface1);
color: var(--ctp-text);
font-weight: 600;
}
.status-btn:hover {
border-color: var(--ctp-surface2);
color: var(--ctp-text);
}
.comments-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 8rem;
overflow-y: auto;
}
.comment {
font-size: 0.65rem;
color: var(--ctp-subtext0);
}
.comment-agent {
font-weight: 600;
color: var(--ctp-blue);
margin-right: 0.25rem;
}
.comment-form {
display: flex;
gap: 0.25rem;
}
.comment-input {
flex: 1;
font-size: 0.65rem;
}
.btn-delete {
align-self: flex-end;
padding: 0.125rem 0.375rem;
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.6rem;
cursor: pointer;
}
.btn-delete:hover {
color: var(--ctp-red);
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { getAgentSessions, getChildSessions, type AgentSession } from '../../stores/agents.svelte';
import AgentCard from './AgentCard.svelte';
interface Props {
/** The main Claude session ID for this project */
mainSessionId: string;
}
let { mainSessionId }: Props = $props();
// Get subagent sessions spawned by the main session
let childSessions = $derived(getChildSessions(mainSessionId));
let hasAgents = $derived(childSessions.length > 0);
let expanded = $state(true);
</script>
{#if hasAgents}
<div class="team-agents-panel">
<button class="panel-header" onclick={() => expanded = !expanded}>
<span class="header-icon">{expanded ? '▾' : '▸'}</span>
<span class="header-title">Team Agents</span>
<span class="agent-count">{childSessions.length}</span>
</button>
{#if expanded}
<div class="agent-list">
{#each childSessions as child (child.id)}
<AgentCard session={child} />
{/each}
</div>
{/if}
</div>
{/if}
<style>
.team-agents-panel {
border-left: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
display: flex;
flex-direction: column;
overflow: hidden;
width: 13.75rem;
flex-shrink: 0;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.72rem;
cursor: pointer;
border-bottom: 1px solid var(--ctp-surface0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-header:hover {
color: var(--ctp-text);
}
.header-icon {
font-size: 0.65rem;
}
.header-title {
font-weight: 600;
}
.agent-count {
margin-left: auto;
background: var(--ctp-surface0);
padding: 0 0.3125rem;
border-radius: 0.5rem;
font-size: 0.65rem;
color: var(--ctp-overlay1);
}
.agent-list {
display: flex;
flex-direction: column;
gap: 0.1875rem;
padding: 0.25rem;
overflow-y: auto;
flex: 1;
}
</style>

View file

@ -0,0 +1,275 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ProjectConfig } from '../../types/groups';
import {
getTerminalTabs,
addTerminalTab,
removeTerminalTab,
type TerminalTab,
} from '../../stores/workspace.svelte';
import { listSshSessions, type SshSession } from '../../adapters/ssh-bridge';
import TerminalPane from '../Terminal/TerminalPane.svelte';
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
/** Cached SSH sessions for building args */
let sshSessions = $state<SshSession[]>([]);
onMount(() => {
listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
});
/** Resolved SSH args per tab, keyed by tab id */
let sshArgsCache = $derived.by(() => {
const cache: Record<string, string[]> = {};
for (const tab of tabs) {
if (tab.type !== 'ssh' || !tab.sshSessionId) continue;
const session = sshSessions.find(s => s.id === tab.sshSessionId);
if (!session) continue;
const args: string[] = [];
if (session.key_file) args.push('-i', session.key_file);
if (session.port && session.port !== 22) args.push('-p', String(session.port));
args.push(`${session.username}@${session.host}`);
cache[tab.id] = args;
}
return cache;
});
interface Props {
project: ProjectConfig;
agentSessionId?: string | null;
}
let { project, agentSessionId }: Props = $props();
let tabs = $derived(getTerminalTabs(project.id));
let activeTabId = $state<string | null>(null);
// Auto-select first tab
$effect(() => {
if (tabs.length > 0 && (!activeTabId || !tabs.find(t => t.id === activeTabId))) {
activeTabId = tabs[0].id;
}
if (tabs.length === 0) {
activeTabId = null;
}
});
function addShellTab() {
const id = crypto.randomUUID();
const num = tabs.filter(t => t.type === 'shell').length + 1;
addTerminalTab(project.id, {
id,
title: `Shell ${num}`,
type: 'shell',
});
activeTabId = id;
}
function addAgentPreviewTab() {
if (!agentSessionId) return;
// Don't create duplicate — check if one already exists for this session
const existing = tabs.find(
t => t.type === 'agent-preview' && t.agentSessionId === agentSessionId,
);
if (existing) {
activeTabId = existing.id;
return;
}
const id = crypto.randomUUID();
addTerminalTab(project.id, {
id,
title: 'Agent Preview',
type: 'agent-preview',
agentSessionId,
});
activeTabId = id;
}
function closeTab(tabId: string) {
removeTerminalTab(project.id, tabId);
}
function handleTabExit(tabId: string) {
closeTab(tabId);
}
</script>
<div class="terminal-tabs" data-testid="terminal-tabs">
<div class="tab-bar">
{#each tabs as tab (tab.id)}
<div
class="tab"
class:active={activeTabId === tab.id}
role="tab"
tabindex="0"
onclick={() => (activeTabId = tab.id)}
onkeydown={e => e.key === 'Enter' && (activeTabId = tab.id)}
>
<span class="tab-title">{tab.title}</span>
<button
class="tab-close"
onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
title="Close"
>×</button>
</div>
{/each}
<button class="tab-add" data-testid="tab-add" onclick={addShellTab} title="New shell">+</button>
{#if agentSessionId}
<button
class="tab-add tab-agent-preview"
onclick={addAgentPreviewTab}
title="Watch agent activity"
>👁</button>
{/if}
</div>
<div class="tab-content">
{#each tabs as tab (tab.id)}
<div class="tab-pane" style:display={activeTabId === tab.id ? 'block' : 'none'}>
{#if tab.type === 'agent-preview' && tab.agentSessionId}
{#if activeTabId === tab.id}
<AgentPreviewPane sessionId={tab.agentSessionId} />
{/if}
{:else if tab.type === 'ssh' && sshArgsCache[tab.id]}
<TerminalPane
cwd={project.cwd}
shell="/usr/bin/ssh"
args={sshArgsCache[tab.id]}
onExit={() => handleTabExit(tab.id)}
/>
{:else if tab.type === 'shell'}
<TerminalPane
cwd={project.cwd}
onExit={() => handleTabExit(tab.id)}
/>
{/if}
</div>
{/each}
{#if tabs.length === 0}
<div class="empty-terminals">
<button class="add-first" onclick={addShellTab}>
+ Open terminal
</button>
</div>
{/if}
</div>
</div>
<style>
.terminal-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.tab-bar {
display: flex;
align-items: center;
gap: 1px;
padding: 0 0.25rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
overflow-x: auto;
flex-shrink: 0;
}
.tab {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
font-size: 0.72rem;
cursor: pointer;
border-radius: 0.1875rem 0.1875rem 0 0;
white-space: nowrap;
transition: color 0.1s, background 0.1s;
}
.tab:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.tab.active {
color: var(--ctp-text);
background: var(--ctp-base);
border-bottom: 1px solid var(--ctp-blue);
}
.tab-title {
max-width: 6.25rem;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.8rem;
cursor: pointer;
padding: 0 0.125rem;
line-height: 1;
}
.tab-close:hover {
color: var(--ctp-red);
}
.tab-add {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.85rem;
cursor: pointer;
padding: 0.125rem 0.375rem;
border-radius: 0.1875rem;
}
.tab-add:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.tab-agent-preview {
font-size: 0.7rem;
}
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-pane {
position: absolute;
inset: 0;
}
.empty-terminals {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.add-first {
padding: 0.375rem 1rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.add-first:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
</style>

View file

@ -0,0 +1,428 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { convertFileSrc } from '@tauri-apps/api/core';
import { listDirectoryChildren, readFileContent, type DirEntry } from '../../adapters/files-bridge';
interface Props {
cwd: string;
mode: 'selenium' | 'tests';
}
let { cwd, mode }: Props = $props();
// ─── Selenium mode ────────────────────────────────────────
let seleniumConnected = $state(false);
let screenshots = $state<string[]>([]);
let selectedScreenshot = $state<string | null>(null);
let seleniumLog = $state<string[]>([]);
let seleniumPollTimer: ReturnType<typeof setInterval> | null = null;
const SCREENSHOTS_DIR = '.selenium/screenshots';
const SELENIUM_LOG = '.selenium/session.log';
async function loadSeleniumState() {
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
try {
const entries = await listDirectoryChildren(screenshotPath);
const imageFiles = entries
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
.map(e => e.path)
.sort()
.reverse();
screenshots = imageFiles;
// Select latest if nothing selected
if (!selectedScreenshot && imageFiles.length > 0) {
selectedScreenshot = imageFiles[0];
}
seleniumConnected = imageFiles.length > 0;
} catch {
screenshots = [];
seleniumConnected = false;
}
// Load session log
try {
const content = await readFileContent(`${cwd}/${SELENIUM_LOG}`);
if (content.type === 'Text') {
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
}
} catch {
seleniumLog = [];
}
}
// ─── Tests mode ───────────────────────────────────────────
let testFiles = $state<DirEntry[]>([]);
let selectedTestFile = $state<string | null>(null);
let testOutput = $state('');
let testRunning = $state(false);
let lastTestResult = $state<'pass' | 'fail' | null>(null);
const TEST_DIRS = ['tests', 'test', '__tests__', 'spec', 'e2e'];
async function loadTestFiles() {
for (const dir of TEST_DIRS) {
try {
const entries = await listDirectoryChildren(`${cwd}/${dir}`);
const tests = entries.filter(e =>
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
/test_.*\.py$/.test(e.name)
);
if (tests.length > 0) {
testFiles = tests;
return;
}
} catch {
// Directory doesn't exist, try next
}
}
testFiles = [];
}
async function viewTestFile(filePath: string) {
selectedTestFile = filePath;
try {
const content = await readFileContent(filePath);
if (content.type === 'Text') {
testOutput = content.content;
}
} catch (e) {
testOutput = `Error: ${e}`;
}
}
onMount(() => {
if (mode === 'selenium') {
loadSeleniumState();
seleniumPollTimer = setInterval(loadSeleniumState, 3000);
} else {
loadTestFiles();
}
});
onDestroy(() => {
if (seleniumPollTimer) clearInterval(seleniumPollTimer);
});
</script>
<div class="testing-tab">
{#if mode === 'selenium'}
<!-- Selenium Live View -->
<div class="selenium-view">
<div class="selenium-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Screenshots</span>
<span class="status-dot" class:connected={seleniumConnected}></span>
</div>
<div class="screenshot-list">
{#each screenshots as path}
<button
class="screenshot-item"
class:active={selectedScreenshot === path}
onclick={() => selectedScreenshot = path}
>
<span class="screenshot-name">{path.split('/').pop()}</span>
</button>
{/each}
{#if screenshots.length === 0}
<div class="empty-hint">
No screenshots yet. The Tester agent saves screenshots to <code>{SCREENSHOTS_DIR}/</code>
</div>
{/if}
</div>
<div class="log-section">
<div class="sidebar-header">
<span class="sidebar-title">Session Log</span>
</div>
<div class="log-output">
{#each seleniumLog as line}
<div class="log-line">{line}</div>
{/each}
{#if seleniumLog.length === 0}
<div class="empty-hint">No log entries</div>
{/if}
</div>
</div>
</div>
<div class="selenium-content">
{#if selectedScreenshot}
<div class="screenshot-preview">
<img
src={convertFileSrc(selectedScreenshot)}
alt="Selenium screenshot"
class="screenshot-img"
/>
</div>
{:else}
<div class="empty-state">
Selenium screenshots will appear here during testing.
<br />
The Tester agent uses Selenium WebDriver for UI testing.
</div>
{/if}
</div>
</div>
{:else}
<!-- Automated Tests View -->
<div class="tests-view">
<div class="tests-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Test Files</span>
{#if lastTestResult}
<span class="result-badge" class:pass={lastTestResult === 'pass'} class:fail={lastTestResult === 'fail'}>
{lastTestResult === 'pass' ? '✓ PASS' : '✗ FAIL'}
</span>
{/if}
</div>
<div class="test-file-list">
{#each testFiles as file (file.path)}
<button
class="test-file-item"
class:active={selectedTestFile === file.path}
onclick={() => viewTestFile(file.path)}
>
<span class="test-icon">🧪</span>
<span class="test-name">{file.name}</span>
</button>
{/each}
{#if testFiles.length === 0}
<div class="empty-hint">
No test files found. The Tester agent creates tests in standard directories (tests/, test/, spec/).
</div>
{/if}
</div>
</div>
<div class="tests-content">
{#if selectedTestFile}
<div class="test-file-header">
<span class="test-file-name">{selectedTestFile.split('/').pop()}</span>
</div>
<pre class="test-output">{testOutput}</pre>
{:else}
<div class="empty-state">
Select a test file to view its contents.
<br />
The Tester agent runs tests via the terminal.
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.testing-tab {
display: flex;
height: 100%;
overflow: hidden;
}
/* Shared sidebar patterns */
.selenium-sidebar, .tests-sidebar {
width: 12rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.sidebar-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.status-dot.connected {
background: var(--ctp-green);
box-shadow: 0 0 4px var(--ctp-green);
}
.empty-hint {
padding: 0.5rem;
font-size: 0.65rem;
color: var(--ctp-overlay0);
line-height: 1.4;
}
.empty-hint code {
background: var(--ctp-surface0);
padding: 0.0625rem 0.25rem;
border-radius: 0.125rem;
font-size: 0.6rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
text-align: center;
line-height: 1.6;
padding: 1rem;
}
/* Selenium view */
.selenium-view, .tests-view {
display: flex;
flex: 1;
overflow: hidden;
}
.screenshot-list, .test-file-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.screenshot-item, .test-file-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.65rem;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.screenshot-item:hover, .test-file-item:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.screenshot-item.active, .test-file-item.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.screenshot-name, .test-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.test-icon { font-size: 0.75rem; }
.log-section {
border-top: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
max-height: 40%;
}
.log-output {
flex: 1;
overflow-y: auto;
padding: 0.25rem 0.5rem;
}
.log-line {
font-size: 0.6rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-subtext0);
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.selenium-content, .tests-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.screenshot-preview {
flex: 1;
overflow: auto;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.5rem;
background: var(--ctp-mantle);
}
.screenshot-img {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface0);
}
/* Tests view */
.result-badge {
font-size: 0.55rem;
font-weight: 700;
padding: 0.0625rem 0.25rem;
border-radius: 0.125rem;
}
.result-badge.pass {
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green);
}
.result-badge.fail {
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
color: var(--ctp-red);
}
.test-file-header {
display: flex;
align-items: center;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.test-file-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
}
.test-output {
flex: 1;
overflow: auto;
padding: 0.5rem;
margin: 0;
background: var(--ctp-mantle);
color: var(--ctp-subtext0);
font-size: 0.7rem;
font-family: var(--term-font-family, monospace);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View file

@ -0,0 +1,534 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// --- Mocks ---
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
// Mock the plugins store to avoid Svelte 5 rune issues in test context
vi.mock('../stores/plugins.svelte', () => {
const commands: Array<{ pluginId: string; label: string; callback: () => void }> = [];
return {
addPluginCommand: vi.fn((pluginId: string, label: string, callback: () => void) => {
commands.push({ pluginId, label, callback });
}),
removePluginCommands: vi.fn((pluginId: string) => {
const toRemove = commands.filter(c => c.pluginId === pluginId);
for (const cmd of toRemove) {
const idx = commands.indexOf(cmd);
if (idx >= 0) commands.splice(idx, 1);
}
}),
pluginEventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
clear: vi.fn(),
},
getPluginCommands: () => [...commands],
};
});
import {
loadPlugin,
unloadPlugin,
getLoadedPlugins,
unloadAllPlugins,
} from './plugin-host';
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
import type { PluginMeta } from '../adapters/plugins-bridge';
import type { GroupId, AgentId } from '../types/ids';
// --- Mock Worker ---
/**
* Simulates a Web Worker that runs the plugin host's worker script.
* Instead of actually creating a Blob + Worker, we intercept postMessage
* and simulate the worker-side logic inline.
*/
class MockWorker {
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: ErrorEvent) => void) | null = null;
private terminated = false;
postMessage(msg: unknown): void {
if (this.terminated) return;
const data = msg as Record<string, unknown>;
if (data.type === 'init') {
this.handleInit(data);
} else if (data.type === 'invoke-callback') {
// Callback invocations from main → worker: no-op in mock
// (the real worker would call the stored callback)
}
}
private handleInit(data: Record<string, unknown>): void {
const code = data.code as string;
const permissions = (data.permissions as string[]) || [];
const meta = data.meta as Record<string, unknown>;
// Build a mock bterminal API that mimics worker-side behavior
// by sending messages back to the main thread (this.sendToMain)
const bterminal: Record<string, unknown> = {
meta: Object.freeze({ ...meta }),
};
if (permissions.includes('palette')) {
let cbId = 0;
bterminal.palette = {
registerCommand: (label: string, callback: () => void) => {
if (typeof label !== 'string' || !label.trim()) {
throw new Error('Command label must be a non-empty string');
}
if (typeof callback !== 'function') {
throw new Error('Command callback must be a function');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'palette-register', label, callbackId: id });
},
};
}
if (permissions.includes('bttask:read')) {
bterminal.tasks = {
list: () => this.rpc('tasks.list', {}),
comments: (taskId: string) => this.rpc('tasks.comments', { taskId }),
};
}
if (permissions.includes('btmsg:read')) {
bterminal.messages = {
inbox: () => this.rpc('messages.inbox', {}),
channels: () => this.rpc('messages.channels', {}),
};
}
if (permissions.includes('events')) {
let cbId = 0;
bterminal.events = {
on: (event: string, callback: (data: unknown) => void) => {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('event.on requires (string, function)');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'event-on', event, callbackId: id });
},
off: (event: string) => {
this.sendToMain({ type: 'event-off', event });
},
};
}
Object.freeze(bterminal);
// Execute the plugin code
try {
const fn = new Function('bterminal', `"use strict"; ${code}`);
fn(bterminal);
this.sendToMain({ type: 'loaded' });
} catch (err) {
this.sendToMain({ type: 'error', message: String(err) });
}
}
private rpcId = 0;
private rpc(method: string, args: Record<string, unknown>): Promise<unknown> {
const id = '__rpc_' + (++this.rpcId);
this.sendToMain({ type: 'rpc', id, method, args });
// In real worker, this would be a pending promise resolved by rpc-result message.
// For tests, return a resolved promise since we test RPC routing separately.
return Promise.resolve([]);
}
private sendToMain(data: unknown): void {
if (this.terminated) return;
// Schedule on microtask to simulate async Worker message delivery
queueMicrotask(() => {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data }));
}
});
}
terminate(): void {
this.terminated = true;
this.onmessage = null;
this.onerror = null;
}
addEventListener(): void { /* stub */ }
removeEventListener(): void { /* stub */ }
dispatchEvent(): boolean { return false; }
}
// Install global Worker mock
const originalWorker = globalThis.Worker;
const originalURL = globalThis.URL;
beforeEach(() => {
vi.clearAllMocks();
unloadAllPlugins();
// Mock Worker constructor
(globalThis as Record<string, unknown>).Worker = MockWorker;
// Mock URL.createObjectURL
if (!globalThis.URL) {
(globalThis as Record<string, unknown>).URL = {} as typeof URL;
}
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-worker-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
(globalThis as Record<string, unknown>).Worker = originalWorker;
if (originalURL) {
globalThis.URL.createObjectURL = originalURL.createObjectURL;
globalThis.URL.revokeObjectURL = originalURL.revokeObjectURL;
}
});
// --- Helpers ---
function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
return {
id: overrides.id ?? 'test-plugin',
name: overrides.name ?? 'Test Plugin',
version: overrides.version ?? '1.0.0',
description: overrides.description ?? 'A test plugin',
main: overrides.main ?? 'index.js',
permissions: overrides.permissions ?? [],
};
}
function mockPluginCode(code: string): void {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve(code);
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
});
}
const GROUP_ID = 'test-group' as GroupId;
const AGENT_ID = 'test-agent' as AgentId;
// --- Worker isolation tests ---
describe('plugin-host Worker isolation', () => {
it('plugin code runs in Worker (cannot access main thread globals)', async () => {
// In a real Worker, window/document/globalThis are unavailable.
// Our MockWorker simulates this by running in strict mode.
const meta = makeMeta({ id: 'isolation-test' });
mockPluginCode('// no-op — isolation verified by Worker boundary');
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('Worker is terminated on unload', async () => {
const meta = makeMeta({ id: 'terminate-test' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(1);
unloadPlugin('terminate-test');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('API object is frozen (cannot add properties)', async () => {
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
mockPluginCode(`
try {
bterminal.hacked = true;
throw new Error('FREEZE FAILED: could add property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could add property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('API object is frozen (cannot delete properties)', async () => {
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
mockPluginCode(`
try {
delete bterminal.meta;
throw new Error('FREEZE FAILED: could delete property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could delete property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('meta is accessible and frozen', async () => {
const meta = makeMeta({ id: 'meta-access', permissions: [] });
mockPluginCode(`
if (bterminal.meta.id !== 'meta-access') {
throw new Error('meta.id mismatch');
}
if (bterminal.meta.name !== 'Test Plugin') {
throw new Error('meta.name mismatch');
}
try {
bterminal.meta.id = 'hacked';
throw new Error('META FREEZE FAILED');
} catch (e) {
if (e.message === 'META FREEZE FAILED') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
// --- Permission-gated API tests ---
describe('plugin-host permissions', () => {
describe('palette permission', () => {
it('plugin with palette permission can register commands', async () => {
const meta = makeMeta({ id: 'palette-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Test Command', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(addPluginCommand).toHaveBeenCalledWith(
'palette-plugin',
'Test Command',
expect.any(Function),
);
});
it('plugin without palette permission has no palette API', async () => {
const meta = makeMeta({ id: 'no-palette-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.palette !== undefined) {
throw new Error('palette API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('palette.registerCommand rejects non-string label', async () => {
const meta = makeMeta({ id: 'bad-label-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand(123, function() {});
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
it('palette.registerCommand rejects non-function callback', async () => {
const meta = makeMeta({ id: 'bad-cb-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Test', 'not-a-function');
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
it('palette.registerCommand rejects empty label', async () => {
const meta = makeMeta({ id: 'empty-label-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand(' ', function() {});
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
});
describe('bttask:read permission', () => {
it('plugin with bttask:read can call tasks.list', async () => {
const meta = makeMeta({ id: 'task-plugin', permissions: ['bttask:read'] });
mockPluginCode(`
bterminal.tasks.list();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('plugin without bttask:read has no tasks API', async () => {
const meta = makeMeta({ id: 'no-task-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.tasks !== undefined) {
throw new Error('tasks API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('btmsg:read permission', () => {
it('plugin with btmsg:read can call messages.inbox', async () => {
const meta = makeMeta({ id: 'msg-plugin', permissions: ['btmsg:read'] });
mockPluginCode(`
bterminal.messages.inbox();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('plugin without btmsg:read has no messages API', async () => {
const meta = makeMeta({ id: 'no-msg-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.messages !== undefined) {
throw new Error('messages API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('events permission', () => {
it('plugin with events permission can subscribe', async () => {
const meta = makeMeta({ id: 'events-plugin', permissions: ['events'] });
mockPluginCode(`
bterminal.events.on('test-event', function(data) {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('plugin without events permission has no events API', async () => {
const meta = makeMeta({ id: 'no-events-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.events !== undefined) {
throw new Error('events API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
});
// --- Lifecycle tests ---
describe('plugin-host lifecycle', () => {
it('loadPlugin registers the plugin', async () => {
const meta = makeMeta({ id: 'lifecycle-load' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
const loaded = getLoadedPlugins();
expect(loaded).toHaveLength(1);
expect(loaded[0].id).toBe('lifecycle-load');
});
it('loadPlugin warns on duplicate load and returns early', async () => {
const meta = makeMeta({ id: 'duplicate-load' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded");
consoleSpy.mockRestore();
expect(getLoadedPlugins()).toHaveLength(1);
});
it('unloadPlugin removes the plugin and cleans up commands', async () => {
const meta = makeMeta({ id: 'lifecycle-unload', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Cmd1', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(1);
unloadPlugin('lifecycle-unload');
expect(getLoadedPlugins()).toHaveLength(0);
expect(removePluginCommands).toHaveBeenCalledWith('lifecycle-unload');
});
it('unloadPlugin is no-op for unknown plugin', () => {
unloadPlugin('nonexistent');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('unloadAllPlugins clears all loaded plugins', async () => {
mockPluginCode('// no-op');
const meta1 = makeMeta({ id: 'all-1' });
await loadPlugin(meta1, GROUP_ID, AGENT_ID);
const meta2 = makeMeta({ id: 'all-2' });
await loadPlugin(meta2, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(2);
unloadAllPlugins();
expect(getLoadedPlugins()).toHaveLength(0);
});
it('loadPlugin cleans up commands on execution error', async () => {
const meta = makeMeta({ id: 'error-cleanup' });
mockPluginCode('throw new Error("plugin crash");');
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
"Plugin 'error-cleanup' execution failed",
);
expect(removePluginCommands).toHaveBeenCalledWith('error-cleanup');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('loadPlugin throws on file read failure', async () => {
const meta = makeMeta({ id: 'read-fail' });
mockInvoke.mockRejectedValue(new Error('file not found'));
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
"Failed to read plugin 'read-fail'",
);
});
it('unloadPlugin cleans up event subscriptions', async () => {
const meta = makeMeta({ id: 'events-cleanup', permissions: ['events'] });
mockPluginCode(`
bterminal.events.on('my-event', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('my-event', expect.any(Function));
unloadPlugin('events-cleanup');
expect(pluginEventBus.off).toHaveBeenCalledWith('my-event', expect.any(Function));
});
});
// --- RPC routing tests ---
describe('plugin-host RPC routing', () => {
it('tasks.list RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
mockPluginCode(`bterminal.tasks.list();`);
// Mock the bttask bridge
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.tasks.list();');
if (cmd === 'bttask_list') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('messages.inbox RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
mockPluginCode(`bterminal.messages.inbox();`);
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.messages.inbox();');
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,339 @@
/**
* Plugin Host Web Worker sandbox for BTerminal plugins.
*
* Each plugin runs in a dedicated Web Worker, providing true process-level
* isolation from the main thread. The Worker has no access to the DOM,
* Tauri IPC, or any main-thread state.
*
* Communication:
* - Main Worker: plugin code, permissions, callback invocations
* - Worker Main: API call proxies (palette, tasks, messages, events)
*
* On unload, the Worker is terminated all plugin state is destroyed.
*/
import type { PluginMeta } from '../adapters/plugins-bridge';
import { readPluginFile } from '../adapters/plugins-bridge';
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
import {
getUnreadMessages,
getChannels,
} from '../adapters/btmsg-bridge';
import {
addPluginCommand,
removePluginCommands,
pluginEventBus,
} from '../stores/plugins.svelte';
import type { GroupId, AgentId } from '../types/ids';
interface LoadedPlugin {
meta: PluginMeta;
worker: Worker;
callbacks: Map<string, () => void>;
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
cleanup: () => void;
}
const loadedPlugins = new Map<string, LoadedPlugin>();
/**
* Build the Worker script as an inline blob.
* The Worker receives plugin code + permissions and builds a sandboxed bterminal API
* that proxies all calls to the main thread via postMessage.
*/
function buildWorkerScript(): string {
return `
"use strict";
// Callback registry for palette commands and event handlers
const _callbacks = new Map();
let _callbackId = 0;
function _nextCallbackId() {
return '__cb_' + (++_callbackId);
}
// Pending RPC calls (for async APIs like tasks.list)
const _pending = new Map();
let _rpcId = 0;
function _rpc(method, args) {
return new Promise((resolve, reject) => {
const id = '__rpc_' + (++_rpcId);
_pending.set(id, { resolve, reject });
self.postMessage({ type: 'rpc', id, method, args });
});
}
// Handle messages from main thread
self.onmessage = function(e) {
const msg = e.data;
if (msg.type === 'init') {
const permissions = msg.permissions || [];
const meta = msg.meta;
// Build the bterminal API based on permissions
const api = { meta: Object.freeze(meta) };
if (permissions.includes('palette')) {
api.palette = {
registerCommand(label, callback) {
if (typeof label !== 'string' || !label.trim()) {
throw new Error('Command label must be a non-empty string');
}
if (typeof callback !== 'function') {
throw new Error('Command callback must be a function');
}
const cbId = _nextCallbackId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
},
};
}
if (permissions.includes('bttask:read')) {
api.tasks = {
list() { return _rpc('tasks.list', {}); },
comments(taskId) { return _rpc('tasks.comments', { taskId }); },
};
}
if (permissions.includes('btmsg:read')) {
api.messages = {
inbox() { return _rpc('messages.inbox', {}); },
channels() { return _rpc('messages.channels', {}); },
};
}
if (permissions.includes('events')) {
api.events = {
on(event, callback) {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('event.on requires (string, function)');
}
const cbId = _nextCallbackId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'event-on', event, callbackId: cbId });
},
off(event, callbackId) {
// Worker-side off is a no-op for now (main thread handles cleanup on terminate)
self.postMessage({ type: 'event-off', event, callbackId });
},
};
}
Object.freeze(api);
// Execute the plugin code
try {
const fn = (0, eval)(
'(function(bterminal) { "use strict"; ' + msg.code + '\\n})'
);
fn(api);
self.postMessage({ type: 'loaded' });
} catch (err) {
self.postMessage({ type: 'error', message: String(err) });
}
}
if (msg.type === 'invoke-callback') {
const cb = _callbacks.get(msg.callbackId);
if (cb) {
try {
cb(msg.data);
} catch (err) {
self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) });
}
}
}
if (msg.type === 'rpc-result') {
const pending = _pending.get(msg.id);
if (pending) {
_pending.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error));
} else {
pending.resolve(msg.result);
}
}
}
};
`;
}
let workerBlobUrl: string | null = null;
function getWorkerBlobUrl(): string {
if (!workerBlobUrl) {
const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
workerBlobUrl = URL.createObjectURL(blob);
}
return workerBlobUrl;
}
/**
* Load and execute a plugin in a Web Worker sandbox.
*/
export async function loadPlugin(
meta: PluginMeta,
groupId: GroupId,
agentId: AgentId,
): Promise<void> {
if (loadedPlugins.has(meta.id)) {
console.warn(`Plugin '${meta.id}' is already loaded`);
return;
}
// Read the plugin's entry file
let code: string;
try {
code = await readPluginFile(meta.id, meta.main);
} catch (e) {
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
}
const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
const callbacks = new Map<string, () => void>();
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
// Set up message handler before sending init
const loadResult = await new Promise<void>((resolve, reject) => {
const onMessage = async (e: MessageEvent) => {
const msg = e.data;
switch (msg.type) {
case 'loaded':
resolve();
break;
case 'error':
// Clean up any commands/events registered before the crash
removePluginCommands(meta.id);
for (const sub of eventSubscriptions) {
pluginEventBus.off(sub.event, sub.handler);
}
worker.terminate();
reject(new Error(`Plugin '${meta.id}' execution failed: ${msg.message}`));
break;
case 'palette-register': {
const cbId = msg.callbackId as string;
const invokeCallback = () => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
};
callbacks.set(cbId, invokeCallback);
addPluginCommand(meta.id, msg.label, invokeCallback);
break;
}
case 'event-on': {
const cbId = msg.callbackId as string;
const handler = (data: unknown) => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
};
eventSubscriptions.push({ event: msg.event, handler });
pluginEventBus.on(msg.event, handler);
break;
}
case 'event-off': {
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
if (idx >= 0) {
pluginEventBus.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
eventSubscriptions.splice(idx, 1);
}
break;
}
case 'rpc': {
const { id, method, args } = msg;
try {
let result: unknown;
switch (method) {
case 'tasks.list':
result = await listTasks(groupId);
break;
case 'tasks.comments':
result = await getTaskComments(args.taskId);
break;
case 'messages.inbox':
result = await getUnreadMessages(agentId);
break;
case 'messages.channels':
result = await getChannels(groupId);
break;
default:
throw new Error(`Unknown RPC method: ${method}`);
}
worker.postMessage({ type: 'rpc-result', id, result });
} catch (err) {
worker.postMessage({
type: 'rpc-result',
id,
error: err instanceof Error ? err.message : String(err),
});
}
break;
}
case 'callback-error':
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
break;
}
};
worker.onmessage = onMessage;
worker.onerror = (err) => {
reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
};
// Send init message with plugin code, permissions, and meta
worker.postMessage({
type: 'init',
code,
permissions: meta.permissions,
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
});
});
// If we get here, the plugin loaded successfully
const cleanup = () => {
removePluginCommands(meta.id);
for (const sub of eventSubscriptions) {
pluginEventBus.off(sub.event, sub.handler);
}
eventSubscriptions.length = 0;
callbacks.clear();
worker.terminate();
};
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
}
/**
* Unload a plugin, terminating its Worker.
*/
export function unloadPlugin(id: string): void {
const plugin = loadedPlugins.get(id);
if (!plugin) return;
plugin.cleanup();
loadedPlugins.delete(id);
}
/**
* Get all currently loaded plugins.
*/
export function getLoadedPlugins(): PluginMeta[] {
return Array.from(loadedPlugins.values()).map(p => p.meta);
}
/**
* Unload all plugins.
*/
export function unloadAllPlugins(): void {
for (const [id] of loadedPlugins) {
unloadPlugin(id);
}
}

View file

@ -0,0 +1,32 @@
// Aider Provider — metadata and capabilities for Aider (OpenRouter / multi-model agent)
import type { ProviderMeta } from './types';
export const AIDER_PROVIDER: ProviderMeta = {
id: 'aider',
name: 'Aider',
description: 'Aider AI coding agent — supports OpenRouter, OpenAI, Anthropic and local models',
capabilities: {
hasProfiles: false,
hasSkills: false,
hasModelSelection: true,
hasSandbox: false,
supportsSubagents: false,
supportsCost: false,
supportsResume: false,
},
sidecarRunner: 'aider-runner.mjs',
defaultModel: 'openrouter/anthropic/claude-sonnet-4',
models: [
{ id: 'openrouter/anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (OpenRouter)' },
{ id: 'openrouter/anthropic/claude-haiku-4', label: 'Claude Haiku 4 (OpenRouter)' },
{ id: 'openrouter/openai/gpt-4.1', label: 'GPT-4.1 (OpenRouter)' },
{ id: 'openrouter/openai/o3', label: 'o3 (OpenRouter)' },
{ id: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro (OpenRouter)' },
{ id: 'openrouter/deepseek/deepseek-r1', label: 'DeepSeek R1 (OpenRouter)' },
{ id: 'openrouter/meta-llama/llama-4-maverick', label: 'Llama 4 Maverick (OpenRouter)' },
{ id: 'anthropic/claude-sonnet-4-5-20250514', label: 'Claude Sonnet 4.5 (direct)' },
{ id: 'o3', label: 'o3 (OpenAI direct)' },
{ id: 'ollama/qwen3:8b', label: 'Qwen3 8B (Ollama)' },
],
};

View file

@ -0,0 +1,25 @@
// 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',
models: [
{ id: 'claude-opus-4-6', label: 'Opus 4.6' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
],
};

View file

@ -0,0 +1,25 @@
// 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',
models: [
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'o3', label: 'o3' },
{ id: 'o4-mini', label: 'o4-mini' },
],
};

View file

@ -0,0 +1,27 @@
// 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',
models: [
{ id: 'qwen3:8b', label: 'Qwen3 8B' },
{ id: 'qwen3:32b', label: 'Qwen3 32B' },
{ id: 'llama3.3:70b', label: 'Llama 3.3 70B' },
{ id: 'deepseek-r1:14b', label: 'DeepSeek R1 14B' },
{ id: 'codellama:13b', label: 'Code Llama 13B' },
],
};

View file

@ -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<ProviderId, ProviderMeta>());
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);
}

View file

@ -0,0 +1,36 @@
// Provider abstraction types — defines the interface for multi-provider agent support
export type ProviderId = 'claude' | 'codex' | 'ollama' | 'aider';
/** 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;
/** Available model presets for dropdown selection */
models?: { id: string; label: string }[];
}
/** Per-provider configuration (stored in settings) */
export interface ProviderSettings {
enabled: boolean;
defaultModel?: string;
/** Provider-specific config blob */
config: Record<string, unknown>;
}

View file

@ -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<AgentSession[]>([]);
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);
}

View file

@ -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<Map<string, SessionAnchor[]>>(new Map());
// Track which projects have had auto-anchoring triggered (prevents re-anchoring on subsequent compactions)
const autoAnchoredProjects = $state<Set<string>>(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<void> {
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<void> {
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<void> {
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<void> {
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],
};
}

View file

@ -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<SessionIdType>;
lastWriteTs: number;
}
// projectId -> filePath -> FileWriteEntry
let projectFileWrites = $state<Map<ProjectIdType, Map<string, FileWriteEntry>>>(new Map());
// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file)
let acknowledgedFiles = $state<Map<ProjectIdType, Set<string>>>(new Map());
// sessionId -> worktree path (null = main working tree)
let sessionWorktrees = $state<Map<SessionIdType, string | null>>(new Map());
// projectId -> filePath -> timestamp of most recent agent write (for external write heuristic)
let agentWriteTimestamps = $state<Map<ProjectIdType, Map<string, number>>>(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();
}

View file

@ -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();
});
});
});

View file

@ -0,0 +1,329 @@
// 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<string, number> = {
'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]>;
/** Number of tasks in 'review' status (for reviewer agents) */
reviewQueueDepth: number;
}
let trackers = $state<Map<ProjectIdType, ProjectTracker>>(new Map());
let stallThresholds = $state<Map<ProjectIdType, number>>(new Map()); // projectId → ms
let tickTs = $state<number>(Date.now());
let tickInterval: ReturnType<typeof setInterval> | 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: [],
reviewQueueDepth: 0,
});
}
/** 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;
}
}
/** Set review queue depth for a project (used by reviewer agents) */
export function setReviewQueueDepth(projectId: ProjectIdType, depth: number): void {
const t = trackers.get(projectId);
if (t) t.reviewQueueDepth = depth;
}
/** 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,
reviewQueueDepth: tracker.reviewQueueDepth,
});
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 };
}

View file

@ -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<Pane[]>([]);
let activePreset = $state<LayoutPreset>('1-col');
let focusedPaneId = $state<string | null>(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<Pane, 'focused'>): 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<void> {
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;
}

View file

@ -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');
});
});
});

View file

@ -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<Machine[]>([]);
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<void> {
try {
machines = await listRemoteMachines();
} catch (e) {
console.warn('Failed to load remote machines:', e);
}
}
export async function addMachine(config: RemoteMachineConfig): Promise<string> {
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<void> {
await removeRemoteMachine(id);
machines = machines.filter(m => m.id !== id);
}
export async function connectMachine(id: string): Promise<void> {
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<void> {
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<void> {
// 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 = [];
}

View file

@ -0,0 +1,152 @@
// Notification store — ephemeral toasts + persistent notification history
import { sendDesktopNotification } from '../adapters/notifications-bridge';
// --- Toast types (existing) ---
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface Toast {
id: string;
type: ToastType;
message: string;
timestamp: number;
}
// --- Notification history types (new) ---
export type NotificationType =
| 'agent_complete'
| 'agent_error'
| 'task_review'
| 'wake_event'
| 'conflict'
| 'system';
export interface HistoryNotification {
id: string;
title: string;
body: string;
type: NotificationType;
timestamp: number;
read: boolean;
projectId?: string;
}
// --- State ---
let toasts = $state<Toast[]>([]);
let notificationHistory = $state<HistoryNotification[]>([]);
const MAX_TOASTS = 5;
const TOAST_DURATION_MS = 4000;
const MAX_HISTORY = 100;
// --- Toast API (preserved from original) ---
export function getNotifications(): Toast[] {
return toasts;
}
export function notify(type: ToastType, message: string): string {
const id = crypto.randomUUID();
toasts.push({ id, type, message, timestamp: Date.now() });
// Cap visible toasts
if (toasts.length > MAX_TOASTS) {
toasts = toasts.slice(-MAX_TOASTS);
}
// Auto-dismiss
setTimeout(() => dismissNotification(id), TOAST_DURATION_MS);
return id;
}
export function dismissNotification(id: string): void {
toasts = toasts.filter(n => n.id !== id);
}
// --- Notification History API (new) ---
/** Map NotificationType to a toast type for the ephemeral toast */
function notificationTypeToToast(type: NotificationType): ToastType {
switch (type) {
case 'agent_complete': return 'success';
case 'agent_error': return 'error';
case 'task_review': return 'info';
case 'wake_event': return 'info';
case 'conflict': return 'warning';
case 'system': return 'info';
}
}
/** Map NotificationType to OS notification urgency */
function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' {
switch (type) {
case 'agent_error': return 'critical';
case 'conflict': return 'normal';
case 'system': return 'normal';
default: return 'low';
}
}
/**
* Add a notification to history, show a toast, and send an OS desktop notification.
*/
export function addNotification(
title: string,
body: string,
type: NotificationType,
projectId?: string,
): string {
const id = crypto.randomUUID();
// Add to history
notificationHistory.push({
id,
title,
body,
type,
timestamp: Date.now(),
read: false,
projectId,
});
// Cap history
if (notificationHistory.length > MAX_HISTORY) {
notificationHistory = notificationHistory.slice(-MAX_HISTORY);
}
// Show ephemeral toast
const toastType = notificationTypeToToast(type);
notify(toastType, `${title}: ${body}`);
// Send OS desktop notification (fire-and-forget)
sendDesktopNotification(title, body, notificationUrgency(type));
return id;
}
export function getNotificationHistory(): HistoryNotification[] {
return notificationHistory;
}
export function getUnreadCount(): number {
return notificationHistory.filter(n => !n.read).length;
}
export function markRead(id: string): void {
const entry = notificationHistory.find(n => n.id === id);
if (entry) entry.read = true;
}
export function markAllRead(): void {
for (const entry of notificationHistory) {
entry.read = true;
}
}
export function clearHistory(): void {
notificationHistory = [];
}

View file

@ -0,0 +1,203 @@
/**
* Plugin store tracks plugin commands, event bus, and plugin state.
* Uses Svelte 5 runes for reactivity.
*/
import type { PluginMeta } from '../adapters/plugins-bridge';
import { discoverPlugins } from '../adapters/plugins-bridge';
import { getSetting, setSetting } from '../adapters/settings-bridge';
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
import type { GroupId, AgentId } from '../types/ids';
// --- Plugin command registry (for CommandPalette) ---
export interface PluginCommand {
pluginId: string;
label: string;
callback: () => void;
}
let commands = $state<PluginCommand[]>([]);
/** Get all plugin-registered commands (reactive). */
export function getPluginCommands(): PluginCommand[] {
return commands;
}
/** Register a command from a plugin. Called by plugin-host. */
export function addPluginCommand(pluginId: string, label: string, callback: () => void): void {
commands = [...commands, { pluginId, label, callback }];
}
/** Remove all commands registered by a specific plugin. Called on unload. */
export function removePluginCommands(pluginId: string): void {
commands = commands.filter(c => c.pluginId !== pluginId);
}
// --- Plugin event bus (simple pub/sub) ---
type EventCallback = (data: unknown) => void;
class PluginEventBusImpl {
private listeners = new Map<string, Set<EventCallback>>();
on(event: string, callback: EventCallback): void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
off(event: string, callback: EventCallback): void {
const set = this.listeners.get(event);
if (set) {
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
}
emit(event: string, data?: unknown): void {
const set = this.listeners.get(event);
if (!set) return;
for (const cb of set) {
try {
cb(data);
} catch (e) {
console.error(`Plugin event handler error for '${event}':`, e);
}
}
}
clear(): void {
this.listeners.clear();
}
}
export const pluginEventBus = new PluginEventBusImpl();
// --- Plugin discovery and lifecycle ---
export type PluginStatus = 'discovered' | 'loaded' | 'error' | 'disabled';
export interface PluginEntry {
meta: PluginMeta;
status: PluginStatus;
error?: string;
}
let pluginEntries = $state<PluginEntry[]>([]);
/** Get all discovered plugins with their status (reactive). */
export function getPluginEntries(): PluginEntry[] {
return pluginEntries;
}
/** Settings key for plugin enabled state */
function pluginEnabledKey(pluginId: string): string {
return `plugin_enabled_${pluginId}`;
}
/** Check if a plugin is enabled in settings (default: true for new plugins) */
async function isPluginEnabled(pluginId: string): Promise<boolean> {
const val = await getSetting(pluginEnabledKey(pluginId));
if (val === null || val === undefined) return true; // enabled by default
return val === 'true' || val === '1';
}
/** Set plugin enabled state */
export async function setPluginEnabled(pluginId: string, enabled: boolean): Promise<void> {
await setSetting(pluginEnabledKey(pluginId), enabled ? 'true' : 'false');
// Update in-memory state
if (enabled) {
const entry = pluginEntries.find(e => e.meta.id === pluginId);
if (entry && entry.status === 'disabled') {
await loadSinglePlugin(entry);
}
} else {
unloadPlugin(pluginId);
pluginEntries = pluginEntries.map(e =>
e.meta.id === pluginId ? { ...e, status: 'disabled' as PluginStatus, error: undefined } : e,
);
}
}
/** Load a single plugin entry, updating its status */
async function loadSinglePlugin(
entry: PluginEntry,
groupId?: GroupId,
agentId?: AgentId,
): Promise<void> {
const gid = groupId ?? ('' as GroupId);
const aid = agentId ?? ('admin' as AgentId);
try {
await loadPlugin(entry.meta, gid, aid);
pluginEntries = pluginEntries.map(e =>
e.meta.id === entry.meta.id ? { ...e, status: 'loaded' as PluginStatus, error: undefined } : e,
);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error(`Failed to load plugin '${entry.meta.id}':`, errorMsg);
pluginEntries = pluginEntries.map(e =>
e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : e,
);
}
}
/**
* Discover and load all enabled plugins.
* Called at app startup or when reloading plugins.
*/
export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise<void> {
// Unload any currently loaded plugins first
unloadAllPlugins();
pluginEventBus.clear();
commands = [];
let discovered: PluginMeta[];
try {
discovered = await discoverPlugins();
} catch (e) {
console.error('Failed to discover plugins:', e);
pluginEntries = [];
return;
}
// Build entries with initial status
const entries: PluginEntry[] = [];
for (const meta of discovered) {
const enabled = await isPluginEnabled(meta.id);
entries.push({
meta,
status: enabled ? 'discovered' : 'disabled',
});
}
pluginEntries = entries;
// Load enabled plugins
for (const entry of pluginEntries) {
if (entry.status === 'discovered') {
await loadSinglePlugin(entry, groupId, agentId);
}
}
}
/**
* Reload all plugins (re-discover and re-load).
*/
export async function reloadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise<void> {
await loadAllPlugins(groupId, agentId);
}
/**
* Clean up all plugins and state.
*/
export function destroyAllPlugins(): void {
unloadAllPlugins();
pluginEventBus.clear();
commands = [];
pluginEntries = [];
}

View file

@ -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<Session[]>([]);
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);
}

View file

@ -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<ThemeId>('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<void> {
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<void> {
return setTheme(flavor);
}
/** Load saved theme from settings DB and apply. Call once on app startup. */
export async function initTheme(): Promise<void> {
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
}
}

Some files were not shown because too many files have changed in this diff Show more