agent-orchestrator/ui-electrobun/src/mainview/App.svelte
Hibryda ef0183de7f feat(electrobun): agent execution layer — sidecar manager + message adapters + store
- 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)
2026-03-22 01:03:05 +01:00

811 lines
29 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>