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:
commit
3672e92b7e
272 changed files with 68600 additions and 0 deletions
170
src/lib/adapters/agent-bridge.test.ts
Normal file
170
src/lib/adapters/agent-bridge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
86
src/lib/adapters/agent-bridge.ts
Normal file
86
src/lib/adapters/agent-bridge.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
140
src/lib/adapters/aider-messages.ts
Normal file
140
src/lib/adapters/aider-messages.ts
Normal 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,
|
||||
}];
|
||||
}
|
||||
}
|
||||
25
src/lib/adapters/anchors-bridge.ts
Normal file
25
src/lib/adapters/anchors-bridge.ts
Normal 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 });
|
||||
}
|
||||
57
src/lib/adapters/audit-bridge.ts
Normal file
57
src/lib/adapters/audit-bridge.ts
Normal 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 });
|
||||
}
|
||||
251
src/lib/adapters/btmsg-bridge.test.ts
Normal file
251
src/lib/adapters/btmsg-bridge.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
234
src/lib/adapters/btmsg-bridge.ts
Normal file
234
src/lib/adapters/btmsg-bridge.ts
Normal 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 });
|
||||
}
|
||||
152
src/lib/adapters/bttask-bridge.test.ts
Normal file
152
src/lib/adapters/bttask-bridge.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/lib/adapters/bttask-bridge.ts
Normal file
65
src/lib/adapters/bttask-bridge.ts
Normal 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 });
|
||||
}
|
||||
28
src/lib/adapters/claude-bridge.ts
Normal file
28
src/lib/adapters/claude-bridge.ts
Normal 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 });
|
||||
}
|
||||
446
src/lib/adapters/claude-messages.test.ts
Normal file
446
src/lib/adapters/claude-messages.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
src/lib/adapters/claude-messages.ts
Normal file
257
src/lib/adapters/claude-messages.ts
Normal 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,
|
||||
}];
|
||||
}
|
||||
249
src/lib/adapters/codex-messages.test.ts
Normal file
249
src/lib/adapters/codex-messages.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
291
src/lib/adapters/codex-messages.ts
Normal file
291
src/lib/adapters/codex-messages.ts
Normal 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;
|
||||
}
|
||||
38
src/lib/adapters/ctx-bridge.ts
Normal file
38
src/lib/adapters/ctx-bridge.ts
Normal 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 });
|
||||
}
|
||||
29
src/lib/adapters/file-bridge.ts
Normal file
29
src/lib/adapters/file-bridge.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
26
src/lib/adapters/files-bridge.ts
Normal file
26
src/lib/adapters/files-bridge.ts
Normal 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 });
|
||||
}
|
||||
41
src/lib/adapters/fs-watcher-bridge.ts
Normal file
41
src/lib/adapters/fs-watcher-bridge.ts
Normal 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');
|
||||
}
|
||||
111
src/lib/adapters/groups-bridge.ts
Normal file
111
src/lib/adapters/groups-bridge.ts
Normal 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');
|
||||
}
|
||||
171
src/lib/adapters/memora-bridge.test.ts
Normal file
171
src/lib/adapters/memora-bridge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
122
src/lib/adapters/memora-bridge.ts
Normal file
122
src/lib/adapters/memora-bridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/lib/adapters/memory-adapter.ts
Normal file
52
src/lib/adapters/memory-adapter.ts
Normal 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];
|
||||
}
|
||||
35
src/lib/adapters/message-adapters.ts
Normal file
35
src/lib/adapters/message-adapters.ts
Normal 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);
|
||||
19
src/lib/adapters/notifications-bridge.ts
Normal file
19
src/lib/adapters/notifications-bridge.ts
Normal 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
|
||||
});
|
||||
}
|
||||
153
src/lib/adapters/ollama-messages.test.ts
Normal file
153
src/lib/adapters/ollama-messages.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
141
src/lib/adapters/ollama-messages.ts
Normal file
141
src/lib/adapters/ollama-messages.ts
Normal 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;
|
||||
}
|
||||
22
src/lib/adapters/plugins-bridge.ts
Normal file
22
src/lib/adapters/plugins-bridge.ts
Normal 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 });
|
||||
}
|
||||
26
src/lib/adapters/provider-bridge.ts
Normal file
26
src/lib/adapters/provider-bridge.ts
Normal 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 '';
|
||||
}
|
||||
52
src/lib/adapters/pty-bridge.ts
Normal file
52
src/lib/adapters/pty-bridge.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
180
src/lib/adapters/remote-bridge.ts
Normal file
180
src/lib/adapters/remote-bridge.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
31
src/lib/adapters/search-bridge.ts
Normal file
31
src/lib/adapters/search-bridge.ts
Normal 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 });
|
||||
}
|
||||
40
src/lib/adapters/secrets-bridge.ts
Normal file
40
src/lib/adapters/secrets-bridge.ts
Normal 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',
|
||||
};
|
||||
50
src/lib/adapters/session-bridge.ts
Normal file
50
src/lib/adapters/session-bridge.ts
Normal 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');
|
||||
}
|
||||
13
src/lib/adapters/settings-bridge.ts
Normal file
13
src/lib/adapters/settings-bridge.ts
Normal 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');
|
||||
}
|
||||
26
src/lib/adapters/ssh-bridge.ts
Normal file
26
src/lib/adapters/ssh-bridge.ts
Normal 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 });
|
||||
}
|
||||
26
src/lib/adapters/telemetry-bridge.ts
Normal file
26
src/lib/adapters/telemetry-bridge.ts
Normal 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),
|
||||
};
|
||||
669
src/lib/agent-dispatcher.test.ts
Normal file
669
src/lib/agent-dispatcher.test.ts
Normal 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
364
src/lib/agent-dispatcher.ts
Normal 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();
|
||||
}
|
||||
1566
src/lib/components/Agent/AgentPane.svelte
Normal file
1566
src/lib/components/Agent/AgentPane.svelte
Normal file
File diff suppressed because it is too large
Load diff
173
src/lib/components/Agent/AgentTree.svelte
Normal file
173
src/lib/components/Agent/AgentTree.svelte
Normal 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>
|
||||
146
src/lib/components/Agent/UsageMeter.svelte
Normal file
146
src/lib/components/Agent/UsageMeter.svelte
Normal 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>
|
||||
396
src/lib/components/Context/ContextPane.svelte
Normal file
396
src/lib/components/Context/ContextPane.svelte
Normal 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} <key> <value></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>
|
||||
428
src/lib/components/Markdown/MarkdownPane.svelte
Normal file
428
src/lib/components/Markdown/MarkdownPane.svelte
Normal 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>
|
||||
300
src/lib/components/Notifications/NotificationCenter.svelte
Normal file
300
src/lib/components/Notifications/NotificationCenter.svelte
Normal 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>
|
||||
94
src/lib/components/Notifications/ToastContainer.svelte
Normal file
94
src/lib/components/Notifications/ToastContainer.svelte
Normal 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)}>×</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>
|
||||
138
src/lib/components/SplashScreen.svelte
Normal file
138
src/lib/components/SplashScreen.svelte
Normal 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>
|
||||
375
src/lib/components/StatusBar/StatusBar.svelte
Normal file
375
src/lib/components/StatusBar/StatusBar.svelte
Normal 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>
|
||||
197
src/lib/components/Terminal/AgentPreviewPane.svelte
Normal file
197
src/lib/components/Terminal/AgentPreviewPane.svelte
Normal 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>
|
||||
134
src/lib/components/Terminal/TerminalPane.svelte
Normal file
134
src/lib/components/Terminal/TerminalPane.svelte
Normal 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>
|
||||
100
src/lib/components/Workspace/AgentCard.svelte
Normal file
100
src/lib/components/Workspace/AgentCard.svelte
Normal 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>
|
||||
382
src/lib/components/Workspace/AgentSession.svelte
Normal file
382
src/lib/components/Workspace/AgentSession.svelte
Normal 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>
|
||||
479
src/lib/components/Workspace/ArchitectureTab.svelte
Normal file
479
src/lib/components/Workspace/ArchitectureTab.svelte
Normal 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>
|
||||
300
src/lib/components/Workspace/AuditLogTab.svelte
Normal file
300
src/lib/components/Workspace/AuditLogTab.svelte
Normal 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>
|
||||
332
src/lib/components/Workspace/CodeEditor.svelte
Normal file
332
src/lib/components/Workspace/CodeEditor.svelte
Normal 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>
|
||||
539
src/lib/components/Workspace/CommandPalette.svelte
Normal file
539
src/lib/components/Workspace/CommandPalette.svelte
Normal 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 1–5</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>
|
||||
690
src/lib/components/Workspace/CommsTab.svelte
Normal file
690
src/lib/components/Workspace/CommsTab.svelte
Normal 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>
|
||||
1807
src/lib/components/Workspace/ContextTab.svelte
Normal file
1807
src/lib/components/Workspace/ContextTab.svelte
Normal file
File diff suppressed because it is too large
Load diff
253
src/lib/components/Workspace/CsvTable.svelte
Normal file
253
src/lib/components/Workspace/CsvTable.svelte
Normal 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>
|
||||
160
src/lib/components/Workspace/DocsTab.svelte
Normal file
160
src/lib/components/Workspace/DocsTab.svelte
Normal 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>
|
||||
700
src/lib/components/Workspace/FilesTab.svelte
Normal file
700
src/lib/components/Workspace/FilesTab.svelte
Normal 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>
|
||||
103
src/lib/components/Workspace/GlobalTabBar.svelte
Normal file
103
src/lib/components/Workspace/GlobalTabBar.svelte
Normal 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>
|
||||
433
src/lib/components/Workspace/GroupAgentsPanel.svelte
Normal file
433
src/lib/components/Workspace/GroupAgentsPanel.svelte
Normal 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>
|
||||
375
src/lib/components/Workspace/MemoriesTab.svelte
Normal file
375
src/lib/components/Workspace/MemoriesTab.svelte
Normal 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>
|
||||
808
src/lib/components/Workspace/MetricsPanel.svelte
Normal file
808
src/lib/components/Workspace/MetricsPanel.svelte
Normal 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>
|
||||
205
src/lib/components/Workspace/MetricsPanel.test.ts
Normal file
205
src/lib/components/Workspace/MetricsPanel.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
292
src/lib/components/Workspace/PdfViewer.svelte
Normal file
292
src/lib/components/Workspace/PdfViewer.svelte
Normal 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>
|
||||
542
src/lib/components/Workspace/ProjectBox.svelte
Normal file
542
src/lib/components/Workspace/ProjectBox.svelte
Normal 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>
|
||||
152
src/lib/components/Workspace/ProjectFiles.svelte
Normal file
152
src/lib/components/Workspace/ProjectFiles.svelte
Normal 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>
|
||||
108
src/lib/components/Workspace/ProjectGrid.svelte
Normal file
108
src/lib/components/Workspace/ProjectGrid.svelte
Normal 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>
|
||||
321
src/lib/components/Workspace/ProjectHeader.svelte
Normal file
321
src/lib/components/Workspace/ProjectHeader.svelte
Normal 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>
|
||||
350
src/lib/components/Workspace/SearchOverlay.svelte
Normal file
350
src/lib/components/Workspace/SearchOverlay.svelte
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/<b>/g, '<mark>')
|
||||
.replace(/<\/b>/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>
|
||||
2959
src/lib/components/Workspace/SettingsTab.svelte
Normal file
2959
src/lib/components/Workspace/SettingsTab.svelte
Normal file
File diff suppressed because it is too large
Load diff
425
src/lib/components/Workspace/SshTab.svelte
Normal file
425
src/lib/components/Workspace/SshTab.svelte
Normal 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>
|
||||
582
src/lib/components/Workspace/TaskBoardTab.svelte
Normal file
582
src/lib/components/Workspace/TaskBoardTab.svelte
Normal 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>
|
||||
91
src/lib/components/Workspace/TeamAgentsPanel.svelte
Normal file
91
src/lib/components/Workspace/TeamAgentsPanel.svelte
Normal 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>
|
||||
275
src/lib/components/Workspace/TerminalTabs.svelte
Normal file
275
src/lib/components/Workspace/TerminalTabs.svelte
Normal 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>
|
||||
428
src/lib/components/Workspace/TestingTab.svelte
Normal file
428
src/lib/components/Workspace/TestingTab.svelte
Normal 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>
|
||||
534
src/lib/plugins/plugin-host.test.ts
Normal file
534
src/lib/plugins/plugin-host.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
339
src/lib/plugins/plugin-host.ts
Normal file
339
src/lib/plugins/plugin-host.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/lib/providers/aider.ts
Normal file
32
src/lib/providers/aider.ts
Normal 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)' },
|
||||
],
|
||||
};
|
||||
25
src/lib/providers/claude.ts
Normal file
25
src/lib/providers/claude.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
25
src/lib/providers/codex.ts
Normal file
25
src/lib/providers/codex.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
27
src/lib/providers/ollama.ts
Normal file
27
src/lib/providers/ollama.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
26
src/lib/providers/registry.svelte.ts
Normal file
26
src/lib/providers/registry.svelte.ts
Normal 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);
|
||||
}
|
||||
36
src/lib/providers/types.ts
Normal file
36
src/lib/providers/types.ts
Normal 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>;
|
||||
}
|
||||
148
src/lib/stores/agents.svelte.ts
Normal file
148
src/lib/stores/agents.svelte.ts
Normal 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);
|
||||
}
|
||||
129
src/lib/stores/anchors.svelte.ts
Normal file
129
src/lib/stores/anchors.svelte.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
284
src/lib/stores/conflicts.svelte.ts
Normal file
284
src/lib/stores/conflicts.svelte.ts
Normal 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();
|
||||
}
|
||||
344
src/lib/stores/conflicts.test.ts
Normal file
344
src/lib/stores/conflicts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
329
src/lib/stores/health.svelte.ts
Normal file
329
src/lib/stores/health.svelte.ts
Normal 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 };
|
||||
}
|
||||
193
src/lib/stores/layout.svelte.ts
Normal file
193
src/lib/stores/layout.svelte.ts
Normal 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;
|
||||
}
|
||||
299
src/lib/stores/layout.test.ts
Normal file
299
src/lib/stores/layout.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
131
src/lib/stores/machines.svelte.ts
Normal file
131
src/lib/stores/machines.svelte.ts
Normal 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 = [];
|
||||
}
|
||||
152
src/lib/stores/notifications.svelte.ts
Normal file
152
src/lib/stores/notifications.svelte.ts
Normal 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 = [];
|
||||
}
|
||||
203
src/lib/stores/plugins.svelte.ts
Normal file
203
src/lib/stores/plugins.svelte.ts
Normal 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 = [];
|
||||
}
|
||||
26
src/lib/stores/sessions.svelte.ts
Normal file
26
src/lib/stores/sessions.svelte.ts
Normal 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);
|
||||
}
|
||||
98
src/lib/stores/theme.svelte.ts
Normal file
98
src/lib/stores/theme.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
269
src/lib/stores/wake-scheduler.svelte.ts
Normal file
269
src/lib/stores/wake-scheduler.svelte.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// Wake scheduler — manages per-manager wake timers and signal evaluation
|
||||
// Supports 3 strategies: persistent, on-demand, smart (threshold-gated)
|
||||
|
||||
import type { WakeStrategy, WakeContext, WakeProjectSnapshot, WakeTaskSummary } from '../types/wake';
|
||||
import type { AgentId } from '../types/ids';
|
||||
import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer';
|
||||
import { getAllProjectHealth, getHealthAggregates } from './health.svelte';
|
||||
import { getAllWorkItems } from './workspace.svelte';
|
||||
import { listTasks } from '../adapters/bttask-bridge';
|
||||
import { getAgentSession } from './agents.svelte';
|
||||
import { logAuditEvent } from '../adapters/audit-bridge';
|
||||
import type { GroupId } from '../types/ids';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface ManagerRegistration {
|
||||
agentId: AgentId;
|
||||
groupId: GroupId;
|
||||
sessionId: string;
|
||||
strategy: WakeStrategy;
|
||||
intervalMs: number;
|
||||
threshold: number;
|
||||
timerId: ReturnType<typeof setInterval> | null;
|
||||
/** Burn rate samples for anomaly detection: [timestamp, totalRate] */
|
||||
burnRateSamples: Array<[number, number]>;
|
||||
}
|
||||
|
||||
export interface WakeEvent {
|
||||
agentId: AgentId;
|
||||
strategy: WakeStrategy;
|
||||
context: WakeContext;
|
||||
/** For persistent: resume with context. For on-demand/smart: fresh session with context. */
|
||||
mode: 'resume' | 'fresh';
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
let registrations = $state<Map<string, ManagerRegistration>>(new Map());
|
||||
let pendingWakes = $state<Map<string, WakeEvent>>(new Map());
|
||||
/** When true, registerManager() becomes a no-op (set in test mode) */
|
||||
let schedulerDisabled = false;
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Disable the wake scheduler (call during app init in test mode) */
|
||||
export function disableWakeScheduler(): void {
|
||||
schedulerDisabled = true;
|
||||
clearWakeScheduler();
|
||||
}
|
||||
|
||||
/** Register a Manager agent for wake scheduling */
|
||||
export function registerManager(
|
||||
agentId: AgentId,
|
||||
groupId: GroupId,
|
||||
sessionId: string,
|
||||
strategy: WakeStrategy,
|
||||
intervalMin: number,
|
||||
threshold: number,
|
||||
): void {
|
||||
if (schedulerDisabled) return;
|
||||
|
||||
// Unregister first to clear any existing timer
|
||||
unregisterManager(agentId);
|
||||
|
||||
const reg: ManagerRegistration = {
|
||||
agentId,
|
||||
groupId,
|
||||
sessionId,
|
||||
strategy,
|
||||
intervalMs: intervalMin * 60 * 1000,
|
||||
threshold,
|
||||
timerId: null,
|
||||
burnRateSamples: [],
|
||||
};
|
||||
|
||||
registrations.set(agentId, reg);
|
||||
startTimer(reg);
|
||||
}
|
||||
|
||||
/** Unregister a Manager agent and stop its timer */
|
||||
export function unregisterManager(agentId: string): void {
|
||||
const reg = registrations.get(agentId);
|
||||
if (reg?.timerId) {
|
||||
clearInterval(reg.timerId);
|
||||
}
|
||||
registrations.delete(agentId);
|
||||
pendingWakes.delete(agentId);
|
||||
}
|
||||
|
||||
/** Update wake config for an already-registered manager */
|
||||
export function updateManagerConfig(
|
||||
agentId: string,
|
||||
strategy: WakeStrategy,
|
||||
intervalMin: number,
|
||||
threshold: number,
|
||||
): void {
|
||||
const reg = registrations.get(agentId);
|
||||
if (!reg) return;
|
||||
|
||||
const needsRestart = reg.strategy !== strategy || reg.intervalMs !== intervalMin * 60 * 1000;
|
||||
reg.strategy = strategy;
|
||||
reg.intervalMs = intervalMin * 60 * 1000;
|
||||
reg.threshold = threshold;
|
||||
|
||||
if (needsRestart) {
|
||||
if (reg.timerId) clearInterval(reg.timerId);
|
||||
startTimer(reg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update session ID for a registered manager (e.g., after session reset) */
|
||||
export function updateManagerSession(agentId: string, sessionId: string): void {
|
||||
const reg = registrations.get(agentId);
|
||||
if (reg) {
|
||||
reg.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get pending wake event for a manager (consumed by AgentSession) */
|
||||
export function getWakeEvent(agentId: string): WakeEvent | undefined {
|
||||
return pendingWakes.get(agentId);
|
||||
}
|
||||
|
||||
/** Consume (clear) a pending wake event after AgentSession handles it */
|
||||
export function consumeWakeEvent(agentId: string): void {
|
||||
pendingWakes.delete(agentId);
|
||||
}
|
||||
|
||||
/** Get all registered managers (for debugging/UI) */
|
||||
export function getRegisteredManagers(): Array<{
|
||||
agentId: string;
|
||||
strategy: WakeStrategy;
|
||||
intervalMin: number;
|
||||
threshold: number;
|
||||
hasPendingWake: boolean;
|
||||
}> {
|
||||
const result: Array<{
|
||||
agentId: string;
|
||||
strategy: WakeStrategy;
|
||||
intervalMin: number;
|
||||
threshold: number;
|
||||
hasPendingWake: boolean;
|
||||
}> = [];
|
||||
for (const [id, reg] of registrations) {
|
||||
result.push({
|
||||
agentId: id,
|
||||
strategy: reg.strategy,
|
||||
intervalMin: reg.intervalMs / 60_000,
|
||||
threshold: reg.threshold,
|
||||
hasPendingWake: pendingWakes.has(id),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Force a manual wake evaluation for a manager (for testing/UI) */
|
||||
export function forceWake(agentId: string): void {
|
||||
const reg = registrations.get(agentId);
|
||||
if (reg) {
|
||||
evaluateAndEmit(reg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all registrations (for workspace teardown) */
|
||||
export function clearWakeScheduler(): void {
|
||||
for (const reg of registrations.values()) {
|
||||
if (reg.timerId) clearInterval(reg.timerId);
|
||||
}
|
||||
registrations = new Map();
|
||||
pendingWakes = new Map();
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
||||
function startTimer(reg: ManagerRegistration): void {
|
||||
reg.timerId = setInterval(() => {
|
||||
evaluateAndEmit(reg);
|
||||
}, reg.intervalMs);
|
||||
}
|
||||
|
||||
async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
|
||||
// Don't queue a new wake if one is already pending
|
||||
if (pendingWakes.has(reg.agentId)) return;
|
||||
|
||||
// For persistent strategy, skip if session is actively running a query
|
||||
if (reg.strategy === 'persistent') {
|
||||
const session = getAgentSession(reg.sessionId);
|
||||
if (session && session.status === 'running') return;
|
||||
}
|
||||
|
||||
// Build project snapshots from health store
|
||||
const healthItems = getAllProjectHealth();
|
||||
const workItems = getAllWorkItems();
|
||||
const projectSnapshots: WakeProjectSnapshot[] = healthItems.map(h => {
|
||||
const workItem = workItems.find(w => w.id === h.projectId);
|
||||
return {
|
||||
projectId: h.projectId,
|
||||
projectName: workItem?.name ?? String(h.projectId),
|
||||
activityState: h.activityState,
|
||||
idleMinutes: Math.floor(h.idleDurationMs / 60_000),
|
||||
burnRatePerHour: h.burnRatePerHour,
|
||||
contextPressurePercent: h.contextPressure !== null ? Math.round(h.contextPressure * 100) : null,
|
||||
fileConflicts: h.fileConflictCount + h.externalConflictCount,
|
||||
attentionScore: h.attentionScore,
|
||||
attentionReason: h.attentionReason,
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch task summary (best-effort)
|
||||
let taskSummary: WakeTaskSummary | undefined;
|
||||
try {
|
||||
const tasks = await listTasks(reg.groupId);
|
||||
taskSummary = {
|
||||
total: tasks.length,
|
||||
todo: tasks.filter(t => t.status === 'todo').length,
|
||||
inProgress: tasks.filter(t => t.status === 'progress').length,
|
||||
blocked: tasks.filter(t => t.status === 'blocked').length,
|
||||
review: tasks.filter(t => t.status === 'review').length,
|
||||
done: tasks.filter(t => t.status === 'done').length,
|
||||
};
|
||||
} catch {
|
||||
// bttask may not be available — continue without task data
|
||||
}
|
||||
|
||||
// Compute average burn rate for anomaly detection
|
||||
const aggregates = getHealthAggregates();
|
||||
const now = Date.now();
|
||||
reg.burnRateSamples.push([now, aggregates.totalBurnRatePerHour]);
|
||||
// Keep 1 hour of samples
|
||||
const hourAgo = now - 3_600_000;
|
||||
reg.burnRateSamples = reg.burnRateSamples.filter(([ts]) => ts > hourAgo);
|
||||
const averageBurnRate = reg.burnRateSamples.length > 1
|
||||
? reg.burnRateSamples.reduce((sum, [, r]) => sum + r, 0) / reg.burnRateSamples.length
|
||||
: undefined;
|
||||
|
||||
// Evaluate signals
|
||||
const evaluation = evaluateWakeSignals({
|
||||
projects: projectSnapshots,
|
||||
taskSummary,
|
||||
averageBurnRate,
|
||||
});
|
||||
|
||||
// Check if we should actually wake based on strategy
|
||||
if (!shouldWake(evaluation, reg.strategy, reg.threshold)) return;
|
||||
|
||||
// Build wake context
|
||||
const context: WakeContext = {
|
||||
evaluation,
|
||||
projectSnapshots,
|
||||
taskSummary,
|
||||
};
|
||||
|
||||
// Determine mode
|
||||
const mode: 'resume' | 'fresh' = reg.strategy === 'persistent' ? 'resume' : 'fresh';
|
||||
|
||||
pendingWakes.set(reg.agentId, {
|
||||
agentId: reg.agentId,
|
||||
strategy: reg.strategy,
|
||||
context,
|
||||
mode,
|
||||
});
|
||||
|
||||
// Audit: log wake event
|
||||
logAuditEvent(
|
||||
reg.agentId,
|
||||
'wake_event',
|
||||
`Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`,
|
||||
).catch(() => {});
|
||||
}
|
||||
357
src/lib/stores/workspace.svelte.ts
Normal file
357
src/lib/stores/workspace.svelte.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
|
||||
import { agentToProject } from '../types/groups';
|
||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||
import { clearHealthTracking } from '../stores/health.svelte';
|
||||
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
|
||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||
import { registerAgents } from '../adapters/btmsg-bridge';
|
||||
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'shell' | 'ssh' | 'agent-terminal' | 'agent-preview';
|
||||
/** SSH session ID if type === 'ssh' */
|
||||
sshSessionId?: string;
|
||||
/** Agent session ID if type === 'agent-preview' */
|
||||
agentSessionId?: string;
|
||||
}
|
||||
|
||||
// --- Core state ---
|
||||
|
||||
let groupsConfig = $state<GroupsFile | null>(null);
|
||||
let activeGroupId = $state<string>('');
|
||||
let activeTab = $state<WorkspaceTab>('sessions');
|
||||
let activeProjectId = $state<string | null>(null);
|
||||
|
||||
/** Terminal tabs per project (keyed by project ID) */
|
||||
let projectTerminals = $state<Record<string, TerminalTab[]>>({});
|
||||
|
||||
// --- Focus flash event (keyboard quick-jump visual feedback) ---
|
||||
|
||||
let focusFlashProjectId = $state<string | null>(null);
|
||||
|
||||
export function getFocusFlashProjectId(): string | null {
|
||||
return focusFlashProjectId;
|
||||
}
|
||||
|
||||
export function triggerFocusFlash(projectId: string): void {
|
||||
focusFlashProjectId = projectId;
|
||||
// Auto-clear after animation duration
|
||||
setTimeout(() => {
|
||||
focusFlashProjectId = null;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// --- Project tab switching (keyboard-driven) ---
|
||||
|
||||
type ProjectTabSwitchCallback = (projectId: string, tabIndex: number) => void;
|
||||
let projectTabSwitchCallbacks: ProjectTabSwitchCallback[] = [];
|
||||
|
||||
export function onProjectTabSwitch(cb: ProjectTabSwitchCallback): () => void {
|
||||
projectTabSwitchCallbacks.push(cb);
|
||||
return () => {
|
||||
projectTabSwitchCallbacks = projectTabSwitchCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitProjectTabSwitch(projectId: string, tabIndex: number): void {
|
||||
for (const cb of projectTabSwitchCallbacks) {
|
||||
cb(projectId, tabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Terminal toggle (keyboard-driven) ---
|
||||
|
||||
type TerminalToggleCallback = (projectId: string) => void;
|
||||
let terminalToggleCallbacks: TerminalToggleCallback[] = [];
|
||||
|
||||
export function onTerminalToggle(cb: TerminalToggleCallback): () => void {
|
||||
terminalToggleCallbacks.push(cb);
|
||||
return () => {
|
||||
terminalToggleCallbacks = terminalToggleCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitTerminalToggle(projectId: string): void {
|
||||
for (const cb of terminalToggleCallbacks) {
|
||||
cb(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agent start event (play button in GroupAgentsPanel) ---
|
||||
|
||||
type AgentStartCallback = (projectId: string) => void;
|
||||
let agentStartCallbacks: AgentStartCallback[] = [];
|
||||
|
||||
export function onAgentStart(cb: AgentStartCallback): () => void {
|
||||
agentStartCallbacks.push(cb);
|
||||
return () => {
|
||||
agentStartCallbacks = agentStartCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitAgentStart(projectId: string): void {
|
||||
for (const cb of agentStartCallbacks) {
|
||||
cb(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agent stop event (stop button in GroupAgentsPanel) ---
|
||||
|
||||
type AgentStopCallback = (projectId: string) => void;
|
||||
let agentStopCallbacks: AgentStopCallback[] = [];
|
||||
|
||||
export function onAgentStop(cb: AgentStopCallback): () => void {
|
||||
agentStopCallbacks.push(cb);
|
||||
return () => {
|
||||
agentStopCallbacks = agentStopCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitAgentStop(projectId: string): void {
|
||||
for (const cb of agentStopCallbacks) {
|
||||
cb(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Getters ---
|
||||
|
||||
export function getGroupsConfig(): GroupsFile | null {
|
||||
return groupsConfig;
|
||||
}
|
||||
|
||||
export function getActiveGroupId(): string {
|
||||
return activeGroupId;
|
||||
}
|
||||
|
||||
export function getActiveTab(): WorkspaceTab {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
export function getActiveProjectId(): string | null {
|
||||
return activeProjectId;
|
||||
}
|
||||
|
||||
export function getActiveGroup(): GroupConfig | undefined {
|
||||
return groupsConfig?.groups.find(g => g.id === activeGroupId);
|
||||
}
|
||||
|
||||
export function getEnabledProjects(): ProjectConfig[] {
|
||||
const group = getActiveGroup();
|
||||
if (!group) return [];
|
||||
return group.projects.filter(p => p.enabled);
|
||||
}
|
||||
|
||||
/** Get all work items: enabled projects + agents as virtual project entries */
|
||||
export function getAllWorkItems(): ProjectConfig[] {
|
||||
const group = getActiveGroup();
|
||||
if (!group) return [];
|
||||
const projects = group.projects.filter(p => p.enabled);
|
||||
const agentProjects = (group.agents ?? [])
|
||||
.filter(a => a.enabled)
|
||||
.map(a => {
|
||||
// Use first project's parent dir as default CWD for agents
|
||||
const groupCwd = projects[0]?.cwd?.replace(/\/[^/]+\/?$/, '/') ?? '/tmp';
|
||||
return agentToProject(a, groupCwd);
|
||||
});
|
||||
return [...agentProjects, ...projects];
|
||||
}
|
||||
|
||||
export function getAllGroups(): GroupConfig[] {
|
||||
return groupsConfig?.groups ?? [];
|
||||
}
|
||||
|
||||
// --- Setters ---
|
||||
|
||||
export function setActiveTab(tab: WorkspaceTab): void {
|
||||
activeTab = tab;
|
||||
}
|
||||
|
||||
export function setActiveProject(projectId: string | null): void {
|
||||
activeProjectId = projectId;
|
||||
}
|
||||
|
||||
export async function switchGroup(groupId: string): Promise<void> {
|
||||
if (groupId === activeGroupId) return;
|
||||
|
||||
// Wait for any in-flight persistence before clearing state
|
||||
await waitForPendingPersistence();
|
||||
|
||||
// Teardown: clear terminal tabs, agent sessions, health tracking, and wake schedulers for the old group
|
||||
projectTerminals = {};
|
||||
clearAllAgentSessions();
|
||||
clearHealthTracking();
|
||||
clearAllConflicts();
|
||||
clearWakeScheduler();
|
||||
|
||||
activeGroupId = groupId;
|
||||
activeProjectId = null;
|
||||
|
||||
// Auto-focus first enabled project
|
||||
const projects = getEnabledProjects();
|
||||
if (projects.length > 0) {
|
||||
activeProjectId = projects[0].id;
|
||||
}
|
||||
|
||||
// Persist active group
|
||||
if (groupsConfig) {
|
||||
groupsConfig.activeGroupId = groupId;
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Terminal tab management per project ---
|
||||
|
||||
export function getTerminalTabs(projectId: string): TerminalTab[] {
|
||||
return projectTerminals[projectId] ?? [];
|
||||
}
|
||||
|
||||
export function addTerminalTab(projectId: string, tab: TerminalTab): void {
|
||||
const tabs = projectTerminals[projectId] ?? [];
|
||||
projectTerminals[projectId] = [...tabs, tab];
|
||||
}
|
||||
|
||||
export function removeTerminalTab(projectId: string, tabId: string): void {
|
||||
const tabs = projectTerminals[projectId] ?? [];
|
||||
projectTerminals[projectId] = tabs.filter(t => t.id !== tabId);
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
export async function loadWorkspace(initialGroupId?: string): Promise<void> {
|
||||
try {
|
||||
const config = await loadGroups();
|
||||
groupsConfig = config;
|
||||
projectTerminals = {};
|
||||
|
||||
// Register all agents from config into btmsg database
|
||||
// (creates agent records, contact permissions, review channels)
|
||||
registerAgents(config).catch(e => console.warn('Failed to register agents:', e));
|
||||
|
||||
// CLI --group flag takes priority, then explicit param, then persisted
|
||||
let cliGroup: string | null = null;
|
||||
if (!initialGroupId) {
|
||||
cliGroup = await getCliGroup();
|
||||
}
|
||||
const targetId = initialGroupId || cliGroup || config.activeGroupId;
|
||||
// Match by ID or by name (CLI users may pass name)
|
||||
const targetGroup = config.groups.find(
|
||||
g => g.id === targetId || g.name === targetId,
|
||||
);
|
||||
|
||||
if (targetGroup) {
|
||||
activeGroupId = targetGroup.id;
|
||||
} else if (config.groups.length > 0) {
|
||||
activeGroupId = config.groups[0].id;
|
||||
}
|
||||
|
||||
// Auto-focus first enabled project
|
||||
const projects = getEnabledProjects();
|
||||
if (projects.length > 0) {
|
||||
activeProjectId = projects[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load groups config:', e);
|
||||
groupsConfig = { version: 1, groups: [], activeGroupId: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWorkspace(): Promise<void> {
|
||||
if (!groupsConfig) return;
|
||||
await saveGroups(groupsConfig);
|
||||
// Re-register agents after config changes (new agents, permission updates)
|
||||
registerAgents(groupsConfig).catch(e => console.warn('Failed to register agents:', e));
|
||||
}
|
||||
|
||||
// --- Group/project mutation ---
|
||||
|
||||
export function addGroup(group: GroupConfig): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: [...groupsConfig.groups, group],
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function removeGroup(groupId: string): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.filter(g => g.id !== groupId),
|
||||
};
|
||||
if (activeGroupId === groupId) {
|
||||
activeGroupId = groupsConfig.groups[0]?.id ?? '';
|
||||
activeProjectId = null;
|
||||
}
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function updateProject(groupId: string, projectId: string, updates: Partial<ProjectConfig>): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.map(g => {
|
||||
if (g.id !== groupId) return g;
|
||||
return {
|
||||
...g,
|
||||
projects: g.projects.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, ...updates };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function addProject(groupId: string, project: ProjectConfig): void {
|
||||
if (!groupsConfig) return;
|
||||
const group = groupsConfig.groups.find(g => g.id === groupId);
|
||||
if (!group || group.projects.length >= 5) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.map(g => {
|
||||
if (g.id !== groupId) return g;
|
||||
return { ...g, projects: [...g.projects, project] };
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function removeProject(groupId: string, projectId: string): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.map(g => {
|
||||
if (g.id !== groupId) return g;
|
||||
return { ...g, projects: g.projects.filter(p => p.id !== projectId) };
|
||||
}),
|
||||
};
|
||||
if (activeProjectId === projectId) {
|
||||
activeProjectId = null;
|
||||
}
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.map(g => {
|
||||
if (g.id !== groupId) return g;
|
||||
return {
|
||||
...g,
|
||||
agents: (g.agents ?? []).map(a => {
|
||||
if (a.id !== agentId) return a;
|
||||
return { ...a, ...updates };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
251
src/lib/stores/workspace.test.ts
Normal file
251
src/lib/stores/workspace.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock groups-bridge before importing the workspace store
|
||||
function mockGroupsData() {
|
||||
return {
|
||||
version: 1,
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Group One',
|
||||
projects: [
|
||||
{ id: 'p1', name: 'Project 1', identifier: 'project-1', description: '', icon: '', cwd: '/tmp/p1', profile: 'default', enabled: true },
|
||||
{ id: 'p2', name: 'Project 2', identifier: 'project-2', description: '', icon: '', cwd: '/tmp/p2', profile: 'default', enabled: true },
|
||||
{ id: 'p3', name: 'Disabled', identifier: 'disabled', description: '', icon: '', cwd: '/tmp/p3', profile: 'default', enabled: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
name: 'Group Two',
|
||||
projects: [
|
||||
{ id: 'p4', name: 'Project 4', identifier: 'project-4', description: '', icon: '', cwd: '/tmp/p4', profile: 'default', enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeGroupId: 'g1',
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../stores/agents.svelte', () => ({
|
||||
clearAllAgentSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/conflicts.svelte', () => ({
|
||||
clearAllConflicts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agent-dispatcher', () => ({
|
||||
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../adapters/groups-bridge', () => ({
|
||||
loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())),
|
||||
saveGroups: vi.fn().mockResolvedValue(undefined),
|
||||
getCliGroup: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import {
|
||||
getGroupsConfig,
|
||||
getActiveGroupId,
|
||||
getActiveTab,
|
||||
getActiveProjectId,
|
||||
getActiveGroup,
|
||||
getEnabledProjects,
|
||||
getAllGroups,
|
||||
setActiveTab,
|
||||
setActiveProject,
|
||||
switchGroup,
|
||||
getTerminalTabs,
|
||||
addTerminalTab,
|
||||
removeTerminalTab,
|
||||
loadWorkspace,
|
||||
addGroup,
|
||||
removeGroup,
|
||||
updateProject,
|
||||
addProject,
|
||||
removeProject,
|
||||
} from './workspace.svelte';
|
||||
|
||||
import { saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset state by reloading
|
||||
await loadWorkspace();
|
||||
});
|
||||
|
||||
describe('workspace store', () => {
|
||||
describe('loadWorkspace', () => {
|
||||
it('loads groups config and sets active group', async () => {
|
||||
expect(getGroupsConfig()).not.toBeNull();
|
||||
expect(getActiveGroupId()).toBe('g1');
|
||||
});
|
||||
|
||||
it('auto-focuses first enabled project', async () => {
|
||||
expect(getActiveProjectId()).toBe('p1');
|
||||
});
|
||||
|
||||
it('accepts initialGroupId override', async () => {
|
||||
await loadWorkspace('g2');
|
||||
expect(getActiveGroupId()).toBe('g2');
|
||||
expect(getActiveProjectId()).toBe('p4');
|
||||
});
|
||||
|
||||
it('falls back to first group if target not found', async () => {
|
||||
await loadWorkspace('nonexistent');
|
||||
expect(getActiveGroupId()).toBe('g1');
|
||||
});
|
||||
|
||||
it('uses CLI --group flag when no initialGroupId given', async () => {
|
||||
vi.mocked(getCliGroup).mockResolvedValueOnce('Group Two');
|
||||
await loadWorkspace();
|
||||
expect(getActiveGroupId()).toBe('g2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters', () => {
|
||||
it('getActiveGroup returns the active group config', () => {
|
||||
const group = getActiveGroup();
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.id).toBe('g1');
|
||||
expect(group!.name).toBe('Group One');
|
||||
});
|
||||
|
||||
it('getEnabledProjects filters disabled projects', () => {
|
||||
const projects = getEnabledProjects();
|
||||
expect(projects).toHaveLength(2);
|
||||
expect(projects.map(p => p.id)).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('getAllGroups returns all groups', () => {
|
||||
const groups = getAllGroups();
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setters', () => {
|
||||
it('setActiveTab changes the active tab', () => {
|
||||
setActiveTab('docs');
|
||||
expect(getActiveTab()).toBe('docs');
|
||||
setActiveTab('sessions');
|
||||
expect(getActiveTab()).toBe('sessions');
|
||||
});
|
||||
|
||||
it('setActiveProject changes the active project', () => {
|
||||
setActiveProject('p2');
|
||||
expect(getActiveProjectId()).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchGroup', () => {
|
||||
it('switches to a different group and auto-focuses first project', async () => {
|
||||
await switchGroup('g2');
|
||||
expect(getActiveGroupId()).toBe('g2');
|
||||
expect(getActiveProjectId()).toBe('p4');
|
||||
});
|
||||
|
||||
it('clears terminal tabs on group switch', async () => {
|
||||
addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' });
|
||||
expect(getTerminalTabs('p1')).toHaveLength(1);
|
||||
|
||||
await switchGroup('g2');
|
||||
expect(getTerminalTabs('p1')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('no-ops when switching to current group', async () => {
|
||||
const projectBefore = getActiveProjectId();
|
||||
vi.mocked(saveGroups).mockClear();
|
||||
await switchGroup('g1');
|
||||
// State should remain unchanged
|
||||
expect(getActiveGroupId()).toBe('g1');
|
||||
expect(getActiveProjectId()).toBe(projectBefore);
|
||||
expect(saveGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists active group', async () => {
|
||||
await switchGroup('g2');
|
||||
expect(saveGroups).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal tabs', () => {
|
||||
it('adds and retrieves terminal tabs per project', () => {
|
||||
addTerminalTab('p1', { id: 't1', title: 'Shell 1', type: 'shell' });
|
||||
addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' });
|
||||
addTerminalTab('p2', { id: 't3', title: 'SSH', type: 'ssh', sshSessionId: 'ssh1' });
|
||||
|
||||
expect(getTerminalTabs('p1')).toHaveLength(2);
|
||||
expect(getTerminalTabs('p2')).toHaveLength(1);
|
||||
expect(getTerminalTabs('p2')[0].sshSessionId).toBe('ssh1');
|
||||
});
|
||||
|
||||
it('removes terminal tabs by id', () => {
|
||||
addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' });
|
||||
addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' });
|
||||
|
||||
removeTerminalTab('p1', 't1');
|
||||
expect(getTerminalTabs('p1')).toHaveLength(1);
|
||||
expect(getTerminalTabs('p1')[0].id).toBe('t2');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown project', () => {
|
||||
expect(getTerminalTabs('unknown')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group mutation', () => {
|
||||
it('addGroup adds a new group', () => {
|
||||
addGroup({ id: 'g3', name: 'New Group', projects: [] });
|
||||
expect(getAllGroups()).toHaveLength(3);
|
||||
expect(saveGroups).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeGroup removes the group and resets active if needed', () => {
|
||||
removeGroup('g1');
|
||||
expect(getAllGroups()).toHaveLength(1);
|
||||
expect(getActiveGroupId()).toBe('g2');
|
||||
});
|
||||
|
||||
it('removeGroup with non-active group keeps active unchanged', () => {
|
||||
removeGroup('g2');
|
||||
expect(getAllGroups()).toHaveLength(1);
|
||||
expect(getActiveGroupId()).toBe('g1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('project mutation', () => {
|
||||
it('updateProject updates project fields', () => {
|
||||
updateProject('g1', 'p1', { name: 'Renamed' });
|
||||
const group = getActiveGroup()!;
|
||||
expect(group.projects.find(p => p.id === 'p1')!.name).toBe('Renamed');
|
||||
expect(saveGroups).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addProject adds a project to a group', () => {
|
||||
addProject('g1', {
|
||||
id: 'p5', name: 'New', identifier: 'new', description: '',
|
||||
icon: '', cwd: '/tmp', profile: 'default', enabled: true,
|
||||
});
|
||||
const group = getActiveGroup()!;
|
||||
expect(group.projects).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('addProject respects 5-project limit', () => {
|
||||
// g1 already has 3 projects, add 2 more to reach 5
|
||||
addProject('g1', { id: 'x1', name: 'X1', identifier: 'x1', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true });
|
||||
addProject('g1', { id: 'x2', name: 'X2', identifier: 'x2', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true });
|
||||
// This 6th should be rejected
|
||||
addProject('g1', { id: 'x3', name: 'X3', identifier: 'x3', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true });
|
||||
const group = getActiveGroup()!;
|
||||
expect(group.projects).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('removeProject removes and clears activeProjectId if needed', () => {
|
||||
setActiveProject('p1');
|
||||
removeProject('g1', 'p1');
|
||||
expect(getActiveProjectId()).toBeNull();
|
||||
const group = getActiveGroup()!;
|
||||
expect(group.projects.find(p => p.id === 'p1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue