feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
2675
ui-electrobun/package-lock.json
generated
Normal file
2675
ui-electrobun/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,11 +11,26 @@
|
||||||
"build:canary": "vite build && electrobun build --env=canary"
|
"build:canary": "vite build && electrobun build --env=canary"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.40.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-image": "^0.9.0",
|
"@xterm/addon-image": "^0.9.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"electrobun": "latest"
|
"electrobun": "latest",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.1",
|
"@sveltejs/vite-plugin-svelte": "^5.0.1",
|
||||||
|
|
|
||||||
379
ui-electrobun/src/bun/btmsg-db.ts
Normal file
379
ui-electrobun/src/bun/btmsg-db.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
/**
|
||||||
|
* btmsg — Inter-agent messaging SQLite store.
|
||||||
|
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg CLI + bttask).
|
||||||
|
* Uses bun:sqlite. Schema matches Rust btmsg.rs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||||
|
const DB_PATH = join(DATA_DIR, "btmsg.db");
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BtmsgAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
groupId: string;
|
||||||
|
tier: number;
|
||||||
|
model: string | null;
|
||||||
|
status: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BtmsgMessage {
|
||||||
|
id: string;
|
||||||
|
fromAgent: string;
|
||||||
|
toAgent: string;
|
||||||
|
content: string;
|
||||||
|
read: boolean;
|
||||||
|
replyTo: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string | null;
|
||||||
|
senderRole: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BtmsgChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
groupId: string;
|
||||||
|
createdBy: string;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BtmsgChannelMessage {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
fromAgent: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string;
|
||||||
|
senderRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadLetter {
|
||||||
|
id: number;
|
||||||
|
fromAgent: string;
|
||||||
|
toAgent: string;
|
||||||
|
content: string;
|
||||||
|
error: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
agentId: string;
|
||||||
|
eventType: string;
|
||||||
|
detail: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema (create-if-absent, matches Rust open_db_or_create) ────────────────
|
||||||
|
|
||||||
|
const SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
||||||
|
group_id TEXT NOT NULL, tier INTEGER NOT NULL DEFAULT 2,
|
||||||
|
model TEXT, cwd TEXT, system_prompt TEXT,
|
||||||
|
status TEXT DEFAULT 'stopped', last_active_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
agent_id TEXT NOT NULL, contact_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (agent_id, contact_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, read INTEGER DEFAULT 0, reply_to TEXT,
|
||||||
|
group_id TEXT NOT NULL, sender_group_id TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent, read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_agent);
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_members (
|
||||||
|
channel_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||||
|
joined_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (channel_id, agent_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_messages (
|
||||||
|
id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, from_agent TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_messages ON channel_messages(channel_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS heartbeats (
|
||||||
|
agent_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS dead_letter_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, from_agent TEXT NOT NULL,
|
||||||
|
to_agent TEXT NOT NULL, content TEXT NOT NULL, error TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL, detail TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS seen_messages (
|
||||||
|
session_id TEXT NOT NULL, message_id TEXT NOT NULL,
|
||||||
|
seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (session_id, message_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seen_messages_session ON seen_messages(session_id);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Also create tasks/task_comments (shared DB with bttask)
|
||||||
|
const TASK_SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
|
||||||
|
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||||
|
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||||
|
CREATE TABLE IF NOT EXISTS task_comments (
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── BtmsgDb class ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BtmsgDb {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
this.db = new Database(DB_PATH);
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||||
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
this.db.exec(SCHEMA);
|
||||||
|
this.db.exec(TASK_SCHEMA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerAgent(
|
||||||
|
id: string, name: string, role: string,
|
||||||
|
groupId: string, tier: number, model?: string,
|
||||||
|
): void {
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO agents (id, name, role, group_id, tier, model)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name=excluded.name, role=excluded.role,
|
||||||
|
group_id=excluded.group_id, tier=excluded.tier, model=excluded.model`
|
||||||
|
).run(id, name, role, groupId, tier, model ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgents(groupId: string): BtmsgAgent[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; name: string; role: string; group_id: string;
|
||||||
|
tier: number; model: string | null; status: string | null;
|
||||||
|
unread_count: number;
|
||||||
|
}, [string]>(
|
||||||
|
`SELECT a.*, (SELECT COUNT(*) FROM messages m
|
||||||
|
WHERE m.to_agent = a.id AND m.read = 0) as unread_count
|
||||||
|
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name`
|
||||||
|
).all(groupId).map(r => ({
|
||||||
|
id: r.id, name: r.name, role: r.role, groupId: r.group_id,
|
||||||
|
tier: r.tier, model: r.model, status: r.status ?? 'stopped',
|
||||||
|
unreadCount: r.unread_count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Direct messages ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sendMessage(fromAgent: string, toAgent: string, content: string): string {
|
||||||
|
// Get sender's group_id
|
||||||
|
const sender = this.db.query<{ group_id: string }, [string]>(
|
||||||
|
"SELECT group_id FROM agents WHERE id = ?"
|
||||||
|
).get(fromAgent);
|
||||||
|
if (!sender) throw new Error(`Sender agent '${fromAgent}' not found`);
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
|
||||||
|
).run(id, fromAgent, toAgent, content, sender.group_id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
listMessages(agentId: string, otherId: string, limit = 50): BtmsgMessage[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; from_agent: string; to_agent: string; content: string;
|
||||||
|
read: number; reply_to: string | null; created_at: string;
|
||||||
|
sender_name: string | null; sender_role: string | null;
|
||||||
|
}, [string, string, string, string, number]>(
|
||||||
|
`SELECT m.id, m.from_agent, m.to_agent, m.content, m.read,
|
||||||
|
m.reply_to, m.created_at,
|
||||||
|
a.name AS sender_name, a.role AS sender_role
|
||||||
|
FROM messages m JOIN agents a ON m.from_agent = a.id
|
||||||
|
WHERE (m.from_agent = ?1 AND m.to_agent = ?2)
|
||||||
|
OR (m.from_agent = ?3 AND m.to_agent = ?4)
|
||||||
|
ORDER BY m.created_at ASC LIMIT ?5`
|
||||||
|
).all(agentId, otherId, otherId, agentId, limit).map(r => ({
|
||||||
|
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
|
||||||
|
content: r.content, read: r.read !== 0, replyTo: r.reply_to,
|
||||||
|
createdAt: r.created_at, senderName: r.sender_name,
|
||||||
|
senderRole: r.sender_role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(agentId: string, messageIds: string[]): void {
|
||||||
|
if (messageIds.length === 0) return;
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
"UPDATE messages SET read = 1 WHERE id = ? AND to_agent = ?"
|
||||||
|
);
|
||||||
|
const tx = this.db.transaction(() => {
|
||||||
|
for (const mid of messageIds) stmt.run(mid, agentId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channels ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
createChannel(name: string, groupId: string, createdBy: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO channels (id, name, group_id, created_by)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
).run(id, name, groupId, createdBy);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
listChannels(groupId: string): BtmsgChannel[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; name: string; group_id: string; created_by: string;
|
||||||
|
member_count: number; created_at: string;
|
||||||
|
}, [string]>(
|
||||||
|
`SELECT c.id, c.name, c.group_id, c.created_by, c.created_at,
|
||||||
|
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) AS member_count
|
||||||
|
FROM channels c WHERE c.group_id = ? ORDER BY c.name`
|
||||||
|
).all(groupId).map(r => ({
|
||||||
|
id: r.id, name: r.name, groupId: r.group_id, createdBy: r.created_by,
|
||||||
|
memberCount: r.member_count, createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannelMessages(channelId: string, limit = 100): BtmsgChannelMessage[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; channel_id: string; from_agent: string;
|
||||||
|
content: string; created_at: string;
|
||||||
|
sender_name: string; sender_role: string;
|
||||||
|
}, [string, number]>(
|
||||||
|
`SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at,
|
||||||
|
COALESCE(a.name, cm.from_agent) AS sender_name,
|
||||||
|
COALESCE(a.role, 'unknown') AS sender_role
|
||||||
|
FROM channel_messages cm
|
||||||
|
LEFT JOIN agents a ON cm.from_agent = a.id
|
||||||
|
WHERE cm.channel_id = ?
|
||||||
|
ORDER BY cm.created_at ASC LIMIT ?`
|
||||||
|
).all(channelId, limit).map(r => ({
|
||||||
|
id: r.id, channelId: r.channel_id, fromAgent: r.from_agent,
|
||||||
|
content: r.content, createdAt: r.created_at,
|
||||||
|
senderName: r.sender_name, senderRole: r.sender_role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendChannelMessage(channelId: string, fromAgent: string, content: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO channel_messages (id, channel_id, from_agent, content)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
).run(id, channelId, fromAgent, content);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heartbeats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
heartbeat(agentId: string): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO heartbeats (agent_id, timestamp) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp`
|
||||||
|
).run(agentId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dead letter queue ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getDeadLetters(limit = 50): DeadLetter[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: number; from_agent: string; to_agent: string;
|
||||||
|
content: string; error: string; created_at: string;
|
||||||
|
}, [number]>(
|
||||||
|
`SELECT id, from_agent, to_agent, content, error, created_at
|
||||||
|
FROM dead_letter_queue ORDER BY created_at DESC LIMIT ?`
|
||||||
|
).all(limit).map(r => ({
|
||||||
|
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
|
||||||
|
content: r.content, error: r.error, createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit log ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logAudit(agentId: string, eventType: string, detail: string): void {
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO audit_log (agent_id, event_type, detail)
|
||||||
|
VALUES (?1, ?2, ?3)`
|
||||||
|
).run(agentId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuditLog(limit = 100): AuditEntry[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: number; agent_id: string; event_type: string;
|
||||||
|
detail: string; created_at: string;
|
||||||
|
}, [number]>(
|
||||||
|
`SELECT id, agent_id, event_type, detail, created_at
|
||||||
|
FROM audit_log ORDER BY created_at DESC LIMIT ?`
|
||||||
|
).all(limit).map(r => ({
|
||||||
|
id: r.id, agentId: r.agent_id, eventType: r.event_type,
|
||||||
|
detail: r.detail, createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seen messages (per-session acknowledgment) ───────────────────────────
|
||||||
|
|
||||||
|
markSeen(sessionId: string, messageIds: string[]): void {
|
||||||
|
if (messageIds.length === 0) return;
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO seen_messages (session_id, message_id) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
const tx = this.db.transaction(() => {
|
||||||
|
for (const mid of messageIds) stmt.run(sessionId, mid);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneSeen(maxAgeSecs = 7 * 24 * 3600): number {
|
||||||
|
const result = this.db.query(
|
||||||
|
"DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?"
|
||||||
|
).run(maxAgeSecs);
|
||||||
|
return (result as { changes: number }).changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
export const btmsgDb = new BtmsgDb();
|
||||||
187
ui-electrobun/src/bun/bttask-db.ts
Normal file
187
ui-electrobun/src/bun/bttask-db.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* bttask — Task board SQLite store.
|
||||||
|
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg).
|
||||||
|
* Uses bun:sqlite. Schema matches Rust bttask.rs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// ── DB path (same DB as btmsg) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||||
|
const DB_PATH = join(DATA_DIR, "btmsg.db");
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
assignedTo: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
groupId: string;
|
||||||
|
parentTaskId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskComment {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
agentId: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = ["todo", "progress", "review", "done", "blocked"] as const;
|
||||||
|
|
||||||
|
// ── BttaskDb class ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BttaskDb {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
this.db = new Database(DB_PATH);
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||||
|
|
||||||
|
// Ensure tables exist (idempotent — btmsg-db may have created them)
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
|
||||||
|
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
|
||||||
|
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||||
|
CREATE TABLE IF NOT EXISTS task_comments (
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: add version column if missing
|
||||||
|
try {
|
||||||
|
this.db.exec("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1");
|
||||||
|
} catch { /* column already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listTasks(groupId: string): Task[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; title: string; description: string; status: string;
|
||||||
|
priority: string; assigned_to: string | null; created_by: string;
|
||||||
|
group_id: string; parent_task_id: string | null; sort_order: number;
|
||||||
|
created_at: string; updated_at: string; version: number;
|
||||||
|
}, [string]>(
|
||||||
|
`SELECT id, title, description, status, priority, assigned_to,
|
||||||
|
created_by, group_id, parent_task_id, sort_order,
|
||||||
|
created_at, updated_at, COALESCE(version, 1) AS version
|
||||||
|
FROM tasks WHERE group_id = ?
|
||||||
|
ORDER BY sort_order ASC, created_at DESC`
|
||||||
|
).all(groupId).map(r => ({
|
||||||
|
id: r.id, title: r.title, description: r.description ?? '',
|
||||||
|
status: r.status ?? 'todo', priority: r.priority ?? 'medium',
|
||||||
|
assignedTo: r.assigned_to, createdBy: r.created_by,
|
||||||
|
groupId: r.group_id, parentTaskId: r.parent_task_id,
|
||||||
|
sortOrder: r.sort_order ?? 0,
|
||||||
|
createdAt: r.created_at ?? '', updatedAt: r.updated_at ?? '',
|
||||||
|
version: r.version ?? 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask(
|
||||||
|
title: string, description: string, priority: string,
|
||||||
|
groupId: string, createdBy: string, assignedTo?: string,
|
||||||
|
): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO tasks (id, title, description, priority, group_id, created_by, assigned_to)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
|
||||||
|
).run(id, title, description, priority, groupId, createdBy, assignedTo ?? null);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update task status with optimistic locking.
|
||||||
|
* Returns new version on success. Throws on version conflict.
|
||||||
|
*/
|
||||||
|
updateTaskStatus(taskId: string, status: string, expectedVersion: number): number {
|
||||||
|
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
|
||||||
|
throw new Error(`Invalid status '${status}'. Valid: ${VALID_STATUSES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.db.query(
|
||||||
|
`UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now')
|
||||||
|
WHERE id = ?2 AND version = ?3`
|
||||||
|
).run(status, taskId, expectedVersion);
|
||||||
|
|
||||||
|
if ((result as { changes: number }).changes === 0) {
|
||||||
|
throw new Error("Task was modified by another agent (version conflict)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedVersion + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTask(taskId: string): void {
|
||||||
|
this.db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId);
|
||||||
|
this.db.query("DELETE FROM tasks WHERE id = ?").run(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comments ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
addComment(taskId: string, agentId: string, content: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.db.query(
|
||||||
|
`INSERT INTO task_comments (id, task_id, agent_id, content)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
).run(id, taskId, agentId, content);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
listComments(taskId: string): TaskComment[] {
|
||||||
|
return this.db.query<{
|
||||||
|
id: string; task_id: string; agent_id: string;
|
||||||
|
content: string; created_at: string;
|
||||||
|
}, [string]>(
|
||||||
|
`SELECT id, task_id, agent_id, content, created_at
|
||||||
|
FROM task_comments WHERE task_id = ?
|
||||||
|
ORDER BY created_at ASC`
|
||||||
|
).all(taskId).map(r => ({
|
||||||
|
id: r.id, taskId: r.task_id, agentId: r.agent_id,
|
||||||
|
content: r.content, createdAt: r.created_at ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review queue ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
reviewQueueCount(groupId: string): number {
|
||||||
|
const row = this.db.query<{ cnt: number }, [string]>(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM tasks WHERE group_id = ? AND status = 'review'"
|
||||||
|
).get(groupId);
|
||||||
|
return row?.cnt ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
export const bttaskDb = new BttaskDb();
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
||||||
import { PtyClient } from "./pty-client.ts";
|
import { PtyClient } from "./pty-client.ts";
|
||||||
import { settingsDb } from "./settings-db.ts";
|
import { settingsDb } from "./settings-db.ts";
|
||||||
|
import { sessionDb } from "./session-db.ts";
|
||||||
|
import { btmsgDb } from "./btmsg-db.ts";
|
||||||
|
import { bttaskDb } from "./bttask-db.ts";
|
||||||
import { SidecarManager } from "./sidecar-manager.ts";
|
import { SidecarManager } from "./sidecar-manager.ts";
|
||||||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { SearchDb } from "./search-db.ts";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
||||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||||
|
|
@ -13,6 +20,8 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||||
|
|
||||||
const ptyClient = new PtyClient();
|
const ptyClient = new PtyClient();
|
||||||
const sidecarManager = new SidecarManager();
|
const sidecarManager = new SidecarManager();
|
||||||
|
const searchDb = new SearchDb();
|
||||||
|
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
||||||
|
|
||||||
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
|
@ -200,6 +209,79 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── File I/O handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
"files.list": async ({ path: dirPath }) => {
|
||||||
|
try {
|
||||||
|
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
const entries = dirents
|
||||||
|
.filter((d) => !d.name.startsWith("."))
|
||||||
|
.map((d) => {
|
||||||
|
let size = 0;
|
||||||
|
if (d.isFile()) {
|
||||||
|
try {
|
||||||
|
size = fs.statSync(path.join(dirPath, d.name)).size;
|
||||||
|
} catch { /* ignore stat errors */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: d.name,
|
||||||
|
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Directories first, then alphabetical
|
||||||
|
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return { entries };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[files.list]", error);
|
||||||
|
return { entries: [], error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"files.read": async ({ path: filePath }) => {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (stat.size > MAX_SIZE) {
|
||||||
|
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect binary by reading first 8KB
|
||||||
|
const buf = Buffer.alloc(Math.min(8192, stat.size));
|
||||||
|
const fd = fs.openSync(filePath, "r");
|
||||||
|
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const isBinary = buf.includes(0); // null byte = binary
|
||||||
|
if (isBinary) {
|
||||||
|
const content = fs.readFileSync(filePath).toString("base64");
|
||||||
|
return { content, encoding: "base64" as const, size: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
return { content, encoding: "utf8" as const, size: stat.size };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[files.read]", error);
|
||||||
|
return { encoding: "utf8" as const, size: 0, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"files.write": async ({ path: filePath, content }) => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, content, "utf8");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[files.write]", error);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Groups handlers ──────────────────────────────────────────────────
|
// ── Groups handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
"groups.list": () => {
|
"groups.list": () => {
|
||||||
|
|
@ -445,6 +527,353 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
||||||
return { sessions: [] };
|
return { sessions: [] };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Session persistence handlers ──────────────────────────────────
|
||||||
|
|
||||||
|
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => {
|
||||||
|
try {
|
||||||
|
sessionDb.saveSession({
|
||||||
|
projectId, sessionId, provider, status, costUsd,
|
||||||
|
inputTokens, outputTokens, model, error, createdAt, updatedAt,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.save]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"session.load": ({ projectId }) => {
|
||||||
|
try {
|
||||||
|
return { session: sessionDb.loadSession(projectId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.load]", err);
|
||||||
|
return { session: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"session.list": ({ projectId }) => {
|
||||||
|
try {
|
||||||
|
return { sessions: sessionDb.listSessionsByProject(projectId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.list]", err);
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"session.messages.save": ({ messages }) => {
|
||||||
|
try {
|
||||||
|
sessionDb.saveMessages(messages.map((m) => ({
|
||||||
|
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
|
||||||
|
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
|
||||||
|
timestamp: m.timestamp, costUsd: m.costUsd ?? 0,
|
||||||
|
inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0,
|
||||||
|
})));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.messages.save]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"session.messages.load": ({ sessionId }) => {
|
||||||
|
try {
|
||||||
|
return { messages: sessionDb.loadMessages(sessionId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.messages.load]", err);
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── btmsg handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => {
|
||||||
|
try {
|
||||||
|
btmsgDb.registerAgent(id, name, role, groupId, tier, model);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.registerAgent]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.getAgents": ({ groupId }) => {
|
||||||
|
try {
|
||||||
|
return { agents: btmsgDb.getAgents(groupId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.getAgents]", err);
|
||||||
|
return { agents: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.sendMessage": ({ fromAgent, toAgent, content }) => {
|
||||||
|
try {
|
||||||
|
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
|
||||||
|
return { ok: true, messageId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[btmsg.sendMessage]", err);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.listMessages": ({ agentId, otherId, limit }) => {
|
||||||
|
try {
|
||||||
|
return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.listMessages]", err);
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.markRead": ({ agentId, messageIds }) => {
|
||||||
|
try {
|
||||||
|
btmsgDb.markRead(agentId, messageIds);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.markRead]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.listChannels": ({ groupId }) => {
|
||||||
|
try {
|
||||||
|
return { channels: btmsgDb.listChannels(groupId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.listChannels]", err);
|
||||||
|
return { channels: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.createChannel": ({ name, groupId, createdBy }) => {
|
||||||
|
try {
|
||||||
|
const channelId = btmsgDb.createChannel(name, groupId, createdBy);
|
||||||
|
return { ok: true, channelId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.createChannel]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.getChannelMessages": ({ channelId, limit }) => {
|
||||||
|
try {
|
||||||
|
return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.getChannelMessages]", err);
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => {
|
||||||
|
try {
|
||||||
|
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
|
||||||
|
return { ok: true, messageId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.sendChannelMessage]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.heartbeat": ({ agentId }) => {
|
||||||
|
try {
|
||||||
|
btmsgDb.heartbeat(agentId);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.heartbeat]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.getDeadLetters": ({ limit }) => {
|
||||||
|
try {
|
||||||
|
return { letters: btmsgDb.getDeadLetters(limit ?? 50) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.getDeadLetters]", err);
|
||||||
|
return { letters: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.logAudit": ({ agentId, eventType, detail }) => {
|
||||||
|
try {
|
||||||
|
btmsgDb.logAudit(agentId, eventType, detail);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.logAudit]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"btmsg.getAuditLog": ({ limit }) => {
|
||||||
|
try {
|
||||||
|
return { entries: btmsgDb.getAuditLog(limit ?? 100) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[btmsg.getAuditLog]", err);
|
||||||
|
return { entries: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── bttask handlers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
"bttask.listTasks": ({ groupId }) => {
|
||||||
|
try {
|
||||||
|
return { tasks: bttaskDb.listTasks(groupId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[bttask.listTasks]", err);
|
||||||
|
return { tasks: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => {
|
||||||
|
try {
|
||||||
|
const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo);
|
||||||
|
return { ok: true, taskId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[bttask.createTask]", err);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => {
|
||||||
|
try {
|
||||||
|
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
|
||||||
|
return { ok: true, newVersion };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[bttask.updateTaskStatus]", err);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.deleteTask": ({ taskId }) => {
|
||||||
|
try {
|
||||||
|
bttaskDb.deleteTask(taskId);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[bttask.deleteTask]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.addComment": ({ taskId, agentId, content }) => {
|
||||||
|
try {
|
||||||
|
const commentId = bttaskDb.addComment(taskId, agentId, content);
|
||||||
|
return { ok: true, commentId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[bttask.addComment]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.listComments": ({ taskId }) => {
|
||||||
|
try {
|
||||||
|
return { comments: bttaskDb.listComments(taskId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[bttask.listComments]", err);
|
||||||
|
return { comments: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"bttask.reviewQueueCount": ({ groupId }) => {
|
||||||
|
try {
|
||||||
|
return { count: bttaskDb.reviewQueueCount(groupId) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[bttask.reviewQueueCount]", err);
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Search handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
"search.query": ({ query, limit }) => {
|
||||||
|
try {
|
||||||
|
const results = searchDb.searchAll(query, limit ?? 20);
|
||||||
|
return { results };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[search.query]", err);
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"search.indexMessage": ({ sessionId, role, content }) => {
|
||||||
|
try {
|
||||||
|
searchDb.indexMessage(sessionId, role, content);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[search.indexMessage]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"search.rebuild": () => {
|
||||||
|
try {
|
||||||
|
searchDb.rebuildIndex();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[search.rebuild]", err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Plugin handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
"plugin.discover": () => {
|
||||||
|
try {
|
||||||
|
const plugins: Array<{
|
||||||
|
id: string; name: string; version: string;
|
||||||
|
description: string; main: string; permissions: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(manifestPath, "utf-8");
|
||||||
|
const manifest = JSON.parse(raw);
|
||||||
|
plugins.push({
|
||||||
|
id: manifest.id ?? entry.name,
|
||||||
|
name: manifest.name ?? entry.name,
|
||||||
|
version: manifest.version ?? "0.0.0",
|
||||||
|
description: manifest.description ?? "",
|
||||||
|
main: manifest.main ?? "index.js",
|
||||||
|
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
|
||||||
|
});
|
||||||
|
} catch (parseErr) {
|
||||||
|
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plugins };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[plugin.discover]", err);
|
||||||
|
return { plugins: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"plugin.readFile": ({ pluginId, filePath }) => {
|
||||||
|
try {
|
||||||
|
// Path traversal protection: resolve and verify within plugins dir
|
||||||
|
const pluginDir = join(PLUGINS_DIR, pluginId);
|
||||||
|
const resolved = path.resolve(pluginDir, filePath);
|
||||||
|
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
|
||||||
|
return { ok: false, content: "", error: "Path traversal blocked" };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
return { ok: false, content: "", error: "File not found" };
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(resolved, "utf-8");
|
||||||
|
return { ok: true, content };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[plugin.readFile]", err);
|
||||||
|
return { ok: false, content: "", error };
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {},
|
messages: {},
|
||||||
|
|
|
||||||
202
ui-electrobun/src/bun/search-db.ts
Normal file
202
ui-electrobun/src/bun/search-db.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
/**
|
||||||
|
* FTS5 full-text search database — bun:sqlite.
|
||||||
|
*
|
||||||
|
* Three virtual tables: search_messages, search_tasks, search_btmsg.
|
||||||
|
* Provides indexing and unified search across all tables.
|
||||||
|
* DB path: ~/.local/share/agor/search.db
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
resultType: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DATA_DIR = join(homedir(), ".local", "share", "agor");
|
||||||
|
const DB_PATH = join(DATA_DIR, "search.db");
|
||||||
|
|
||||||
|
// ── SearchDb class ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class SearchDb {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor(dbPath?: string) {
|
||||||
|
const path = dbPath ?? DB_PATH;
|
||||||
|
const dir = join(path, "..");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
this.db = new Database(path);
|
||||||
|
this.db.run("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.run("PRAGMA busy_timeout = 2000");
|
||||||
|
this.createTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTables(): void {
|
||||||
|
this.db.run(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5(
|
||||||
|
session_id,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.db.run(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5(
|
||||||
|
task_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
assigned_to
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.db.run(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5(
|
||||||
|
message_id,
|
||||||
|
from_agent,
|
||||||
|
to_agent,
|
||||||
|
content,
|
||||||
|
channel_name
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index an agent message. */
|
||||||
|
indexMessage(sessionId: string, role: string, content: string): void {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
this.db.run(
|
||||||
|
"INSERT INTO search_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||||
|
[sessionId, role, content, ts],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index a task. */
|
||||||
|
indexTask(taskId: string, title: string, description: string, status: string, assignedTo: string): void {
|
||||||
|
this.db.run(
|
||||||
|
"INSERT INTO search_tasks (task_id, title, description, status, assigned_to) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[taskId, title, description, status, assignedTo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index a btmsg message. */
|
||||||
|
indexBtmsg(msgId: string, fromAgent: string, toAgent: string, content: string, channel: string): void {
|
||||||
|
this.db.run(
|
||||||
|
"INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[msgId, fromAgent, toAgent, content, channel],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search across all FTS5 tables. */
|
||||||
|
searchAll(query: string, limit = 20): SearchResult[] {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
// Search messages
|
||||||
|
try {
|
||||||
|
const msgRows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, role,
|
||||||
|
snippet(search_messages, 2, '<b>', '</b>', '...', 32) as snip,
|
||||||
|
rank
|
||||||
|
FROM search_messages
|
||||||
|
WHERE search_messages MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(query, limit) as Array<{ session_id: string; role: string; snip: string; rank: number }>;
|
||||||
|
|
||||||
|
for (const row of msgRows) {
|
||||||
|
results.push({
|
||||||
|
resultType: "message",
|
||||||
|
id: row.session_id,
|
||||||
|
title: row.role,
|
||||||
|
snippet: row.snip ?? "",
|
||||||
|
score: Math.abs(row.rank ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// FTS5 syntax error — skip messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search tasks
|
||||||
|
try {
|
||||||
|
const taskRows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT task_id, title,
|
||||||
|
snippet(search_tasks, 2, '<b>', '</b>', '...', 32) as snip,
|
||||||
|
rank
|
||||||
|
FROM search_tasks
|
||||||
|
WHERE search_tasks MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(query, limit) as Array<{ task_id: string; title: string; snip: string; rank: number }>;
|
||||||
|
|
||||||
|
for (const row of taskRows) {
|
||||||
|
results.push({
|
||||||
|
resultType: "task",
|
||||||
|
id: row.task_id,
|
||||||
|
title: row.title,
|
||||||
|
snippet: row.snip ?? "",
|
||||||
|
score: Math.abs(row.rank ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// FTS5 syntax error — skip tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search btmsg
|
||||||
|
try {
|
||||||
|
const btmsgRows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT message_id, from_agent,
|
||||||
|
snippet(search_btmsg, 3, '<b>', '</b>', '...', 32) as snip,
|
||||||
|
rank
|
||||||
|
FROM search_btmsg
|
||||||
|
WHERE search_btmsg MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(query, limit) as Array<{ message_id: string; from_agent: string; snip: string; rank: number }>;
|
||||||
|
|
||||||
|
for (const row of btmsgRows) {
|
||||||
|
results.push({
|
||||||
|
resultType: "btmsg",
|
||||||
|
id: row.message_id,
|
||||||
|
title: row.from_agent,
|
||||||
|
snippet: row.snip ?? "",
|
||||||
|
score: Math.abs(row.rank ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// FTS5 syntax error — skip btmsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (lower = more relevant for FTS5 rank)
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop and recreate all FTS5 tables. */
|
||||||
|
rebuildIndex(): void {
|
||||||
|
this.db.run("DROP TABLE IF EXISTS search_messages");
|
||||||
|
this.db.run("DROP TABLE IF EXISTS search_tasks");
|
||||||
|
this.db.run("DROP TABLE IF EXISTS search_btmsg");
|
||||||
|
this.createTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
326
ui-electrobun/src/bun/session-db.ts
Normal file
326
ui-electrobun/src/bun/session-db.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
/**
|
||||||
|
* Session persistence — SQLite-backed agent session & message storage.
|
||||||
|
* Uses bun:sqlite. DB: ~/.config/agor/settings.db (shared with SettingsDb).
|
||||||
|
*
|
||||||
|
* Tables: agent_sessions, agent_messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// ── DB path ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONFIG_DIR = join(homedir(), ".config", "agor");
|
||||||
|
const DB_PATH = join(CONFIG_DIR, "settings.db");
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StoredSession {
|
||||||
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
costUsd: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
model: string;
|
||||||
|
error?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredMessage {
|
||||||
|
sessionId: string;
|
||||||
|
msgId: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: string;
|
||||||
|
timestamp: number;
|
||||||
|
costUsd: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SESSION_SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
cost_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
error TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_sessions_project
|
||||||
|
ON agent_sessions(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
msg_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
tool_name TEXT,
|
||||||
|
tool_input TEXT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
cost_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (session_id, msg_id),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
|
||||||
|
ON agent_messages(session_id, timestamp);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── SessionDb class ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class SessionDb {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
this.db = new Database(DB_PATH);
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA busy_timeout = 500");
|
||||||
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
this.db.exec(SESSION_SCHEMA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
saveSession(s: StoredSession): void {
|
||||||
|
this.db
|
||||||
|
.query(
|
||||||
|
`INSERT INTO agent_sessions
|
||||||
|
(project_id, session_id, provider, status, cost_usd,
|
||||||
|
input_tokens, output_tokens, model, error, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
cost_usd = excluded.cost_usd,
|
||||||
|
input_tokens = excluded.input_tokens,
|
||||||
|
output_tokens = excluded.output_tokens,
|
||||||
|
model = excluded.model,
|
||||||
|
error = excluded.error,
|
||||||
|
updated_at = excluded.updated_at`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
s.projectId,
|
||||||
|
s.sessionId,
|
||||||
|
s.provider,
|
||||||
|
s.status,
|
||||||
|
s.costUsd,
|
||||||
|
s.inputTokens,
|
||||||
|
s.outputTokens,
|
||||||
|
s.model,
|
||||||
|
s.error ?? null,
|
||||||
|
s.createdAt,
|
||||||
|
s.updatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession(projectId: string): StoredSession | null {
|
||||||
|
const row = this.db
|
||||||
|
.query<
|
||||||
|
{
|
||||||
|
project_id: string;
|
||||||
|
session_id: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
cost_usd: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
model: string;
|
||||||
|
error: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
},
|
||||||
|
[string]
|
||||||
|
>(
|
||||||
|
`SELECT * FROM agent_sessions
|
||||||
|
WHERE project_id = ?
|
||||||
|
ORDER BY updated_at DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(projectId);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
projectId: row.project_id,
|
||||||
|
sessionId: row.session_id,
|
||||||
|
provider: row.provider,
|
||||||
|
status: row.status,
|
||||||
|
costUsd: row.cost_usd,
|
||||||
|
inputTokens: row.input_tokens,
|
||||||
|
outputTokens: row.output_tokens,
|
||||||
|
model: row.model,
|
||||||
|
error: row.error ?? undefined,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listSessionsByProject(projectId: string): StoredSession[] {
|
||||||
|
const rows = this.db
|
||||||
|
.query<
|
||||||
|
{
|
||||||
|
project_id: string;
|
||||||
|
session_id: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
cost_usd: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
model: string;
|
||||||
|
error: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
},
|
||||||
|
[string]
|
||||||
|
>(
|
||||||
|
`SELECT * FROM agent_sessions
|
||||||
|
WHERE project_id = ?
|
||||||
|
ORDER BY updated_at DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.all(projectId);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
projectId: r.project_id,
|
||||||
|
sessionId: r.session_id,
|
||||||
|
provider: r.provider,
|
||||||
|
status: r.status,
|
||||||
|
costUsd: r.cost_usd,
|
||||||
|
inputTokens: r.input_tokens,
|
||||||
|
outputTokens: r.output_tokens,
|
||||||
|
model: r.model,
|
||||||
|
error: r.error ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
saveMessage(m: StoredMessage): void {
|
||||||
|
this.db
|
||||||
|
.query(
|
||||||
|
`INSERT INTO agent_messages
|
||||||
|
(session_id, msg_id, role, content, tool_name, tool_input,
|
||||||
|
timestamp, cost_usd, input_tokens, output_tokens)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
|
ON CONFLICT(session_id, msg_id) DO NOTHING`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
m.sessionId,
|
||||||
|
m.msgId,
|
||||||
|
m.role,
|
||||||
|
m.content,
|
||||||
|
m.toolName ?? null,
|
||||||
|
m.toolInput ?? null,
|
||||||
|
m.timestamp,
|
||||||
|
m.costUsd,
|
||||||
|
m.inputTokens,
|
||||||
|
m.outputTokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessages(msgs: StoredMessage[]): void {
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO agent_messages
|
||||||
|
(session_id, msg_id, role, content, tool_name, tool_input,
|
||||||
|
timestamp, cost_usd, input_tokens, output_tokens)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
|
ON CONFLICT(session_id, msg_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = this.db.transaction(() => {
|
||||||
|
for (const m of msgs) {
|
||||||
|
stmt.run(
|
||||||
|
m.sessionId,
|
||||||
|
m.msgId,
|
||||||
|
m.role,
|
||||||
|
m.content,
|
||||||
|
m.toolName ?? null,
|
||||||
|
m.toolInput ?? null,
|
||||||
|
m.timestamp,
|
||||||
|
m.costUsd,
|
||||||
|
m.inputTokens,
|
||||||
|
m.outputTokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMessages(sessionId: string): StoredMessage[] {
|
||||||
|
const rows = this.db
|
||||||
|
.query<
|
||||||
|
{
|
||||||
|
session_id: string;
|
||||||
|
msg_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tool_name: string | null;
|
||||||
|
tool_input: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
cost_usd: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
},
|
||||||
|
[string]
|
||||||
|
>(
|
||||||
|
`SELECT * FROM agent_messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp ASC`
|
||||||
|
)
|
||||||
|
.all(sessionId);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
sessionId: r.session_id,
|
||||||
|
msgId: r.msg_id,
|
||||||
|
role: r.role,
|
||||||
|
content: r.content,
|
||||||
|
toolName: r.tool_name ?? undefined,
|
||||||
|
toolInput: r.tool_input ?? undefined,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
costUsd: r.cost_usd,
|
||||||
|
inputTokens: r.input_tokens,
|
||||||
|
outputTokens: r.output_tokens,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Delete sessions older than maxAgeDays for a project, keeping at most keepCount. */
|
||||||
|
pruneOldSessions(projectId: string, keepCount = 10): void {
|
||||||
|
this.db
|
||||||
|
.query(
|
||||||
|
`DELETE FROM agent_sessions
|
||||||
|
WHERE project_id = ?1
|
||||||
|
AND session_id NOT IN (
|
||||||
|
SELECT session_id FROM agent_sessions
|
||||||
|
WHERE project_id = ?1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?2
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run(projectId, keepCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
export const sessionDb = new SessionDb();
|
||||||
|
|
@ -5,9 +5,12 @@
|
||||||
import CommandPalette from './CommandPalette.svelte';
|
import CommandPalette from './CommandPalette.svelte';
|
||||||
import ToastContainer from './ToastContainer.svelte';
|
import ToastContainer from './ToastContainer.svelte';
|
||||||
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
|
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
|
||||||
|
import StatusBar from './StatusBar.svelte';
|
||||||
|
import SearchOverlay from './SearchOverlay.svelte';
|
||||||
import { themeStore } from './theme-store.svelte.ts';
|
import { themeStore } from './theme-store.svelte.ts';
|
||||||
import { fontStore } from './font-store.svelte.ts';
|
import { fontStore } from './font-store.svelte.ts';
|
||||||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||||
|
import { trackProject } from './health-store.svelte.ts';
|
||||||
import { appRpc } from './rpc.ts';
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
@ -137,6 +140,7 @@
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
let paletteOpen = $state(false);
|
let paletteOpen = $state(false);
|
||||||
let drawerOpen = $state(false);
|
let drawerOpen = $state(false);
|
||||||
|
let searchOpen = $state(false);
|
||||||
let sessionStart = $state(Date.now());
|
let sessionStart = $state(Date.now());
|
||||||
|
|
||||||
let notifications = $state<Notification[]>([
|
let notifications = $state<Notification[]>([
|
||||||
|
|
@ -236,15 +240,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status bar aggregates ──────────────────────────────────────
|
// ── Status bar aggregates ──────────────────────────────────────
|
||||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
|
||||||
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
|
|
||||||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
|
||||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||||
let attentionItems = $derived(PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75));
|
|
||||||
|
|
||||||
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
|
|
||||||
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
|
|
||||||
|
|
||||||
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
|
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
|
||||||
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
|
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
|
||||||
|
|
@ -299,13 +296,29 @@
|
||||||
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
|
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
|
||||||
keybindingStore.on('minimize', () => handleMinimize());
|
keybindingStore.on('minimize', () => handleMinimize());
|
||||||
|
|
||||||
|
// Ctrl+Shift+F for search overlay
|
||||||
|
function handleSearchShortcut(e: KeyboardEvent) {
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchOpen = !searchOpen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleSearchShortcut);
|
||||||
|
|
||||||
|
// Track projects for health monitoring
|
||||||
|
for (const p of PROJECTS) trackProject(p.id);
|
||||||
|
|
||||||
const cleanup = keybindingStore.installListener();
|
const cleanup = keybindingStore.installListener();
|
||||||
return cleanup;
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
document.removeEventListener('keydown', handleSearchShortcut);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||||
|
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<NotifDrawer
|
<NotifDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
|
|
@ -423,59 +436,14 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar (health-backed) -->
|
||||||
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
<StatusBar
|
||||||
{#if runningCount > 0}
|
projectCount={filteredProjects.length}
|
||||||
<span class="status-segment">
|
{totalTokens}
|
||||||
<span class="status-dot-sm green" aria-hidden="true"></span>
|
totalCost={totalCost}
|
||||||
<span class="status-value">{runningCount}</span>
|
{sessionDuration}
|
||||||
<span>running</span>
|
groupName={activeGroup?.name ?? ''}
|
||||||
</span>
|
/>
|
||||||
{/if}
|
|
||||||
{#if idleCount > 0}
|
|
||||||
<span class="status-segment">
|
|
||||||
<span class="status-dot-sm gray" aria-hidden="true"></span>
|
|
||||||
<span class="status-value">{idleCount}</span>
|
|
||||||
<span>idle</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if stalledCount > 0}
|
|
||||||
<span class="status-segment">
|
|
||||||
<span class="status-dot-sm orange" aria-hidden="true"></span>
|
|
||||||
<span class="status-value">{stalledCount}</span>
|
|
||||||
<span>stalled</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if attentionItems.length > 0}
|
|
||||||
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p => p.name).join(', ')}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
<span class="status-value">{attentionItems.length}</span>
|
|
||||||
<span>attention</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="status-bar-spacer"></span>
|
|
||||||
|
|
||||||
<span class="status-segment" title="Active group">
|
|
||||||
<span class="status-value">{activeGroup?.name}</span>
|
|
||||||
</span>
|
|
||||||
<span class="status-segment" title="Session duration">
|
|
||||||
<span>session</span>
|
|
||||||
<span class="status-value">{sessionDuration}</span>
|
|
||||||
</span>
|
|
||||||
<span class="status-segment" title="Total tokens used">
|
|
||||||
<span>tokens</span>
|
|
||||||
<span class="status-value">{fmtTokens(totalTokens)}</span>
|
|
||||||
</span>
|
|
||||||
<span class="status-segment" title="Total session cost">
|
|
||||||
<span>cost</span>
|
|
||||||
<span class="status-value">{fmtCost(totalCost)}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{#if DEBUG_ENABLED && debugLog.length > 0}
|
{#if DEBUG_ENABLED && debugLog.length > 0}
|
||||||
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
|
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
|
||||||
|
|
@ -728,55 +696,5 @@
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status bar ───────────────────────────────────────────── */
|
/* Status bar styles are in StatusBar.svelte */
|
||||||
.status-bar {
|
|
||||||
height: var(--status-bar-height);
|
|
||||||
background: var(--ctp-crust);
|
|
||||||
border-top: 1px solid var(--ctp-surface0);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 0 0.625rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--ctp-subtext0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-segment {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot-sm {
|
|
||||||
width: 0.4375rem;
|
|
||||||
height: 0.4375rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot-sm.green { background: var(--ctp-green); }
|
|
||||||
.status-dot-sm.gray { background: var(--ctp-overlay0); }
|
|
||||||
.status-dot-sm.orange { background: var(--ctp-peach); }
|
|
||||||
|
|
||||||
.status-value { color: var(--ctp-text); font-weight: 500; }
|
|
||||||
.status-bar-spacer { flex: 1; }
|
|
||||||
|
|
||||||
.attn-badge { color: var(--ctp-yellow); }
|
|
||||||
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
|
|
||||||
|
|
||||||
.palette-hint {
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
background: var(--ctp-surface0);
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
color: var(--ctp-overlay0);
|
|
||||||
font-family: var(--ui-font-family);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-hint:hover { color: var(--ctp-subtext0); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
248
ui-electrobun/src/mainview/CodeEditor.svelte
Normal file
248
ui-electrobun/src/mainview/CodeEditor.svelte
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, untrack } from 'svelte';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, 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;
|
||||||
|
readonly?: boolean;
|
||||||
|
onchange?: (content: string) => void;
|
||||||
|
onsave?: () => void;
|
||||||
|
onblur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { content, lang, readonly = false, onchange, onsave, onblur }: Props = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement | undefined = $state();
|
||||||
|
let view: EditorView | undefined = $state();
|
||||||
|
|
||||||
|
/** Map file extension hint to CodeMirror language extension (dynamic import). */
|
||||||
|
async function getLangExtension(l: string) {
|
||||||
|
switch (l) {
|
||||||
|
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':
|
||||||
|
case 'md': {
|
||||||
|
const { markdown } = await import('@codemirror/lang-markdown');
|
||||||
|
return markdown();
|
||||||
|
}
|
||||||
|
case 'python':
|
||||||
|
case 'py': {
|
||||||
|
const { python } = await import('@codemirror/lang-python');
|
||||||
|
return python();
|
||||||
|
}
|
||||||
|
case 'rust':
|
||||||
|
case 'rs': {
|
||||||
|
const { rust } = await import('@codemirror/lang-rust');
|
||||||
|
return rust();
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catppuccin Mocha theme via 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',
|
||||||
|
},
|
||||||
|
}, { dark: true });
|
||||||
|
|
||||||
|
function buildExtensions(langExt: ReturnType<Awaited<ReturnType<typeof getLangExtension>>> | null) {
|
||||||
|
const exts = [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
history(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
indentOnInput(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion(),
|
||||||
|
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 (readonly) exts.push(EditorState.readOnly.of(true));
|
||||||
|
if (langExt) exts.push(langExt);
|
||||||
|
return exts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEditor() {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const langExt = await getLangExtension(lang);
|
||||||
|
view = new EditorView({
|
||||||
|
state: EditorState.create({ doc: content, extensions: buildExtensions(langExt) }),
|
||||||
|
parent: container,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => { createEditor(); });
|
||||||
|
onDestroy(() => { view?.destroy(); });
|
||||||
|
|
||||||
|
// When content prop changes externally (different file loaded), replace editor content
|
||||||
|
let lastContent = $state(untrack(() => 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(untrack(() => lang));
|
||||||
|
$effect(() => {
|
||||||
|
const l = lang;
|
||||||
|
if (l !== lastLang && view) {
|
||||||
|
lastLang = l;
|
||||||
|
const currentContent = view.state.doc.toString();
|
||||||
|
view.destroy();
|
||||||
|
queueMicrotask(async () => {
|
||||||
|
const langExt = await getLangExtension(l);
|
||||||
|
view = new EditorView({
|
||||||
|
state: EditorState.create({ doc: currentContent, extensions: buildExtensions(langExt) }),
|
||||||
|
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>
|
||||||
521
ui-electrobun/src/mainview/CommsTab.svelte
Normal file
521
ui-electrobun/src/mainview/CommsTab.svelte
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
groupId: string;
|
||||||
|
createdBy: string;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelMessage {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
fromAgent: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string;
|
||||||
|
senderRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
groupId: string;
|
||||||
|
tier: number;
|
||||||
|
status: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DM {
|
||||||
|
id: string;
|
||||||
|
fromAgent: string;
|
||||||
|
toAgent: string;
|
||||||
|
content: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string | null;
|
||||||
|
senderRole: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupId: string;
|
||||||
|
/** Agent ID for this project's perspective (defaults to 'admin'). */
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { groupId, agentId = 'admin' }: Props = $props();
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TabMode = 'channels' | 'dms';
|
||||||
|
let mode = $state<TabMode>('channels');
|
||||||
|
|
||||||
|
let channels = $state<Channel[]>([]);
|
||||||
|
let agents = $state<Agent[]>([]);
|
||||||
|
let activeChannelId = $state<string | null>(null);
|
||||||
|
let activeDmAgentId = $state<string | null>(null);
|
||||||
|
let channelMessages = $state<ChannelMessage[]>([]);
|
||||||
|
let dmMessages = $state<DM[]>([]);
|
||||||
|
let input = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// ── Data fetching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['btmsg.listChannels']({ groupId });
|
||||||
|
channels = res.channels;
|
||||||
|
if (channels.length > 0 && !activeChannelId) {
|
||||||
|
activeChannelId = channels[0].id;
|
||||||
|
await loadChannelMessages(channels[0].id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommsTab] loadChannels:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['btmsg.getAgents']({ groupId });
|
||||||
|
agents = res.agents.filter((a: Agent) => a.id !== agentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommsTab] loadAgents:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChannelMessages(channelId: string) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const res = await appRpc.request['btmsg.getChannelMessages']({
|
||||||
|
channelId, limit: 100,
|
||||||
|
});
|
||||||
|
channelMessages = res.messages;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommsTab] loadChannelMessages:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDmMessages(otherId: string) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const res = await appRpc.request['btmsg.listMessages']({
|
||||||
|
agentId, otherId, limit: 50,
|
||||||
|
});
|
||||||
|
dmMessages = res.messages;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommsTab] loadDmMessages:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChannel(id: string) {
|
||||||
|
activeChannelId = id;
|
||||||
|
loadChannelMessages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDm(otherId: string) {
|
||||||
|
activeDmAgentId = otherId;
|
||||||
|
loadDmMessages(otherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'channels' && activeChannelId) {
|
||||||
|
await appRpc.request['btmsg.sendChannelMessage']({
|
||||||
|
channelId: activeChannelId, fromAgent: agentId, content: text,
|
||||||
|
});
|
||||||
|
await loadChannelMessages(activeChannelId);
|
||||||
|
} else if (mode === 'dms' && activeDmAgentId) {
|
||||||
|
await appRpc.request['btmsg.sendMessage']({
|
||||||
|
fromAgent: agentId, toAgent: activeDmAgentId, content: text,
|
||||||
|
});
|
||||||
|
await loadDmMessages(activeDmAgentId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommsTab] sendMessage:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init + polling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadChannels();
|
||||||
|
loadAgents();
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (mode === 'channels' && activeChannelId) {
|
||||||
|
loadChannelMessages(activeChannelId);
|
||||||
|
} else if (mode === 'dms' && activeDmAgentId) {
|
||||||
|
loadDmMessages(activeDmAgentId);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="comms-tab">
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="comms-mode-bar">
|
||||||
|
<button
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={mode === 'channels'}
|
||||||
|
onclick={() => { mode = 'channels'; }}
|
||||||
|
>Channels</button>
|
||||||
|
<button
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={mode === 'dms'}
|
||||||
|
onclick={() => { mode = 'dms'; loadAgents(); }}
|
||||||
|
>DMs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comms-body">
|
||||||
|
<!-- Sidebar: channel list or agent list -->
|
||||||
|
<div class="comms-sidebar">
|
||||||
|
{#if mode === 'channels'}
|
||||||
|
{#each channels as ch}
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
class:active={activeChannelId === ch.id}
|
||||||
|
onclick={() => selectChannel(ch.id)}
|
||||||
|
>
|
||||||
|
<span class="ch-hash">#</span>
|
||||||
|
<span class="ch-name">{ch.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if channels.length === 0}
|
||||||
|
<div class="sidebar-empty">No channels</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each agents as ag}
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
class:active={activeDmAgentId === ag.id}
|
||||||
|
onclick={() => selectDm(ag.id)}
|
||||||
|
>
|
||||||
|
<span class="agent-dot {ag.status}"></span>
|
||||||
|
<span class="agent-name">{ag.name}</span>
|
||||||
|
<span class="agent-role">{ag.role}</span>
|
||||||
|
{#if ag.unreadCount > 0}
|
||||||
|
<span class="unread-badge">{ag.unreadCount}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if agents.length === 0}
|
||||||
|
<div class="sidebar-empty">No agents</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message area -->
|
||||||
|
<div class="comms-messages">
|
||||||
|
{#if loading}
|
||||||
|
<div class="msg-loading">Loading...</div>
|
||||||
|
{:else if mode === 'channels'}
|
||||||
|
<div class="msg-list">
|
||||||
|
{#each channelMessages as msg}
|
||||||
|
<div class="msg-row">
|
||||||
|
<span class="msg-sender">{msg.senderName}</span>
|
||||||
|
<span class="msg-role">{msg.senderRole}</span>
|
||||||
|
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
|
||||||
|
<div class="msg-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if channelMessages.length === 0}
|
||||||
|
<div class="msg-empty">No messages in this channel</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="msg-list">
|
||||||
|
{#each dmMessages as msg}
|
||||||
|
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
|
||||||
|
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
|
||||||
|
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
|
||||||
|
<div class="msg-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if dmMessages.length === 0 && activeDmAgentId}
|
||||||
|
<div class="msg-empty">No messages yet</div>
|
||||||
|
{/if}
|
||||||
|
{#if !activeDmAgentId}
|
||||||
|
<div class="msg-empty">Select an agent to message</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Input bar -->
|
||||||
|
<div class="msg-input-bar">
|
||||||
|
<input
|
||||||
|
class="msg-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'}
|
||||||
|
bind:value={input}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comms-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comms-mode-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:hover { color: var(--ctp-text); }
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-color: var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comms-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comms-sidebar {
|
||||||
|
width: 10rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--ctp-surface0);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||||
|
.sidebar-item.active { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||||
|
|
||||||
|
.ch-hash {
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.agent-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ctp-overlay0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-dot.active { background: var(--ctp-green); }
|
||||||
|
.agent-dot.running { background: var(--ctp-green); }
|
||||||
|
.agent-dot.stopped { background: var(--ctp-overlay0); }
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-role {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
background: var(--ctp-red);
|
||||||
|
color: var(--ctp-base);
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
min-width: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comms-messages {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-mine { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.msg-sender {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-role {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-empty, .msg-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-top: 1px solid var(--ctp-surface0);
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 1.75rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-input:focus { border-color: var(--ctp-mauve); }
|
||||||
|
|
||||||
|
.msg-send-btn {
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
|
||||||
|
border: 1px solid var(--ctp-blue);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-send-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
243
ui-electrobun/src/mainview/CsvTable.svelte
Normal file
243
ui-electrobun/src/mainview/CsvTable.svelte
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { content, filename }: Props = $props();
|
||||||
|
|
||||||
|
/** RFC 4180 CSV parser with quoted field support. */
|
||||||
|
function parseCsv(text: string, delimiter: 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] === '"') {
|
||||||
|
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 {
|
||||||
|
while (i < len && text[i] !== delimiter && text[i] !== '\n' && text[i] !== '\r') {
|
||||||
|
field += text[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.push(field);
|
||||||
|
|
||||||
|
if (i < len && text[i] === delimiter) {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
if (i < len && text[i] === '\r') i++;
|
||||||
|
if (i < len && text[i] === '\n') i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect delimiter from first line: comma, semicolon, tab, or pipe. */
|
||||||
|
function detectDelimiter(text: string): string {
|
||||||
|
const firstLine = text.split('\n')[0] ?? '';
|
||||||
|
const counts: [string, number][] = [
|
||||||
|
[',', (firstLine.match(/,/g) ?? []).length],
|
||||||
|
[';', (firstLine.match(/;/g) ?? []).length],
|
||||||
|
['\t', (firstLine.match(/\t/g) ?? []).length],
|
||||||
|
['|', (firstLine.match(/\|/g) ?? []).length],
|
||||||
|
];
|
||||||
|
counts.sort((a, b) => b[1] - a[1]);
|
||||||
|
return counts[0][1] > 0 ? counts[0][0] : ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
let delimiter = $derived(detectDelimiter(content));
|
||||||
|
let parsed = $derived(parseCsv(content, delimiter));
|
||||||
|
let headers = $derived(parsed[0] ?? []);
|
||||||
|
let dataRows = $derived(parsed.slice(1));
|
||||||
|
let totalRows = $derived(dataRows.length);
|
||||||
|
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] ?? '';
|
||||||
|
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' : ''} x {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>
|
||||||
|
|
@ -1,133 +1,352 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface FileNode {
|
import { appRpc } from './rpc.ts';
|
||||||
|
import CodeEditor from './CodeEditor.svelte';
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
import CsvTable from './CsvTable.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { cwd }: Props = $props();
|
||||||
|
|
||||||
|
interface DirEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'file' | 'dir';
|
type: 'file' | 'dir';
|
||||||
children?: FileNode[];
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo directory tree
|
// Tree state: track loaded children and open/closed per path
|
||||||
const TREE: FileNode[] = [
|
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
|
||||||
{
|
let openDirs = $state<Set<string>>(new Set());
|
||||||
name: 'src', type: 'dir', children: [
|
let loadingDirs = $state<Set<string>>(new Set());
|
||||||
{
|
|
||||||
name: 'lib', type: 'dir', children: [
|
|
||||||
{
|
|
||||||
name: 'stores', type: 'dir', children: [
|
|
||||||
{ name: 'workspace.svelte.ts', type: 'file' },
|
|
||||||
{ name: 'agents.svelte.ts', type: 'file' },
|
|
||||||
{ name: 'health.svelte.ts', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'adapters', type: 'dir', children: [
|
|
||||||
{ name: 'claude-messages.ts', type: 'file' },
|
|
||||||
{ name: 'agent-bridge.ts', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ name: 'agent-dispatcher.ts', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ name: 'App.svelte', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'src-tauri', type: 'dir', children: [
|
|
||||||
{
|
|
||||||
name: 'src', type: 'dir', children: [
|
|
||||||
{ name: 'lib.rs', type: 'file' },
|
|
||||||
{ name: 'btmsg.rs', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ name: 'Cargo.toml', type: 'file' },
|
|
||||||
{ name: 'package.json', type: 'file' },
|
|
||||||
{ name: 'vite.config.ts', type: 'file' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
|
|
||||||
let selectedFile = $state<string | null>(null);
|
let selectedFile = $state<string | null>(null);
|
||||||
|
|
||||||
function toggleDir(path: string) {
|
// File viewer state
|
||||||
const s = new Set(openDirs);
|
let fileContent = $state<string | null>(null);
|
||||||
if (s.has(path)) s.delete(path);
|
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
|
||||||
else s.add(path);
|
let fileSize = $state(0);
|
||||||
openDirs = s;
|
let fileError = $state<string | null>(null);
|
||||||
|
let fileLoading = $state(false);
|
||||||
|
let isDirty = $state(false);
|
||||||
|
let editorContent = $state('');
|
||||||
|
|
||||||
|
// Extension-based type detection
|
||||||
|
const CODE_EXTS = new Set([
|
||||||
|
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
||||||
|
'py', 'rs', 'go', 'css', 'scss', 'less',
|
||||||
|
'html', 'svelte', 'vue',
|
||||||
|
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
|
||||||
|
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
|
||||||
|
]);
|
||||||
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
|
||||||
|
const PDF_EXTS = new Set(['pdf']);
|
||||||
|
const CSV_EXTS = new Set(['csv', 'tsv']);
|
||||||
|
|
||||||
|
function getExt(name: string): string {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(path: string) {
|
type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
|
||||||
selectedFile = path;
|
|
||||||
|
function detectFileType(name: string): FileType {
|
||||||
|
const ext = getExt(name);
|
||||||
|
if (PDF_EXTS.has(ext)) return 'pdf';
|
||||||
|
if (CSV_EXTS.has(ext)) return 'csv';
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
|
if (CODE_EXTS.has(ext)) return 'code';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map extension to CodeMirror language name. */
|
||||||
|
function extToLang(name: string): string {
|
||||||
|
const ext = getExt(name);
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||||
|
mjs: 'javascript', cjs: 'javascript',
|
||||||
|
py: 'python', rs: 'rust', go: 'go',
|
||||||
|
css: 'css', scss: 'css', less: 'css',
|
||||||
|
html: 'html', svelte: 'html', vue: 'html',
|
||||||
|
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
|
||||||
|
toml: 'toml', sh: 'bash', bash: 'bash',
|
||||||
|
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
|
||||||
|
java: 'java', php: 'php',
|
||||||
|
};
|
||||||
|
return map[ext] ?? 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
|
||||||
|
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
|
||||||
|
|
||||||
|
/** Load directory entries via RPC. */
|
||||||
|
async function loadDir(dirPath: string) {
|
||||||
|
if (childrenCache.has(dirPath)) return;
|
||||||
|
const key = dirPath;
|
||||||
|
loadingDirs = new Set([...loadingDirs, key]);
|
||||||
|
try {
|
||||||
|
const result = await appRpc.request["files.list"]({ path: dirPath });
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[files.list] ${dirPath}: ${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new Map(childrenCache);
|
||||||
|
next.set(dirPath, result.entries);
|
||||||
|
childrenCache = next;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[files.list]', err);
|
||||||
|
} finally {
|
||||||
|
const s = new Set(loadingDirs);
|
||||||
|
s.delete(key);
|
||||||
|
loadingDirs = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle a directory open/closed. Lazy-loads on first open. */
|
||||||
|
async function toggleDir(dirPath: string) {
|
||||||
|
const s = new Set(openDirs);
|
||||||
|
if (s.has(dirPath)) {
|
||||||
|
s.delete(dirPath);
|
||||||
|
openDirs = s;
|
||||||
|
} else {
|
||||||
|
s.add(dirPath);
|
||||||
|
openDirs = s;
|
||||||
|
await loadDir(dirPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select and load a file. */
|
||||||
|
async function selectFile(filePath: string) {
|
||||||
|
if (selectedFile === filePath) return;
|
||||||
|
selectedFile = filePath;
|
||||||
|
isDirty = false;
|
||||||
|
fileContent = null;
|
||||||
|
fileError = null;
|
||||||
|
fileLoading = true;
|
||||||
|
|
||||||
|
const type = detectFileType(filePath);
|
||||||
|
|
||||||
|
// PDF uses its own loader via PdfViewer
|
||||||
|
if (type === 'pdf') {
|
||||||
|
fileLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images: read as base64 for display
|
||||||
|
if (type === 'image') {
|
||||||
|
try {
|
||||||
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||||
|
if (result.error) {
|
||||||
|
fileError = result.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileContent = result.content ?? '';
|
||||||
|
fileEncoding = result.encoding;
|
||||||
|
fileSize = result.size;
|
||||||
|
} catch (err) {
|
||||||
|
fileError = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
fileLoading = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||||
|
if (result.error) {
|
||||||
|
fileError = result.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileContent = result.content ?? '';
|
||||||
|
fileEncoding = result.encoding;
|
||||||
|
fileSize = result.size;
|
||||||
|
editorContent = fileContent;
|
||||||
|
} catch (err) {
|
||||||
|
fileError = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
fileLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save current file. */
|
||||||
|
async function saveFile() {
|
||||||
|
if (!selectedFile || !isDirty) return;
|
||||||
|
try {
|
||||||
|
const result = await appRpc.request["files.write"]({
|
||||||
|
path: selectedFile,
|
||||||
|
content: editorContent,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
isDirty = false;
|
||||||
|
fileContent = editorContent;
|
||||||
|
} else if (result.error) {
|
||||||
|
console.error('[files.write]', result.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[files.write]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditorChange(newContent: string) {
|
||||||
|
editorContent = newContent;
|
||||||
|
isDirty = newContent !== fileContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIcon(name: string): string {
|
function fileIcon(name: string): string {
|
||||||
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩';
|
const ext = getExt(name);
|
||||||
if (name.endsWith('.svelte')) return '◈';
|
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
|
||||||
if (name.endsWith('.rs')) return '⊕';
|
if (ext === 'svelte' || ext === 'vue') return '~';
|
||||||
if (name.endsWith('.toml')) return '⚙';
|
if (ext === 'rs') return 'Rs';
|
||||||
if (name.endsWith('.json')) return '{}';
|
if (ext === 'py') return 'Py';
|
||||||
return '·';
|
if (ext === 'go') return 'Go';
|
||||||
|
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
|
||||||
|
if (ext === 'md') return 'M';
|
||||||
|
if (ext === 'css' || ext === 'scss') return '#';
|
||||||
|
if (ext === 'html') return '<>';
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'Im';
|
||||||
|
if (ext === 'pdf') return 'Pd';
|
||||||
|
if (CSV_EXTS.has(ext)) return 'Tb';
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load root directory on mount
|
||||||
|
$effect(() => {
|
||||||
|
if (cwd) {
|
||||||
|
loadDir(cwd);
|
||||||
|
const s = new Set(openDirs);
|
||||||
|
s.add(cwd);
|
||||||
|
openDirs = s;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="file-browser">
|
<div class="file-browser">
|
||||||
|
<!-- Tree panel -->
|
||||||
<div class="fb-tree">
|
<div class="fb-tree">
|
||||||
{#snippet renderNode(node: FileNode, path: string, depth: number)}
|
{#snippet renderEntries(dirPath: string, depth: number)}
|
||||||
{#if node.type === 'dir'}
|
{#if childrenCache.has(dirPath)}
|
||||||
<button
|
{#each childrenCache.get(dirPath) ?? [] as entry}
|
||||||
class="fb-row fb-dir"
|
{@const fullPath = `${dirPath}/${entry.name}`}
|
||||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
{#if entry.type === 'dir'}
|
||||||
onclick={() => toggleDir(path)}
|
<button
|
||||||
aria-expanded={openDirs.has(path)}
|
class="fb-row fb-dir"
|
||||||
>
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||||
<span class="fb-chevron" class:open={openDirs.has(path)}>›</span>
|
onclick={() => toggleDir(fullPath)}
|
||||||
<span class="fb-icon dir-icon">📁</span>
|
aria-expanded={openDirs.has(fullPath)}
|
||||||
<span class="fb-name">{node.name}</span>
|
>
|
||||||
</button>
|
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
|
||||||
{#if openDirs.has(path) && node.children}
|
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
|
||||||
{#each node.children as child}
|
</span>
|
||||||
{@render renderNode(child, `${path}/${child.name}`, depth + 1)}
|
<span class="fb-name">{entry.name}</span>
|
||||||
{/each}
|
</button>
|
||||||
{/if}
|
{#if openDirs.has(fullPath)}
|
||||||
{:else}
|
{@render renderEntries(fullPath, depth + 1)}
|
||||||
<button
|
{/if}
|
||||||
class="fb-row fb-file"
|
{:else}
|
||||||
class:selected={selectedFile === path}
|
<button
|
||||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
class="fb-row fb-file"
|
||||||
onclick={() => selectFile(path)}
|
class:selected={selectedFile === fullPath}
|
||||||
title={path}
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||||
>
|
onclick={() => selectFile(fullPath)}
|
||||||
<span class="fb-icon file-type">{fileIcon(node.name)}</span>
|
title={`${entry.name} (${formatSize(entry.size)})`}
|
||||||
<span class="fb-name">{node.name}</span>
|
>
|
||||||
</button>
|
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
|
||||||
|
<span class="fb-name">{entry.name}</span>
|
||||||
|
{#if selectedFile === fullPath && isDirty}
|
||||||
|
<span class="dirty-dot" title="Unsaved changes"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{:else if loadingDirs.has(dirPath)}
|
||||||
|
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#each TREE as node}
|
{@render renderEntries(cwd, 0)}
|
||||||
{@render renderNode(node, node.name, 0)}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selectedFile}
|
<!-- Viewer panel -->
|
||||||
<div class="fb-preview">
|
<div class="fb-viewer">
|
||||||
<div class="fb-preview-label">{selectedFile}</div>
|
{#if !selectedFile}
|
||||||
<div class="fb-preview-content">(click to open in editor)</div>
|
<div class="fb-empty">Select a file to view</div>
|
||||||
</div>
|
{:else if fileLoading}
|
||||||
{/if}
|
<div class="fb-empty">Loading...</div>
|
||||||
|
{:else if fileError}
|
||||||
|
<div class="fb-error">{fileError}</div>
|
||||||
|
{:else if selectedType === 'pdf'}
|
||||||
|
<PdfViewer filePath={selectedFile} />
|
||||||
|
{:else if selectedType === 'csv' && fileContent != null}
|
||||||
|
<CsvTable content={fileContent} filename={selectedName} />
|
||||||
|
{:else if selectedType === 'image' && fileContent}
|
||||||
|
{@const ext = getExt(selectedName)}
|
||||||
|
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
|
||||||
|
<div class="fb-image-wrap">
|
||||||
|
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
|
||||||
|
<img
|
||||||
|
class="fb-image"
|
||||||
|
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
|
||||||
|
alt={selectedName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if selectedType === 'code' && fileContent != null}
|
||||||
|
<div class="fb-editor-header">
|
||||||
|
<span class="fb-editor-path" title={selectedFile}>
|
||||||
|
{selectedName}
|
||||||
|
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||||
|
</span>
|
||||||
|
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
content={fileContent}
|
||||||
|
lang={extToLang(selectedFile)}
|
||||||
|
onsave={saveFile}
|
||||||
|
onchange={onEditorChange}
|
||||||
|
onblur={saveFile}
|
||||||
|
/>
|
||||||
|
{:else if fileContent != null}
|
||||||
|
<!-- Raw text fallback -->
|
||||||
|
<div class="fb-editor-header">
|
||||||
|
<span class="fb-editor-path" title={selectedFile}>
|
||||||
|
{selectedName}
|
||||||
|
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||||
|
</span>
|
||||||
|
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
content={fileContent}
|
||||||
|
lang="text"
|
||||||
|
onsave={saveFile}
|
||||||
|
onchange={onEditorChange}
|
||||||
|
onblur={saveFile}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.file-browser {
|
.file-browser {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tree panel ── */
|
||||||
.fb-tree {
|
.fb-tree {
|
||||||
flex: 1;
|
width: 14rem;
|
||||||
|
min-width: 10rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
|
border-right: 1px solid var(--ctp-surface0);
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
|
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
|
||||||
|
|
@ -163,25 +382,25 @@
|
||||||
.fb-chevron {
|
.fb-chevron {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
color: var(--ctp-overlay1);
|
color: var(--ctp-overlay1);
|
||||||
transition: transform 0.12s;
|
transition: transform 0.12s;
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-family: var(--term-font-family, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-chevron.open { transform: rotate(90deg); }
|
.fb-chevron.open { transform: rotate(90deg); }
|
||||||
|
|
||||||
.fb-icon {
|
.fb-icon { flex-shrink: 0; font-style: normal; }
|
||||||
flex-shrink: 0;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-type {
|
.file-type {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6rem;
|
||||||
color: var(--ctp-overlay1);
|
color: var(--ctp-overlay1);
|
||||||
font-family: var(--term-font-family);
|
font-family: var(--term-font-family, monospace);
|
||||||
|
width: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-name {
|
.fb-name {
|
||||||
|
|
@ -192,26 +411,104 @@
|
||||||
|
|
||||||
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
|
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
|
||||||
|
|
||||||
.fb-preview {
|
.fb-loading {
|
||||||
border-top: 1px solid var(--ctp-surface0);
|
font-size: 0.7rem;
|
||||||
padding: 0.5rem 0.75rem;
|
color: var(--ctp-overlay0);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirty-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ctp-peach);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Viewer panel ── */
|
||||||
|
.fb-viewer {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--ctp-red);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
background: var(--ctp-mantle);
|
background: var(--ctp-mantle);
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-preview-label {
|
.fb-editor-path {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
color: var(--ctp-subtext0);
|
color: var(--ctp-subtext0);
|
||||||
font-family: var(--term-font-family);
|
font-family: var(--term-font-family, monospace);
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-preview-content {
|
.dirty-indicator {
|
||||||
font-size: 0.75rem;
|
color: var(--ctp-peach);
|
||||||
color: var(--ctp-overlay0);
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fb-editor-size {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-image-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-image-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-family: var(--term-font-family, monospace);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100% - 2rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal file
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, untrack } from 'svelte';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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(fp: string) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read file as base64 via RPC, then convert to Uint8Array
|
||||||
|
const result = await appRpc.request["files.read"]({ path: fp });
|
||||||
|
if (result.error) {
|
||||||
|
error = result.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.content) {
|
||||||
|
error = 'Empty file';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Uint8Array;
|
||||||
|
if (result.encoding === 'base64') {
|
||||||
|
const binary = atob(result.content);
|
||||||
|
data = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
|
||||||
|
} else {
|
||||||
|
// Shouldn't happen for PDF but handle gracefully
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
data = encoder.encode(result.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaceholders() {
|
||||||
|
if (!pdfDoc || !container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
renderedPages.clear();
|
||||||
|
renderingPages.clear();
|
||||||
|
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = new IntersectionObserver(onIntersect, {
|
||||||
|
root: container,
|
||||||
|
rootMargin: '200px 0px',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'pdf-page-slot';
|
||||||
|
placeholder.dataset.page = String(i);
|
||||||
|
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`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
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); });
|
||||||
|
|
||||||
|
let lastPath = $state(untrack(() => 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>
|
||||||
|
|
@ -3,12 +3,15 @@
|
||||||
import TerminalTabs from './TerminalTabs.svelte';
|
import TerminalTabs from './TerminalTabs.svelte';
|
||||||
import FileBrowser from './FileBrowser.svelte';
|
import FileBrowser from './FileBrowser.svelte';
|
||||||
import MemoryTab from './MemoryTab.svelte';
|
import MemoryTab from './MemoryTab.svelte';
|
||||||
|
import CommsTab from './CommsTab.svelte';
|
||||||
|
import TaskBoardTab from './TaskBoardTab.svelte';
|
||||||
import {
|
import {
|
||||||
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
||||||
|
loadLastSession,
|
||||||
type AgentStatus, type AgentMessage,
|
type AgentStatus, type AgentMessage,
|
||||||
} from './agent-store.svelte.ts';
|
} from './agent-store.svelte.ts';
|
||||||
|
|
||||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory' | 'comms' | 'tasks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -29,6 +32,8 @@
|
||||||
clonesAtMax?: boolean;
|
clonesAtMax?: boolean;
|
||||||
/** Callback when user requests cloning (receives projectId and branch name). */
|
/** Callback when user requests cloning (receives projectId and branch name). */
|
||||||
onClone?: (projectId: string, branch: string) => void;
|
onClone?: (projectId: string, branch: string) => void;
|
||||||
|
/** Group ID for btmsg/bttask context. */
|
||||||
|
groupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -315,7 +320,7 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label="Files"
|
aria-label="Files"
|
||||||
>
|
>
|
||||||
<FileBrowser />
|
<FileBrowser {cwd} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal file
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate?: (resultType: string, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose, onNavigate }: Props = $props();
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
resultType: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let results = $state<SearchResult[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
// Debounce timer
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Group results by type
|
||||||
|
let grouped = $derived(() => {
|
||||||
|
const groups: Record<string, SearchResult[]> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
const key = r.resultType;
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(r);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
let groupLabels: Record<string, string> = {
|
||||||
|
message: 'Messages',
|
||||||
|
task: 'Tasks',
|
||||||
|
btmsg: 'Communications',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => doSearch(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
|
||||||
|
results = res.results ?? [];
|
||||||
|
selectedIndex = 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[search]', err);
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex < results.length - 1) selectedIndex++;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex > 0) selectedIndex--;
|
||||||
|
} else if (e.key === 'Enter' && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = results[selectedIndex];
|
||||||
|
if (item) {
|
||||||
|
onNavigate?.(item.resultType, item.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResult(idx: number) {
|
||||||
|
const item = results[idx];
|
||||||
|
if (item) {
|
||||||
|
onNavigate?.(item.resultType, item.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus input when opened
|
||||||
|
$effect(() => {
|
||||||
|
if (open && inputEl) {
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
query = '';
|
||||||
|
results = [];
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
class="search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search messages, tasks, communications..."
|
||||||
|
bind:value={query}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
{#if loading}
|
||||||
|
<span class="loading-dot" aria-label="Searching"></span>
|
||||||
|
{/if}
|
||||||
|
<kbd class="esc-hint">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if results.length > 0}
|
||||||
|
<div class="results-list">
|
||||||
|
{#each Object.entries(grouped()) as [type, items]}
|
||||||
|
<div class="result-group">
|
||||||
|
<div class="group-label">{groupLabels[type] ?? type}</div>
|
||||||
|
{#each items as item, i}
|
||||||
|
{@const flatIdx = results.indexOf(item)}
|
||||||
|
<button
|
||||||
|
class="result-item"
|
||||||
|
class:selected={flatIdx === selectedIndex}
|
||||||
|
onclick={() => selectResult(flatIdx)}
|
||||||
|
onmouseenter={() => selectedIndex = flatIdx}
|
||||||
|
>
|
||||||
|
<span class="result-title">{item.title}</span>
|
||||||
|
<span class="result-snippet">{@html item.snippet}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if query.trim() && !loading}
|
||||||
|
<div class="no-results">No results for "{query}"</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-panel {
|
||||||
|
width: min(36rem, 90vw);
|
||||||
|
max-height: 60vh;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ctp-blue);
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.esc-hint {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-group {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover, .result-item.selected {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet :global(b) {
|
||||||
|
color: var(--ctp-yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list::-webkit-scrollbar { width: 0.375rem; }
|
||||||
|
.results-list::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.results-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||||
|
</style>
|
||||||
275
ui-electrobun/src/mainview/StatusBar.svelte
Normal file
275
ui-electrobun/src/mainview/StatusBar.svelte
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getHealthAggregates,
|
||||||
|
getAttentionQueue,
|
||||||
|
type ProjectHealth,
|
||||||
|
} from './health-store.svelte.ts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
sessionDuration: string;
|
||||||
|
groupName: string;
|
||||||
|
onFocusProject?: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
projectCount,
|
||||||
|
totalTokens,
|
||||||
|
totalCost,
|
||||||
|
sessionDuration,
|
||||||
|
groupName,
|
||||||
|
onFocusProject,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let health = $derived(getHealthAggregates());
|
||||||
|
let attentionQueue = $derived(getAttentionQueue(5));
|
||||||
|
let showAttention = $state(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 fmtTokens(n: number): string {
|
||||||
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCost(n: number): string {
|
||||||
|
return `$${n.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusProject(projectId: string) {
|
||||||
|
onFocusProject?.(projectId);
|
||||||
|
showAttention = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
||||||
|
<!-- Left: agent state counts -->
|
||||||
|
{#if health.running > 0}
|
||||||
|
<span class="status-segment">
|
||||||
|
<span class="dot green pulse-dot" aria-hidden="true"></span>
|
||||||
|
<span class="val">{health.running}</span>
|
||||||
|
<span>running</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if health.idle > 0}
|
||||||
|
<span class="status-segment">
|
||||||
|
<span class="dot gray" aria-hidden="true"></span>
|
||||||
|
<span class="val">{health.idle}</span>
|
||||||
|
<span>idle</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if health.stalled > 0}
|
||||||
|
<span class="status-segment stalled">
|
||||||
|
<span class="dot orange" aria-hidden="true"></span>
|
||||||
|
<span class="val">{health.stalled}</span>
|
||||||
|
<span>stalled</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Attention queue -->
|
||||||
|
{#if attentionQueue.length > 0}
|
||||||
|
<button
|
||||||
|
class="status-segment attn-btn"
|
||||||
|
class:attn-open={showAttention}
|
||||||
|
onclick={() => showAttention = !showAttention}
|
||||||
|
title="Needs attention"
|
||||||
|
>
|
||||||
|
<span class="dot orange pulse-dot" aria-hidden="true"></span>
|
||||||
|
<span class="val">{attentionQueue.length}</span>
|
||||||
|
<span>attention</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<!-- Right: aggregates -->
|
||||||
|
{#if health.totalBurnRatePerHour > 0}
|
||||||
|
<span class="status-segment burn" title="Burn rate">
|
||||||
|
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="status-segment" title="Active group">
|
||||||
|
<span class="val">{groupName}</span>
|
||||||
|
</span>
|
||||||
|
<span class="status-segment" title="Projects">
|
||||||
|
<span class="val">{projectCount}</span>
|
||||||
|
<span>projects</span>
|
||||||
|
</span>
|
||||||
|
<span class="status-segment" title="Session duration">
|
||||||
|
<span>session</span>
|
||||||
|
<span class="val">{sessionDuration}</span>
|
||||||
|
</span>
|
||||||
|
<span class="status-segment" title="Total tokens">
|
||||||
|
<span>tokens</span>
|
||||||
|
<span class="val">{fmtTokens(totalTokens)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="status-segment" title="Total cost">
|
||||||
|
<span>cost</span>
|
||||||
|
<span class="val cost">{fmtCost(totalCost)}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Attention 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-id">{item.projectId.slice(0, 12)}</span>
|
||||||
|
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
|
||||||
|
{#if item.contextPressure !== null && item.contextPressure > 0.5}
|
||||||
|
<span class="card-ctx">ctx {Math.round(item.contextPressure * 100)}%</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-bar {
|
||||||
|
height: var(--status-bar-height, 1.5rem);
|
||||||
|
background: var(--ctp-crust);
|
||||||
|
border-top: 1px solid var(--ctp-surface0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-segment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.green { background: var(--ctp-green); }
|
||||||
|
.dot.gray { background: var(--ctp-overlay0); }
|
||||||
|
.dot.orange { background: var(--ctp-peach); }
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.val { color: var(--ctp-text); font-weight: 500; }
|
||||||
|
.cost { color: var(--ctp-yellow); }
|
||||||
|
.burn { color: var(--ctp-mauve); font-weight: 600; }
|
||||||
|
.stalled { color: var(--ctp-peach); font-weight: 600; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* Attention button */
|
||||||
|
.attn-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
font: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attn-btn:hover, .attn-btn.attn-open {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attention panel */
|
||||||
|
.attention-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--status-bar-height, 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-id { font-weight: 600; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-hint {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-hint:hover { color: var(--ctp-subtext0); }
|
||||||
|
</style>
|
||||||
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal file
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
assignedTo: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
groupId: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupId: string;
|
||||||
|
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { groupId, agentId = 'admin' }: Props = $props();
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
||||||
|
const COL_LABELS: Record<string, string> = {
|
||||||
|
todo: 'To Do', progress: 'In Progress', review: 'Review',
|
||||||
|
done: 'Done', blocked: 'Blocked',
|
||||||
|
};
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
|
||||||
|
low: 'var(--ctp-teal)',
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasks = $state<Task[]>([]);
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let newTitle = $state('');
|
||||||
|
let newDesc = $state('');
|
||||||
|
let newPriority = $state('medium');
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
let draggedTaskId = $state<string | null>(null);
|
||||||
|
let dragOverCol = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tasksByCol = $derived(
|
||||||
|
COLUMNS.reduce((acc, col) => {
|
||||||
|
acc[col] = tasks.filter(t => t.status === col);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Task[]>)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Data fetching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
||||||
|
tasks = res.tasks;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TaskBoard] loadTasks:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask() {
|
||||||
|
const title = newTitle.trim();
|
||||||
|
if (!title) { error = 'Title required'; return; }
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['bttask.createTask']({
|
||||||
|
title,
|
||||||
|
description: newDesc.trim(),
|
||||||
|
priority: newPriority,
|
||||||
|
groupId,
|
||||||
|
createdBy: agentId,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
newTitle = '';
|
||||||
|
newDesc = '';
|
||||||
|
newPriority = 'medium';
|
||||||
|
showCreateForm = false;
|
||||||
|
await loadTasks();
|
||||||
|
} else {
|
||||||
|
error = res.error ?? 'Failed to create task';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TaskBoard] createTask:', err);
|
||||||
|
error = 'Failed to create task';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTask(taskId: string, newStatus: string) {
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['bttask.updateTaskStatus']({
|
||||||
|
taskId,
|
||||||
|
status: newStatus,
|
||||||
|
expectedVersion: task.version,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Optimistic update
|
||||||
|
task.status = newStatus;
|
||||||
|
task.version = res.newVersion ?? task.version + 1;
|
||||||
|
tasks = [...tasks]; // trigger reactivity
|
||||||
|
} else {
|
||||||
|
error = res.error ?? 'Version conflict';
|
||||||
|
await loadTasks(); // reload on conflict
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TaskBoard] moveTask:', err);
|
||||||
|
await loadTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId: string) {
|
||||||
|
try {
|
||||||
|
await appRpc.request['bttask.deleteTask']({ taskId });
|
||||||
|
tasks = tasks.filter(t => t.id !== taskId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TaskBoard] deleteTask:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, taskId: string) {
|
||||||
|
draggedTaskId = taskId;
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, col: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCol = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragOverCol = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent, col: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCol = null;
|
||||||
|
if (draggedTaskId) {
|
||||||
|
moveTask(draggedTaskId, col);
|
||||||
|
draggedTaskId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
draggedTaskId = null;
|
||||||
|
dragOverCol = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init + polling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadTasks();
|
||||||
|
pollTimer = setInterval(loadTasks, 5000);
|
||||||
|
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="task-board">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="tb-toolbar">
|
||||||
|
<span class="tb-title">Task Board</span>
|
||||||
|
<span class="tb-count">{tasks.length} tasks</span>
|
||||||
|
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||||
|
{showCreateForm ? 'Cancel' : '+ Task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create form -->
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="tb-create-form">
|
||||||
|
<input
|
||||||
|
class="tb-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Task title"
|
||||||
|
bind:value={newTitle}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="tb-input tb-desc"
|
||||||
|
type="text"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
bind:value={newDesc}
|
||||||
|
/>
|
||||||
|
<div class="tb-form-row">
|
||||||
|
<select class="tb-select" bind:value={newPriority}>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
<button class="tb-submit" onclick={createTask}>Create</button>
|
||||||
|
</div>
|
||||||
|
{#if error}
|
||||||
|
<span class="tb-error">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kanban columns -->
|
||||||
|
<div class="tb-columns">
|
||||||
|
{#each COLUMNS as col}
|
||||||
|
<div
|
||||||
|
class="tb-column"
|
||||||
|
class:drag-over={dragOverCol === col}
|
||||||
|
ondragover={(e) => onDragOver(e, col)}
|
||||||
|
ondragleave={onDragLeave}
|
||||||
|
ondrop={(e) => onDrop(e, col)}
|
||||||
|
role="list"
|
||||||
|
aria-label="{COL_LABELS[col]} column"
|
||||||
|
>
|
||||||
|
<div class="tb-col-header">
|
||||||
|
<span class="tb-col-label">{COL_LABELS[col]}</span>
|
||||||
|
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tb-col-body">
|
||||||
|
{#each tasksByCol[col] ?? [] as task (task.id)}
|
||||||
|
<div
|
||||||
|
class="tb-card"
|
||||||
|
class:dragging={draggedTaskId === task.id}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, task.id)}
|
||||||
|
ondragend={onDragEnd}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<span
|
||||||
|
class="priority-dot"
|
||||||
|
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
|
||||||
|
title="Priority: {task.priority}"
|
||||||
|
></span>
|
||||||
|
<span class="card-title">{task.title}</span>
|
||||||
|
<button
|
||||||
|
class="card-delete"
|
||||||
|
onclick={() => deleteTask(task.id)}
|
||||||
|
title="Delete task"
|
||||||
|
aria-label="Delete task"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{#if task.description}
|
||||||
|
<div class="card-desc">{task.description}</div>
|
||||||
|
{/if}
|
||||||
|
{#if task.assignedTo}
|
||||||
|
<div class="card-assignee">
|
||||||
|
<span class="assignee-icon">@</span>
|
||||||
|
{task.assignedTo}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.task-board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-add-btn {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
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-family: var(--ui-font-family);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-add-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-input {
|
||||||
|
height: 1.625rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-input:focus { border-color: var(--ctp-mauve); }
|
||||||
|
|
||||||
|
.tb-form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-select {
|
||||||
|
height: 1.625rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-submit {
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
height: 1.625rem;
|
||||||
|
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
|
||||||
|
border: 1px solid var(--ctp-mauve);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
font-family: var(--ui-font-family);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-submit:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-column.drag-over {
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-col-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-col-count {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-col-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-card {
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
border: 1px solid var(--ctp-surface0);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
cursor: grab;
|
||||||
|
transition: border-color 0.12s, opacity 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-card:hover { border-color: var(--ctp-surface1); }
|
||||||
|
.tb-card.dragging { opacity: 0.4; }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-delete {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-card:hover .card-delete { opacity: 1; }
|
||||||
|
.card-delete:hover { color: var(--ctp-red); }
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-assignee {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-icon {
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -74,6 +74,54 @@ let sessions = $state<Record<string, AgentSession>>({});
|
||||||
// Grace period timers for cleanup after done/error
|
// Grace period timers for cleanup after done/error
|
||||||
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Debounce timer for message persistence
|
||||||
|
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// ── Session persistence helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function persistSession(session: AgentSession): void {
|
||||||
|
appRpc.request['session.save']({
|
||||||
|
projectId: session.projectId,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
provider: session.provider,
|
||||||
|
status: session.status,
|
||||||
|
costUsd: session.costUsd,
|
||||||
|
inputTokens: session.inputTokens,
|
||||||
|
outputTokens: session.outputTokens,
|
||||||
|
model: session.model,
|
||||||
|
error: session.error,
|
||||||
|
createdAt: session.messages[0]?.timestamp ?? Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.error('[session.save] persist error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistMessages(session: AgentSession): void {
|
||||||
|
// Debounce: batch message saves every 2 seconds
|
||||||
|
const existing = msgPersistTimers.get(session.sessionId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
msgPersistTimers.delete(session.sessionId);
|
||||||
|
const msgs = session.messages.map((m) => ({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
msgId: m.id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
toolName: m.toolName,
|
||||||
|
toolInput: m.toolInput,
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
}));
|
||||||
|
if (msgs.length === 0) return;
|
||||||
|
appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => {
|
||||||
|
console.error('[session.messages.save] persist error:', err);
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
msgPersistTimers.set(session.sessionId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
// ── RPC event listeners (registered once) ────────────────────────────────────
|
// ── RPC event listeners (registered once) ────────────────────────────────────
|
||||||
|
|
||||||
let listenersRegistered = false;
|
let listenersRegistered = false;
|
||||||
|
|
@ -104,6 +152,7 @@ function ensureListeners() {
|
||||||
|
|
||||||
if (converted.length > 0) {
|
if (converted.length > 0) {
|
||||||
session.messages = [...session.messages, ...converted];
|
session.messages = [...session.messages, ...converted];
|
||||||
|
persistMessages(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -119,8 +168,18 @@ function ensureListeners() {
|
||||||
session.status = normalizeStatus(payload.status);
|
session.status = normalizeStatus(payload.status);
|
||||||
if (payload.error) session.error = payload.error;
|
if (payload.error) session.error = payload.error;
|
||||||
|
|
||||||
|
// Persist on every status change
|
||||||
|
persistSession(session);
|
||||||
|
|
||||||
// Schedule cleanup after done/error (Fix #2)
|
// Schedule cleanup after done/error (Fix #2)
|
||||||
if (session.status === 'done' || session.status === 'error') {
|
if (session.status === 'done' || session.status === 'error') {
|
||||||
|
// Flush any pending message persistence immediately
|
||||||
|
const pendingTimer = msgPersistTimers.get(session.sessionId);
|
||||||
|
if (pendingTimer) {
|
||||||
|
clearTimeout(pendingTimer);
|
||||||
|
msgPersistTimers.delete(session.sessionId);
|
||||||
|
}
|
||||||
|
persistMessages(session);
|
||||||
scheduleCleanup(session.sessionId, session.projectId);
|
scheduleCleanup(session.sessionId, session.projectId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -427,5 +486,57 @@ export function clearSession(projectId: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the last session for a project from SQLite (for restart recovery).
|
||||||
|
* Restores session state + messages into the reactive store.
|
||||||
|
* Only restores done/error sessions (running sessions are gone after restart).
|
||||||
|
*/
|
||||||
|
export async function loadLastSession(projectId: string): Promise<boolean> {
|
||||||
|
ensureListeners();
|
||||||
|
try {
|
||||||
|
const { session } = await appRpc.request['session.load']({ projectId });
|
||||||
|
if (!session) return false;
|
||||||
|
|
||||||
|
// Only restore completed sessions (running sessions can't be resumed)
|
||||||
|
if (session.status !== 'done' && session.status !== 'error') return false;
|
||||||
|
|
||||||
|
// Load messages for this session
|
||||||
|
const { messages: storedMsgs } = await appRpc.request['session.messages.load']({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredMessages: AgentMessage[] = storedMsgs.map((m: {
|
||||||
|
msgId: string; role: string; content: string;
|
||||||
|
toolName?: string; toolInput?: string; timestamp: number;
|
||||||
|
}) => ({
|
||||||
|
id: m.msgId,
|
||||||
|
role: m.role as MsgRole,
|
||||||
|
content: m.content,
|
||||||
|
toolName: m.toolName,
|
||||||
|
toolInput: m.toolInput,
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
sessions[session.sessionId] = {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
projectId: session.projectId,
|
||||||
|
provider: session.provider,
|
||||||
|
status: normalizeStatus(session.status),
|
||||||
|
messages: restoredMessages,
|
||||||
|
costUsd: session.costUsd,
|
||||||
|
inputTokens: session.inputTokens,
|
||||||
|
outputTokens: session.outputTokens,
|
||||||
|
model: session.model,
|
||||||
|
error: session.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
projectSessionMap.set(projectId, session.sessionId);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[loadLastSession] error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Initialize listeners on module load. */
|
/** Initialize listeners on module load. */
|
||||||
ensureListeners();
|
ensureListeners();
|
||||||
|
|
|
||||||
229
ui-electrobun/src/mainview/health-store.svelte.ts
Normal file
229
ui-electrobun/src/mainview/health-store.svelte.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
/**
|
||||||
|
* Per-project health tracking — Svelte 5 runes.
|
||||||
|
*
|
||||||
|
* Tracks activity state, burn rate (5-min EMA from cost snapshots),
|
||||||
|
* context pressure (tokens / model limit), and attention scoring.
|
||||||
|
* 5-second tick timer drives derived state updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
|
||||||
|
|
||||||
|
export interface ProjectHealth {
|
||||||
|
projectId: string;
|
||||||
|
activityState: ActivityState;
|
||||||
|
activeTool: string | null;
|
||||||
|
idleDurationMs: number;
|
||||||
|
burnRatePerHour: number;
|
||||||
|
contextPressure: number | null;
|
||||||
|
fileConflictCount: number;
|
||||||
|
attentionScore: number;
|
||||||
|
attentionReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const TICK_INTERVAL_MS = 5_000;
|
||||||
|
const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
||||||
|
|
||||||
|
// ── Internal state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProjectTracker {
|
||||||
|
projectId: string;
|
||||||
|
lastActivityTs: number;
|
||||||
|
lastToolName: string | null;
|
||||||
|
toolInFlight: boolean;
|
||||||
|
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackers = $state<Map<string, ProjectTracker>>(new Map());
|
||||||
|
let tickTs = $state<number>(Date.now());
|
||||||
|
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// ── Attention scoring (pure) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scoreAttention(
|
||||||
|
activityState: ActivityState,
|
||||||
|
contextPressure: number | null,
|
||||||
|
fileConflictCount: number,
|
||||||
|
status: string,
|
||||||
|
): { score: number; reason: string | null } {
|
||||||
|
if (status === 'error') return { score: 90, reason: 'Agent error' };
|
||||||
|
if (activityState === 'stalled') return { score: 100, reason: 'Agent stalled (>15 min)' };
|
||||||
|
if (contextPressure !== null && contextPressure > 0.9) return { score: 80, reason: 'Context >90%' };
|
||||||
|
if (fileConflictCount > 0) return { score: 70, reason: `${fileConflictCount} file conflict(s)` };
|
||||||
|
if (contextPressure !== null && contextPressure > 0.75) return { score: 40, reason: 'Context >75%' };
|
||||||
|
return { score: 0, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Burn rate calculation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
|
const costDelta = last[1] - first[1];
|
||||||
|
return Math.max(0, costDelta / elapsedHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived health per project ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
|
let activityState: ActivityState;
|
||||||
|
let idleDurationMs = 0;
|
||||||
|
let activeTool: string | null = null;
|
||||||
|
|
||||||
|
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
|
||||||
|
activityState = 'inactive';
|
||||||
|
} else if (tracker.toolInFlight) {
|
||||||
|
activityState = 'running';
|
||||||
|
activeTool = tracker.lastToolName;
|
||||||
|
} else {
|
||||||
|
idleDurationMs = now - tracker.lastActivityTs;
|
||||||
|
activityState = idleDurationMs >= DEFAULT_STALL_THRESHOLD_MS ? 'stalled' : 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
let contextPressure: number | null = null;
|
||||||
|
if (tracker.totalTokens > 0) {
|
||||||
|
contextPressure = Math.min(1, tracker.totalTokens / DEFAULT_CONTEXT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
|
||||||
|
const attention = scoreAttention(activityState, contextPressure, 0, tracker.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: tracker.projectId,
|
||||||
|
activityState,
|
||||||
|
activeTool,
|
||||||
|
idleDurationMs,
|
||||||
|
burnRatePerHour,
|
||||||
|
contextPressure,
|
||||||
|
fileConflictCount: 0,
|
||||||
|
attentionScore: attention.score,
|
||||||
|
attentionReason: attention.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Register a project for health tracking. */
|
||||||
|
export function trackProject(projectId: string): void {
|
||||||
|
if (trackers.has(projectId)) return;
|
||||||
|
trackers.set(projectId, {
|
||||||
|
projectId,
|
||||||
|
lastActivityTs: Date.now(),
|
||||||
|
lastToolName: null,
|
||||||
|
toolInFlight: false,
|
||||||
|
costSnapshots: [],
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
status: 'inactive',
|
||||||
|
});
|
||||||
|
if (!tickInterval) startHealthTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record activity (call on every agent message). */
|
||||||
|
export function recordActivity(projectId: string, toolName?: string): void {
|
||||||
|
const t = trackers.get(projectId);
|
||||||
|
if (!t) return;
|
||||||
|
t.lastActivityTs = Date.now();
|
||||||
|
t.status = 'running';
|
||||||
|
if (toolName !== undefined) {
|
||||||
|
t.lastToolName = toolName;
|
||||||
|
t.toolInFlight = true;
|
||||||
|
}
|
||||||
|
if (!tickInterval) startHealthTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record tool completion. */
|
||||||
|
export function recordToolDone(projectId: string): 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: string, totalTokens: number, costUsd: number): void {
|
||||||
|
const t = trackers.get(projectId);
|
||||||
|
if (!t) return;
|
||||||
|
const now = Date.now();
|
||||||
|
t.totalTokens = totalTokens;
|
||||||
|
t.totalCost = costUsd;
|
||||||
|
t.costSnapshots.push([now, costUsd]);
|
||||||
|
const cutoff = now - BURN_RATE_WINDOW_MS * 2;
|
||||||
|
t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update project status. */
|
||||||
|
export function setProjectStatus(projectId: string, status: 'running' | 'idle' | 'done' | 'error'): void {
|
||||||
|
const t = trackers.get(projectId);
|
||||||
|
if (t) t.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get health for a single project (reactive via tickTs). */
|
||||||
|
export function getProjectHealth(projectId: string): ProjectHealth | null {
|
||||||
|
const now = tickTs;
|
||||||
|
const t = trackers.get(projectId);
|
||||||
|
if (!t) return null;
|
||||||
|
return computeHealth(t, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get top N items needing attention. */
|
||||||
|
export function getAttentionQueue(limit = 5): ProjectHealth[] {
|
||||||
|
const now = tickTs;
|
||||||
|
const results: ProjectHealth[] = [];
|
||||||
|
for (const t of trackers.values()) {
|
||||||
|
const h = computeHealth(t, now);
|
||||||
|
if (h.attentionScore > 0) results.push(h);
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.attentionScore - a.attentionScore);
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get aggregate stats across all tracked projects. */
|
||||||
|
export function getHealthAggregates(): {
|
||||||
|
running: number;
|
||||||
|
idle: number;
|
||||||
|
stalled: number;
|
||||||
|
totalBurnRatePerHour: number;
|
||||||
|
} {
|
||||||
|
const now = tickTs;
|
||||||
|
let running = 0, idle = 0, stalled = 0, totalBurnRatePerHour = 0;
|
||||||
|
for (const t of trackers.values()) {
|
||||||
|
const h = computeHealth(t, now);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the health tick timer. */
|
||||||
|
function startHealthTick(): void {
|
||||||
|
if (tickInterval) return;
|
||||||
|
tickInterval = setInterval(() => {
|
||||||
|
tickTs = Date.now();
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the health tick timer. */
|
||||||
|
export function stopHealthTick(): void {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
tickInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
287
ui-electrobun/src/mainview/plugin-host.ts
Normal file
287
ui-electrobun/src/mainview/plugin-host.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* Plugin Host — Web Worker sandbox for Electrobun plugins.
|
||||||
|
*
|
||||||
|
* Each plugin runs in a dedicated Web Worker with no DOM/IPC access.
|
||||||
|
* Communication: Main <-> Worker via postMessage.
|
||||||
|
* Permission-gated API (messages, events, notifications, palette).
|
||||||
|
* On unload, Worker is terminated — all plugin state destroyed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PluginMeta {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
main: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
meta: PluginMeta;
|
||||||
|
worker: Worker;
|
||||||
|
callbacks: Map<string, () => void>;
|
||||||
|
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginCommandCallback = () => void;
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadedPlugins = new Map<string, LoadedPlugin>();
|
||||||
|
|
||||||
|
// External command/event registries (set by plugin-store)
|
||||||
|
let commandRegistry: ((pluginId: string, label: string, callback: PluginCommandCallback) => void) | null = null;
|
||||||
|
let commandRemover: ((pluginId: string) => void) | null = null;
|
||||||
|
let eventBus: { on: (event: string, handler: (data: unknown) => void) => void; off: (event: string, handler: (data: unknown) => void) => void } | null = null;
|
||||||
|
|
||||||
|
/** Wire up external registries (called by plugin-store on init). */
|
||||||
|
export function setPluginRegistries(opts: {
|
||||||
|
addCommand: (pluginId: string, label: string, cb: PluginCommandCallback) => void;
|
||||||
|
removeCommands: (pluginId: string) => void;
|
||||||
|
eventBus: { on: (e: string, h: (d: unknown) => void) => void; off: (e: string, h: (d: unknown) => void) => void };
|
||||||
|
}): void {
|
||||||
|
commandRegistry = opts.addCommand;
|
||||||
|
commandRemover = opts.removeCommands;
|
||||||
|
eventBus = opts.eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Worker script builder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildWorkerScript(): string {
|
||||||
|
return `
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const _callbacks = new Map();
|
||||||
|
let _callbackId = 0;
|
||||||
|
function _nextCbId() { return '__cb_' + (++_callbackId); }
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = function(e) {
|
||||||
|
const msg = e.data;
|
||||||
|
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
const permissions = msg.permissions || [];
|
||||||
|
const meta = msg.meta;
|
||||||
|
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 non-empty string');
|
||||||
|
if (typeof callback !== 'function') throw new Error('Command callback must be a function');
|
||||||
|
const cbId = _nextCbId();
|
||||||
|
_callbacks.set(cbId, callback);
|
||||||
|
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('notifications')) {
|
||||||
|
api.notifications = {
|
||||||
|
show(message) {
|
||||||
|
self.postMessage({ type: 'notification', message: String(message) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('messages')) {
|
||||||
|
api.messages = {
|
||||||
|
list() { return _rpc('messages.list', {}); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('events')) {
|
||||||
|
api.events = {
|
||||||
|
on(event, callback) {
|
||||||
|
if (typeof event !== 'string' || typeof callback !== 'function') {
|
||||||
|
throw new Error('events.on requires (string, function)');
|
||||||
|
}
|
||||||
|
const cbId = _nextCbId();
|
||||||
|
_callbacks.set(cbId, callback);
|
||||||
|
self.postMessage({ type: 'event-on', event, callbackId: cbId });
|
||||||
|
},
|
||||||
|
off(event) {
|
||||||
|
self.postMessage({ type: 'event-off', event });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.freeze(api);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fn = (0, eval)('(function(agor) { "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and execute a plugin in a Web Worker sandbox.
|
||||||
|
* Reads plugin code via RPC from Bun process.
|
||||||
|
*/
|
||||||
|
export async function loadPlugin(meta: PluginMeta): Promise<void> {
|
||||||
|
if (loadedPlugins.has(meta.id)) {
|
||||||
|
console.warn(`Plugin '${meta.id}' is already loaded`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
const validPerms = new Set(['palette', 'notifications', 'messages', 'events']);
|
||||||
|
for (const p of meta.permissions) {
|
||||||
|
if (!validPerms.has(p)) {
|
||||||
|
throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read plugin code via RPC
|
||||||
|
let code: string;
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['plugin.readFile']({ pluginId: meta.id, filePath: meta.main });
|
||||||
|
if (!res.ok) throw new Error(res.error ?? 'Failed to read plugin file');
|
||||||
|
code = res.content;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to read plugin '${meta.id}' entry '${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 }> = [];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const msg = e.data;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'loaded':
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
commandRemover?.(meta.id);
|
||||||
|
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
|
||||||
|
worker.terminate();
|
||||||
|
reject(new Error(`Plugin '${meta.id}' failed: ${msg.message}`));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'palette-register': {
|
||||||
|
const cbId = msg.callbackId as string;
|
||||||
|
const invoke = () => worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
|
||||||
|
callbacks.set(cbId, invoke);
|
||||||
|
commandRegistry?.(meta.id, msg.label, invoke);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'notification':
|
||||||
|
console.log(`[plugin:${meta.id}] notification:`, msg.message);
|
||||||
|
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 });
|
||||||
|
eventBus?.on(msg.event, handler);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'event-off': {
|
||||||
|
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
|
||||||
|
if (idx >= 0) {
|
||||||
|
eventBus?.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
|
||||||
|
eventSubscriptions.splice(idx, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'callback-error':
|
||||||
|
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = (err) => reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'init',
|
||||||
|
code,
|
||||||
|
permissions: meta.permissions,
|
||||||
|
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
commandRemover?.(meta.id);
|
||||||
|
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
|
||||||
|
eventSubscriptions.length = 0;
|
||||||
|
callbacks.clear();
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
|
||||||
|
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload a plugin. */
|
||||||
|
export function unloadPlugin(id: string): void {
|
||||||
|
const plugin = loadedPlugins.get(id);
|
||||||
|
if (!plugin) return;
|
||||||
|
plugin.cleanup();
|
||||||
|
loadedPlugins.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all loaded plugin metas. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
136
ui-electrobun/src/mainview/plugin-store.svelte.ts
Normal file
136
ui-electrobun/src/mainview/plugin-store.svelte.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* Plugin store — Svelte 5 runes.
|
||||||
|
*
|
||||||
|
* Discovers plugins from ~/.config/agor/plugins/ via RPC.
|
||||||
|
* Manages command registry (for palette integration) and event bus.
|
||||||
|
* Coordinates with plugin-host.ts for Web Worker lifecycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appRpc } from './rpc.ts';
|
||||||
|
import {
|
||||||
|
loadPlugin,
|
||||||
|
unloadPlugin,
|
||||||
|
unloadAllPlugins,
|
||||||
|
getLoadedPlugins,
|
||||||
|
setPluginRegistries,
|
||||||
|
type PluginMeta,
|
||||||
|
} from './plugin-host.ts';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PluginCommand {
|
||||||
|
pluginId: string;
|
||||||
|
label: string;
|
||||||
|
callback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let discovered = $state<PluginMeta[]>([]);
|
||||||
|
let commands = $state<PluginCommand[]>([]);
|
||||||
|
let loaded = $derived(getLoadedPlugins());
|
||||||
|
|
||||||
|
// ── Event bus (simple pub/sub) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EventHandler = (data: unknown) => void;
|
||||||
|
const eventListeners = new Map<string, Set<EventHandler>>();
|
||||||
|
|
||||||
|
const pluginEventBus = {
|
||||||
|
on(event: string, handler: EventHandler): void {
|
||||||
|
let set = eventListeners.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
eventListeners.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
},
|
||||||
|
off(event: string, handler: EventHandler): void {
|
||||||
|
eventListeners.get(event)?.delete(handler);
|
||||||
|
},
|
||||||
|
emit(event: string, data: unknown): void {
|
||||||
|
const set = eventListeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const handler of set) {
|
||||||
|
try { handler(data); }
|
||||||
|
catch (err) { console.error(`[plugin-event] ${event}:`, err); }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Command registry ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function addPluginCommand(pluginId: string, label: string, callback: () => void): void {
|
||||||
|
commands = [...commands, { pluginId, label, callback }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePluginCommands(pluginId: string): void {
|
||||||
|
commands = commands.filter(c => c.pluginId !== pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up registries to plugin-host
|
||||||
|
setPluginRegistries({
|
||||||
|
addCommand: addPluginCommand,
|
||||||
|
removeCommands: removePluginCommands,
|
||||||
|
eventBus: pluginEventBus,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Discover plugins from ~/.config/agor/plugins/ via RPC. */
|
||||||
|
export async function discoverPlugins(): Promise<PluginMeta[]> {
|
||||||
|
try {
|
||||||
|
const res = await appRpc.request['plugin.discover']({});
|
||||||
|
discovered = res.plugins ?? [];
|
||||||
|
return discovered;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[plugin-store] discover error:', err);
|
||||||
|
discovered = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a discovered plugin by id. */
|
||||||
|
export async function loadPluginById(pluginId: string): Promise<void> {
|
||||||
|
const meta = discovered.find(p => p.id === pluginId);
|
||||||
|
if (!meta) throw new Error(`Plugin not found: ${pluginId}`);
|
||||||
|
await loadPlugin(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload a plugin by id. */
|
||||||
|
export function unloadPluginById(pluginId: string): void {
|
||||||
|
unloadPlugin(pluginId);
|
||||||
|
removePluginCommands(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load all discovered plugins. */
|
||||||
|
export async function loadAllPlugins(): Promise<void> {
|
||||||
|
const plugins = await discoverPlugins();
|
||||||
|
for (const meta of plugins) {
|
||||||
|
try {
|
||||||
|
await loadPlugin(meta);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin-store] Failed to load '${meta.id}':`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload all plugins. */
|
||||||
|
export function unloadAll(): void {
|
||||||
|
unloadAllPlugins();
|
||||||
|
commands = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get discovered plugins (reactive). */
|
||||||
|
export function getDiscoveredPlugins(): PluginMeta[] {
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get registered commands (reactive, for palette integration). */
|
||||||
|
export function getPluginCommands(): PluginCommand[] {
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an event to all plugins listening for it. */
|
||||||
|
export function emitPluginEvent(event: string, data: unknown): void {
|
||||||
|
pluginEventBus.emit(event, data);
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,36 @@ export type PtyRPCRequests = {
|
||||||
response: { ok: boolean };
|
response: { ok: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── File I/O RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** List directory children (files + subdirs). Returns sorted entries. */
|
||||||
|
"files.list": {
|
||||||
|
params: { path: string };
|
||||||
|
response: {
|
||||||
|
entries: Array<{
|
||||||
|
name: string;
|
||||||
|
type: "file" | "dir";
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Read a file's content. Returns text for text files, base64 for binary. */
|
||||||
|
"files.read": {
|
||||||
|
params: { path: string };
|
||||||
|
response: {
|
||||||
|
content?: string;
|
||||||
|
encoding: "utf8" | "base64";
|
||||||
|
size: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Write text content to a file. */
|
||||||
|
"files.write": {
|
||||||
|
params: { path: string; content: string };
|
||||||
|
response: { ok: boolean; error?: string };
|
||||||
|
};
|
||||||
|
|
||||||
// ── Groups RPC ─────────────────────────────────────────────────────────────
|
// ── Groups RPC ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Return all project groups. */
|
/** Return all project groups. */
|
||||||
|
|
@ -190,6 +220,268 @@ export type PtyRPCRequests = {
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Session persistence RPC ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Save/update a session record. */
|
||||||
|
"session.save": {
|
||||||
|
params: {
|
||||||
|
projectId: string; sessionId: string; provider: string;
|
||||||
|
status: string; costUsd: number; inputTokens: number;
|
||||||
|
outputTokens: number; model: string; error?: string;
|
||||||
|
createdAt: number; updatedAt: number;
|
||||||
|
};
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Load the most recent session for a project. */
|
||||||
|
"session.load": {
|
||||||
|
params: { projectId: string };
|
||||||
|
response: {
|
||||||
|
session: {
|
||||||
|
projectId: string; sessionId: string; provider: string;
|
||||||
|
status: string; costUsd: number; inputTokens: number;
|
||||||
|
outputTokens: number; model: string; error?: string;
|
||||||
|
createdAt: number; updatedAt: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** List sessions for a project (max 20). */
|
||||||
|
"session.list": {
|
||||||
|
params: { projectId: string };
|
||||||
|
response: {
|
||||||
|
sessions: Array<{
|
||||||
|
projectId: string; sessionId: string; provider: string;
|
||||||
|
status: string; costUsd: number; inputTokens: number;
|
||||||
|
outputTokens: number; model: string; error?: string;
|
||||||
|
createdAt: number; updatedAt: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Save agent messages (batch). */
|
||||||
|
"session.messages.save": {
|
||||||
|
params: {
|
||||||
|
messages: Array<{
|
||||||
|
sessionId: string; msgId: string; role: string; content: string;
|
||||||
|
toolName?: string; toolInput?: string; timestamp: number;
|
||||||
|
costUsd?: number; inputTokens?: number; outputTokens?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Load all messages for a session. */
|
||||||
|
"session.messages.load": {
|
||||||
|
params: { sessionId: string };
|
||||||
|
response: {
|
||||||
|
messages: Array<{
|
||||||
|
sessionId: string; msgId: string; role: string; content: string;
|
||||||
|
toolName?: string; toolInput?: string; timestamp: number;
|
||||||
|
costUsd: number; inputTokens: number; outputTokens: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── btmsg RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Register an agent in btmsg. */
|
||||||
|
"btmsg.registerAgent": {
|
||||||
|
params: {
|
||||||
|
id: string; name: string; role: string;
|
||||||
|
groupId: string; tier: number; model?: string;
|
||||||
|
};
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** List agents for a group. */
|
||||||
|
"btmsg.getAgents": {
|
||||||
|
params: { groupId: string };
|
||||||
|
response: {
|
||||||
|
agents: Array<{
|
||||||
|
id: string; name: string; role: string; groupId: string;
|
||||||
|
tier: number; model: string | null; status: string; unreadCount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Send a direct message between agents. */
|
||||||
|
"btmsg.sendMessage": {
|
||||||
|
params: { fromAgent: string; toAgent: string; content: string };
|
||||||
|
response: { ok: boolean; messageId?: string; error?: string };
|
||||||
|
};
|
||||||
|
/** Get message history between two agents. */
|
||||||
|
"btmsg.listMessages": {
|
||||||
|
params: { agentId: string; otherId: string; limit?: number };
|
||||||
|
response: {
|
||||||
|
messages: Array<{
|
||||||
|
id: string; fromAgent: string; toAgent: string; content: string;
|
||||||
|
read: boolean; replyTo: string | null; createdAt: string;
|
||||||
|
senderName: string | null; senderRole: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Mark messages as read. */
|
||||||
|
"btmsg.markRead": {
|
||||||
|
params: { agentId: string; messageIds: string[] };
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** List channels for a group. */
|
||||||
|
"btmsg.listChannels": {
|
||||||
|
params: { groupId: string };
|
||||||
|
response: {
|
||||||
|
channels: Array<{
|
||||||
|
id: string; name: string; groupId: string; createdBy: string;
|
||||||
|
memberCount: number; createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Create a channel. */
|
||||||
|
"btmsg.createChannel": {
|
||||||
|
params: { name: string; groupId: string; createdBy: string };
|
||||||
|
response: { ok: boolean; channelId?: string };
|
||||||
|
};
|
||||||
|
/** Get channel messages. */
|
||||||
|
"btmsg.getChannelMessages": {
|
||||||
|
params: { channelId: string; limit?: number };
|
||||||
|
response: {
|
||||||
|
messages: Array<{
|
||||||
|
id: string; channelId: string; fromAgent: string; content: string;
|
||||||
|
createdAt: string; senderName: string; senderRole: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Send a channel message. */
|
||||||
|
"btmsg.sendChannelMessage": {
|
||||||
|
params: { channelId: string; fromAgent: string; content: string };
|
||||||
|
response: { ok: boolean; messageId?: string };
|
||||||
|
};
|
||||||
|
/** Record agent heartbeat. */
|
||||||
|
"btmsg.heartbeat": {
|
||||||
|
params: { agentId: string };
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Get dead letter queue entries. */
|
||||||
|
"btmsg.getDeadLetters": {
|
||||||
|
params: { limit?: number };
|
||||||
|
response: {
|
||||||
|
letters: Array<{
|
||||||
|
id: number; fromAgent: string; toAgent: string;
|
||||||
|
content: string; error: string; createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Log an audit event. */
|
||||||
|
"btmsg.logAudit": {
|
||||||
|
params: { agentId: string; eventType: string; detail: string };
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Get audit log. */
|
||||||
|
"btmsg.getAuditLog": {
|
||||||
|
params: { limit?: number };
|
||||||
|
response: {
|
||||||
|
entries: Array<{
|
||||||
|
id: number; agentId: string; eventType: string;
|
||||||
|
detail: string; createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── bttask RPC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** List tasks for a group. */
|
||||||
|
"bttask.listTasks": {
|
||||||
|
params: { groupId: string };
|
||||||
|
response: {
|
||||||
|
tasks: Array<{
|
||||||
|
id: string; title: string; description: string; status: string;
|
||||||
|
priority: string; assignedTo: string | null; createdBy: string;
|
||||||
|
groupId: string; parentTaskId: string | null; sortOrder: number;
|
||||||
|
createdAt: string; updatedAt: string; version: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Create a task. */
|
||||||
|
"bttask.createTask": {
|
||||||
|
params: {
|
||||||
|
title: string; description: string; priority: string;
|
||||||
|
groupId: string; createdBy: string; assignedTo?: string;
|
||||||
|
};
|
||||||
|
response: { ok: boolean; taskId?: string; error?: string };
|
||||||
|
};
|
||||||
|
/** Update task status with optimistic locking. */
|
||||||
|
"bttask.updateTaskStatus": {
|
||||||
|
params: { taskId: string; status: string; expectedVersion: number };
|
||||||
|
response: { ok: boolean; newVersion?: number; error?: string };
|
||||||
|
};
|
||||||
|
/** Delete a task. */
|
||||||
|
"bttask.deleteTask": {
|
||||||
|
params: { taskId: string };
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Add a comment to a task. */
|
||||||
|
"bttask.addComment": {
|
||||||
|
params: { taskId: string; agentId: string; content: string };
|
||||||
|
response: { ok: boolean; commentId?: string };
|
||||||
|
};
|
||||||
|
/** List comments for a task. */
|
||||||
|
"bttask.listComments": {
|
||||||
|
params: { taskId: string };
|
||||||
|
response: {
|
||||||
|
comments: Array<{
|
||||||
|
id: string; taskId: string; agentId: string;
|
||||||
|
content: string; createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Count tasks in 'review' status. */
|
||||||
|
"bttask.reviewQueueCount": {
|
||||||
|
params: { groupId: string };
|
||||||
|
response: { count: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Search RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full-text search across messages, tasks, and btmsg. */
|
||||||
|
"search.query": {
|
||||||
|
params: { query: string; limit?: number };
|
||||||
|
response: {
|
||||||
|
results: Array<{
|
||||||
|
resultType: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Index a message for search. */
|
||||||
|
"search.indexMessage": {
|
||||||
|
params: { sessionId: string; role: string; content: string };
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
/** Rebuild the entire search index. */
|
||||||
|
"search.rebuild": {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: { ok: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Plugin RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Discover plugins from ~/.config/agor/plugins/. */
|
||||||
|
"plugin.discover": {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: {
|
||||||
|
plugins: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
main: string;
|
||||||
|
permissions: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Read a plugin file (path-traversal-safe). */
|
||||||
|
"plugin.readFile": {
|
||||||
|
params: { pluginId: string; filePath: string };
|
||||||
|
response: { ok: boolean; content: string; error?: string };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue