- SidecarManager: spawns claude/codex/ollama runners via Bun.spawn(), NDJSON stdio protocol, Claude CLI auto-detection, env stripping, AbortController stop, Deno/Node runtime detection - MessageAdapter: parses Claude stream-json, Codex ThreadEvent, Ollama chunks into common AgentMessage format - agent-store.svelte.ts: per-project reactive session state, RPC event listeners for agent.message/status/cost - AgentPane: wired to real sessions (start/stop/prompt), stop button, thinking/system message rendering - ProjectCard: status dot from real agent status, cost/tokens from store - 5 new RPC types (agent.start/stop/prompt/list + events)
811 lines
29 KiB
Svelte
811 lines
29 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import ProjectCard from './ProjectCard.svelte';
|
||
import SettingsDrawer from './SettingsDrawer.svelte';
|
||
import CommandPalette from './CommandPalette.svelte';
|
||
import ToastContainer from './ToastContainer.svelte';
|
||
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
|
||
import { themeStore } from './theme-store.svelte.ts';
|
||
import { fontStore } from './font-store.svelte.ts';
|
||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||
import { appRpc } from './main.ts';
|
||
|
||
// ── Types ─────────────────────────────────────────────────────
|
||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||
|
||
interface AgentMessage {
|
||
id: number;
|
||
role: MsgRole;
|
||
content: string;
|
||
}
|
||
|
||
interface Project {
|
||
id: string;
|
||
name: string;
|
||
cwd: string;
|
||
accent: string;
|
||
status: AgentStatus;
|
||
costUsd: number;
|
||
tokens: number;
|
||
messages: AgentMessage[];
|
||
provider?: string;
|
||
profile?: string;
|
||
model?: string;
|
||
contextPct?: number;
|
||
burnRate?: number;
|
||
groupId?: string;
|
||
cloneOf?: string;
|
||
worktreeBranch?: string;
|
||
mainRepoPath?: string;
|
||
cloneIndex?: number;
|
||
}
|
||
|
||
interface Group {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
position: number;
|
||
hasNew?: boolean;
|
||
}
|
||
|
||
// ── Demo data ──────────────────────────────────────────────────
|
||
let PROJECTS = $state<Project[]>([
|
||
{
|
||
id: 'p1',
|
||
name: 'agent-orchestrator',
|
||
cwd: '~/code/ai/agent-orchestrator',
|
||
accent: 'var(--ctp-mauve)',
|
||
status: 'running',
|
||
costUsd: 0.034,
|
||
tokens: 18420,
|
||
provider: 'claude',
|
||
profile: 'dev',
|
||
model: 'claude-opus-4-5',
|
||
contextPct: 78,
|
||
burnRate: 0.12,
|
||
groupId: 'dev',
|
||
mainRepoPath: '~/code/ai/agent-orchestrator',
|
||
messages: [
|
||
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
|
||
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
|
||
{ id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' },
|
||
{ id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' },
|
||
{ id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'p2',
|
||
name: 'quanta-discord-bot',
|
||
cwd: '~/code/quanta/discord-bot',
|
||
accent: 'var(--ctp-sapphire)',
|
||
status: 'idle',
|
||
costUsd: 0.011,
|
||
tokens: 6830,
|
||
provider: 'claude',
|
||
model: 'claude-sonnet-4-5',
|
||
contextPct: 32,
|
||
groupId: 'dev',
|
||
messages: [
|
||
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
|
||
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
|
||
{ id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' },
|
||
{ id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' },
|
||
{ id: 5, role: 'assistant', content: 'Found it. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
|
||
],
|
||
},
|
||
]);
|
||
|
||
// ── Groups state ───────────────────────────────────────────────
|
||
let groups = $state<Group[]>([
|
||
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
|
||
{ id: 'test', name: 'Testing', icon: '🧪', position: 1, hasNew: true },
|
||
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
|
||
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
|
||
]);
|
||
let activeGroupId = $state('dev');
|
||
|
||
// ── Filtered projects for active group ────────────────────────
|
||
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
|
||
let filteredProjects = $derived(
|
||
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
|
||
);
|
||
|
||
// Group projects into: top-level cards + clone groups
|
||
interface ProjectRow { type: 'standalone'; project: Project; }
|
||
interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; }
|
||
type GridRow = ProjectRow | CloneGroupRow;
|
||
|
||
let gridRows = $derived((): GridRow[] => {
|
||
const standalone: GridRow[] = [];
|
||
const cloneParentIds = new Set(
|
||
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
|
||
);
|
||
for (const p of filteredProjects) {
|
||
if (p.cloneOf) continue;
|
||
if (cloneParentIds.has(p.id)) {
|
||
const clones = filteredProjects
|
||
.filter(c => c.cloneOf === p.id)
|
||
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
|
||
standalone.push({ type: 'clone-group', parent: p, clones });
|
||
} else {
|
||
standalone.push({ type: 'standalone', project: p });
|
||
}
|
||
}
|
||
return standalone;
|
||
});
|
||
|
||
// ── Clone helpers ──────────────────────────────────────────────
|
||
function cloneCountForProject(projectId: string): number {
|
||
return PROJECTS.filter(p => p.cloneOf === projectId).length;
|
||
}
|
||
|
||
function handleClone(projectId: string) {
|
||
const source = PROJECTS.find(p => p.id === projectId);
|
||
if (!source) return;
|
||
const branchName = `feature/clone-${Date.now()}`;
|
||
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
|
||
if (result.ok && result.project) {
|
||
const cloneConfig = JSON.parse(result.project.config) as Project;
|
||
PROJECTS = [...PROJECTS, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
|
||
} else {
|
||
console.error('[clone]', result.error);
|
||
}
|
||
}).catch(console.error);
|
||
}
|
||
|
||
// ── Reactive state ─────────────────────────────────────────────
|
||
let settingsOpen = $state(false);
|
||
let paletteOpen = $state(false);
|
||
let drawerOpen = $state(false);
|
||
let sessionStart = $state(Date.now());
|
||
|
||
let notifications = $state<Notification[]>([
|
||
{ id: 1, message: 'Agent completed: wake scheduler implemented', type: 'success', time: '2m ago' },
|
||
{ id: 2, message: 'Context pressure: 78% on agent-orchestrator', type: 'warning', time: '5m ago' },
|
||
{ id: 3, message: 'PTY daemon connected', type: 'info', time: '12m ago' },
|
||
]);
|
||
|
||
let notifCount = $derived(notifications.length);
|
||
|
||
function clearNotifications() {
|
||
notifications = [];
|
||
drawerOpen = false;
|
||
}
|
||
|
||
// ── setActiveGroup: fire-and-forget RPC ───────────────────────
|
||
function setActiveGroup(id: string | undefined) {
|
||
if (!id) return;
|
||
console.log('[DEBUG] setActiveGroup:', id);
|
||
activeGroupId = id;
|
||
// NO RPC — pure local state change for debugging
|
||
}
|
||
|
||
// ── Window controls ────────────────────────────────────────────
|
||
function handleClose() { appRpc.request["window.close"]({}).catch(console.error); }
|
||
function handleMaximize() { appRpc.request["window.maximize"]({}).catch(console.error); }
|
||
function handleMinimize() { appRpc.request["window.minimize"]({}).catch(console.error); }
|
||
|
||
// ── Blink ──────────────────────────────────────────────────────
|
||
let blinkVisible = $state(true);
|
||
$effect(() => {
|
||
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
|
||
return () => clearInterval(id);
|
||
});
|
||
|
||
// ── Session duration ───────────────────────────────────────────
|
||
let sessionDuration = $state('0m');
|
||
$effect(() => {
|
||
function update() {
|
||
const mins = Math.floor((Date.now() - sessionStart) / 60000);
|
||
sessionDuration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||
}
|
||
update();
|
||
const id = setInterval(update, 10000);
|
||
return () => clearInterval(id);
|
||
});
|
||
|
||
// ── JS-based window drag (replaces broken -webkit-app-region on WebKitGTK) ──
|
||
let isDraggingWindow = false;
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let winStartX = 0;
|
||
let winStartY = 0;
|
||
|
||
function onDragStart(e: MouseEvent) {
|
||
isDraggingWindow = true;
|
||
dragStartX = e.screenX;
|
||
dragStartY = e.screenY;
|
||
appRpc?.request["window.getFrame"]({}).then((frame: any) => {
|
||
winStartX = frame.x;
|
||
winStartY = frame.y;
|
||
}).catch(() => {});
|
||
window.addEventListener('mousemove', onDragMove);
|
||
window.addEventListener('mouseup', onDragEnd);
|
||
}
|
||
|
||
function onDragMove(e: MouseEvent) {
|
||
if (!isDraggingWindow) return;
|
||
const dx = e.screenX - dragStartX;
|
||
const dy = e.screenY - dragStartY;
|
||
appRpc?.request["window.setPosition"]?.({ x: winStartX + dx, y: winStartY + dy })?.catch?.(() => {});
|
||
}
|
||
|
||
function onDragEnd() {
|
||
isDraggingWindow = false;
|
||
window.removeEventListener('mousemove', onDragMove);
|
||
window.removeEventListener('mouseup', onDragEnd);
|
||
saveWindowFrame();
|
||
}
|
||
|
||
// ── Window frame persistence (debounced 500ms) ─────────────────
|
||
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||
function saveWindowFrame() {
|
||
if (frameSaveTimer) clearTimeout(frameSaveTimer);
|
||
frameSaveTimer = setTimeout(() => {
|
||
appRpc.request["window.getFrame"]({}).then((frame) => {
|
||
appRpc.request["settings.set"]({ key: 'win_x', value: String(frame.x) }).catch(console.error);
|
||
appRpc.request["settings.set"]({ key: 'win_y', value: String(frame.y) }).catch(console.error);
|
||
appRpc.request["settings.set"]({ key: 'win_width', value: String(frame.width) }).catch(console.error);
|
||
appRpc.request["settings.set"]({ key: 'win_height', value: String(frame.height) }).catch(console.error);
|
||
}).catch(console.error);
|
||
}, 500);
|
||
}
|
||
|
||
// ── 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 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 ─────────────────────
|
||
let debugLog = $state<string[]>([]);
|
||
|
||
$effect(() => {
|
||
function debugClick(e: MouseEvent) {
|
||
const el = e.target as HTMLElement;
|
||
const tag = el?.tagName;
|
||
const cls = (el?.className?.toString?.() ?? '').slice(0, 40);
|
||
const elAtPoint = document.elementFromPoint(e.clientX, e.clientY);
|
||
let line = `CLICK ${tag}.${cls} @(${e.clientX},${e.clientY})`;
|
||
if (elAtPoint && elAtPoint !== el) {
|
||
const overTag = (elAtPoint as HTMLElement).tagName;
|
||
const overCls = ((elAtPoint as HTMLElement).className?.toString?.() ?? '').slice(0, 40);
|
||
line += ` ⚠️OVERLAY: ${overTag}.${overCls}`;
|
||
}
|
||
debugLog = [...debugLog.slice(-8), line];
|
||
}
|
||
document.addEventListener('click', debugClick, true);
|
||
document.addEventListener('mousedown', (e) => {
|
||
const el = e.target as HTMLElement;
|
||
debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`];
|
||
}, true);
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────
|
||
onMount(() => {
|
||
themeStore.initTheme(appRpc).catch(console.error);
|
||
fontStore.initFonts(appRpc).catch(console.error);
|
||
keybindingStore.init(appRpc).catch(console.error);
|
||
|
||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
|
||
if (dbGroups.length > 0) groups = dbGroups;
|
||
}).catch(console.error);
|
||
|
||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
|
||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||
}).catch(console.error);
|
||
|
||
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
|
||
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
|
||
keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id));
|
||
keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id));
|
||
keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id));
|
||
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
|
||
keybindingStore.on('minimize', () => handleMinimize());
|
||
|
||
const cleanup = keybindingStore.installListener();
|
||
return cleanup;
|
||
});
|
||
</script>
|
||
|
||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||
<ToastContainer />
|
||
<NotifDrawer
|
||
open={drawerOpen}
|
||
{notifications}
|
||
onClear={clearNotifications}
|
||
onClose={() => drawerOpen = false}
|
||
/>
|
||
|
||
<div
|
||
class="app-shell"
|
||
role="presentation"
|
||
onresize={saveWindowFrame}
|
||
>
|
||
<!-- Left sidebar icon rail -->
|
||
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
|
||
<!-- AGOR vertical title -->
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="agor-title" aria-hidden="true">AGOR</div>
|
||
|
||
<!-- Group icons — numbered circles -->
|
||
<div class="sidebar-groups" role="list" aria-label="Project groups">
|
||
{#each groups as group, i}
|
||
<button
|
||
class="group-btn"
|
||
class:active={activeGroupId === group.id}
|
||
onclick={() => setActiveGroup(group.id)}
|
||
aria-label="{group.name} (Ctrl+{i + 1})"
|
||
title="{group.name} (Ctrl+{i + 1})"
|
||
>
|
||
<span class="group-circle" aria-hidden="true">{i + 1}</span>
|
||
{#if group.hasNew}
|
||
<span class="group-badge" aria-label="New activity"></span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
|
||
<div class="sidebar-spacer"></div>
|
||
|
||
<!-- Settings gear -->
|
||
<button
|
||
class="sidebar-icon"
|
||
class:active={settingsOpen}
|
||
onclick={() => settingsOpen = !settingsOpen}
|
||
aria-label="Settings (Ctrl+,)"
|
||
title="Settings (Ctrl+,)"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||
</svg>
|
||
</button>
|
||
</aside>
|
||
|
||
<!-- Main workspace — no drag region (WebKitGTK captures clicks incorrectly) -->
|
||
<main class="workspace">
|
||
<!-- Project grid -->
|
||
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
|
||
<!-- ALL projects rendered always with display:none/flex toggling.
|
||
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
|
||
{#each PROJECTS as project (project.id)}
|
||
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
|
||
<ProjectCard
|
||
id={project.id}
|
||
name={project.name}
|
||
cwd={project.cwd}
|
||
accent={project.accent}
|
||
provider={project.provider}
|
||
profile={project.profile}
|
||
model={project.model}
|
||
contextPct={project.contextPct}
|
||
burnRate={project.burnRate}
|
||
{blinkVisible}
|
||
clonesAtMax={cloneCountForProject(project.id) >= 3}
|
||
onClone={handleClone}
|
||
worktreeBranch={project.worktreeBranch}
|
||
cloneOf={project.cloneOf}
|
||
/>
|
||
</div>
|
||
{/each}
|
||
|
||
<!-- Empty group — always in DOM, visibility toggled -->
|
||
<div class="empty-group" role="listitem"
|
||
style:display={filteredProjects.length === 0 ? 'flex' : 'none'}>
|
||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Right sidebar: window controls + notification bell -->
|
||
<aside class="right-bar" aria-label="Window controls and notifications">
|
||
<div class="window-controls-vertical" role="toolbar" aria-label="Window controls">
|
||
<button class="wc-btn close-btn" onclick={handleClose} aria-label="Close window" title="Close">✕</button>
|
||
<button class="wc-btn" onclick={handleMaximize} aria-label="Maximize window" title="Maximize">□</button>
|
||
<button class="wc-btn" onclick={handleMinimize} aria-label="Minimize window" title="Minimize">─</button>
|
||
</div>
|
||
|
||
<div class="right-spacer"></div>
|
||
|
||
<button
|
||
class="right-icon notif-btn"
|
||
class:active={drawerOpen}
|
||
onclick={() => drawerOpen = !drawerOpen}
|
||
aria-label="{notifCount > 0 ? `${notifCount} notifications` : 'Notifications'}"
|
||
title="Notifications"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||
</svg>
|
||
{#if notifCount > 0}
|
||
<span class="right-badge" aria-hidden="true">{notifCount}</span>
|
||
{/if}
|
||
</button>
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- Status bar -->
|
||
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
||
{#if runningCount > 0}
|
||
<span class="status-segment">
|
||
<span class="status-dot-sm green" aria-hidden="true"></span>
|
||
<span class="status-value">{runningCount}</span>
|
||
<span>running</span>
|
||
</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>
|
||
|
||
<!-- DEBUG: visible click log -->
|
||
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
|
||
{#each debugLog as line}
|
||
<div>{line}</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<style>
|
||
:global(body) { overflow: hidden; }
|
||
:global(#app) { display: flex; flex-direction: column; height: 100vh; }
|
||
|
||
.app-shell {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Left sidebar ─────────────────────────────────────────── */
|
||
.sidebar {
|
||
width: var(--sidebar-width);
|
||
flex-shrink: 0;
|
||
background: var(--ctp-mantle);
|
||
border-right: 1px solid var(--ctp-surface0);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 0.375rem 0 0.5rem;
|
||
gap: 0.125rem;
|
||
}
|
||
|
||
.agor-title {
|
||
writing-mode: vertical-rl;
|
||
transform: rotate(180deg);
|
||
font-family: 'Inter', system-ui, sans-serif;
|
||
font-weight: 900;
|
||
font-size: 1.25rem;
|
||
letter-spacing: 0.2em;
|
||
color: var(--ctp-overlay0);
|
||
padding: 1rem 0;
|
||
user-select: none;
|
||
flex-shrink: 0;
|
||
/* NO -webkit-app-region — broken on WebKitGTK (captures all clicks in window) */
|
||
cursor: grab;
|
||
}
|
||
|
||
.sidebar-groups {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
width: 100%;
|
||
padding: 0.25rem 0;
|
||
border-bottom: 1px solid var(--ctp-surface0);
|
||
margin-bottom: 0.125rem;
|
||
}
|
||
|
||
.sidebar-spacer { flex: 1; }
|
||
|
||
/* Numbered group button */
|
||
.group-btn {
|
||
position: relative;
|
||
width: 2.25rem;
|
||
height: 2.25rem;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
}
|
||
|
||
.group-circle {
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
border-radius: 50%;
|
||
border: 1.5px solid var(--ctp-surface1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--ctp-subtext0);
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.group-btn:hover .group-circle {
|
||
border-color: var(--ctp-overlay1);
|
||
color: var(--ctp-text);
|
||
background: var(--ctp-surface0);
|
||
}
|
||
|
||
.group-btn.active .group-circle {
|
||
border-color: var(--accent, var(--ctp-mauve));
|
||
color: var(--accent, var(--ctp-mauve));
|
||
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 10%, transparent);
|
||
}
|
||
|
||
.group-badge {
|
||
position: absolute;
|
||
top: 0.125rem;
|
||
right: 0.125rem;
|
||
width: 0.375rem;
|
||
height: 0.375rem;
|
||
border-radius: 50%;
|
||
background: var(--ctp-red);
|
||
}
|
||
|
||
/* Settings icon button */
|
||
.sidebar-icon {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 0.375rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--ctp-overlay1);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.15s, color 0.15s;
|
||
padding: 0;
|
||
}
|
||
|
||
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||
.sidebar-icon svg { width: 1rem; height: 1rem; }
|
||
|
||
/* ── Main workspace ───────────────────────────────────────── */
|
||
.workspace {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Project grid ─────────────────────────────────────────── */
|
||
.project-grid {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-auto-rows: 1fr; /* equal rows that fill available height */
|
||
gap: 0.5rem;
|
||
padding: 0.5rem;
|
||
background: var(--ctp-crust);
|
||
overflow-y: auto;
|
||
align-content: start;
|
||
}
|
||
|
||
.project-grid::-webkit-scrollbar { width: 0.375rem; }
|
||
.project-grid::-webkit-scrollbar-track { background: transparent; }
|
||
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||
|
||
.clone-group-row {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 0;
|
||
align-items: stretch;
|
||
min-height: 0;
|
||
}
|
||
|
||
.clone-group-row :global(.project-card) { flex: 1; min-width: 0; }
|
||
|
||
.chain-icon {
|
||
flex-shrink: 0;
|
||
width: 1.5rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--ctp-surface1);
|
||
}
|
||
|
||
.chain-icon svg { width: 1rem; height: 1rem; }
|
||
|
||
.empty-group {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.5rem;
|
||
padding: 3rem 0;
|
||
color: var(--ctp-overlay0);
|
||
}
|
||
|
||
.empty-group-text { font-size: 0.875rem; font-style: italic; }
|
||
|
||
/* ── Right sidebar ────────────────────────────────────────── */
|
||
.right-bar {
|
||
width: 2.25rem;
|
||
flex-shrink: 0;
|
||
background: var(--ctp-mantle);
|
||
border-left: 1px solid var(--ctp-surface0);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 0.375rem 0;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.window-controls-vertical {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.125rem;
|
||
}
|
||
|
||
.wc-btn {
|
||
width: 1.5rem;
|
||
height: 1.5rem;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--ctp-overlay1);
|
||
font-size: 0.625rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.12s, color 0.12s;
|
||
padding: 0;
|
||
font-family: var(--ui-font-family);
|
||
/* no-drag not needed — all -webkit-app-region removed */
|
||
}
|
||
|
||
.wc-btn:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||
.close-btn:hover { background: var(--ctp-red); color: var(--ctp-base); }
|
||
|
||
.right-spacer { flex: 1; }
|
||
|
||
.right-icon {
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--ctp-overlay1);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 0.375rem;
|
||
position: relative;
|
||
transition: color 0.12s, background 0.12s;
|
||
padding: 0;
|
||
}
|
||
|
||
.right-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||
.right-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||
.right-icon svg { width: 0.875rem; height: 0.875rem; }
|
||
|
||
.right-badge {
|
||
position: absolute;
|
||
top: 0.125rem;
|
||
right: 0.125rem;
|
||
min-width: 0.875rem;
|
||
height: 0.875rem;
|
||
background: var(--ctp-red);
|
||
color: var(--ctp-base);
|
||
border-radius: 0.4375rem;
|
||
font-size: 0.5rem;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 0.125rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Status bar ───────────────────────────────────────────── */
|
||
.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>
|