CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs
HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle
MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`
LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
894 lines
33 KiB
Svelte
894 lines
33 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 StatusBar from './StatusBar.svelte';
|
|
import SearchOverlay from './SearchOverlay.svelte';
|
|
import SplashScreen from './SplashScreen.svelte';
|
|
import { themeStore } from './theme-store.svelte.ts';
|
|
import { fontStore } from './font-store.svelte.ts';
|
|
import { keybindingStore } from './keybinding-store.svelte.ts';
|
|
import { trackProject } from './health-store.svelte.ts';
|
|
import { setAgentToastFn } from './agent-store.svelte.ts';
|
|
import { appRpc } from './rpc.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;
|
|
}
|
|
|
|
// ── Accent colors for auto-assignment ─────────────────────────
|
|
const ACCENTS = [
|
|
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
|
|
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
|
|
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
|
|
];
|
|
|
|
// ── Projects state (loaded from SQLite) ─────────────────────────
|
|
let PROJECTS = $state<Project[]>([]);
|
|
|
|
// ── Groups state (loaded from SQLite) ────────────────────────────
|
|
let groups = $state<Group[]>([
|
|
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
|
|
]);
|
|
|
|
// ── Add/Remove project UI state ──────────────────────────────────
|
|
let showAddProject = $state(false);
|
|
let newProjectName = $state('');
|
|
let newProjectCwd = $state('');
|
|
let projectToDelete = $state<string | null>(null);
|
|
|
|
async function addProject() {
|
|
const name = newProjectName.trim();
|
|
const cwd = newProjectCwd.trim();
|
|
if (!name || !cwd) return;
|
|
|
|
const id = `p-${Date.now()}`;
|
|
const accent = ACCENTS[PROJECTS.length % ACCENTS.length];
|
|
const project: Project = {
|
|
id, name, cwd, accent,
|
|
status: 'idle', costUsd: 0, tokens: 0, messages: [],
|
|
provider: 'claude', groupId: activeGroupId,
|
|
};
|
|
PROJECTS = [...PROJECTS, project];
|
|
trackProject(id);
|
|
|
|
await appRpc.request['settings.setProject']({
|
|
id,
|
|
config: JSON.stringify(project),
|
|
}).catch(console.error);
|
|
|
|
showAddProject = false;
|
|
newProjectName = '';
|
|
newProjectCwd = '';
|
|
}
|
|
|
|
async function confirmDeleteProject() {
|
|
if (!projectToDelete) return;
|
|
PROJECTS = PROJECTS.filter(p => p.id !== projectToDelete);
|
|
await appRpc.request['settings.deleteProject']({ id: projectToDelete }).catch(console.error);
|
|
projectToDelete = null;
|
|
}
|
|
|
|
// ── Add/Remove group UI state ───────────────────────────────────
|
|
let showAddGroup = $state(false);
|
|
let newGroupName = $state('');
|
|
|
|
async function addGroup() {
|
|
const name = newGroupName.trim();
|
|
if (!name) return;
|
|
const id = `grp-${Date.now()}`;
|
|
const position = groups.length;
|
|
const group: Group = { id, name, icon: String(position + 1), position };
|
|
groups = [...groups, group];
|
|
await appRpc.request['groups.create']({ id, name, icon: group.icon, position }).catch(console.error);
|
|
showAddGroup = false;
|
|
newGroupName = '';
|
|
}
|
|
|
|
// Fix #19: removeGroup removed — was defined but never called from UI
|
|
let activeGroupId = $state('dev');
|
|
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
|
|
let previousGroupId = $state<string | null>(null);
|
|
let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
|
|
|
|
// ── 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)
|
|
);
|
|
|
|
// ── Clone helpers ──────────────────────────────────────────────
|
|
function cloneCountForProject(projectId: string): number {
|
|
return PROJECTS.filter(p => p.cloneOf === projectId).length;
|
|
}
|
|
|
|
function handleClone(projectId: string, branch: string) {
|
|
const source = PROJECTS.find(p => p.id === projectId);
|
|
if (!source) return;
|
|
const branchName = branch || `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);
|
|
}
|
|
|
|
// ── Splash screen ─────────────────────────────────────────────
|
|
let appReady = $state(false);
|
|
|
|
// ── Reactive state ─────────────────────────────────────────────
|
|
let settingsOpen = $state(false);
|
|
let paletteOpen = $state(false);
|
|
let drawerOpen = $state(false);
|
|
let searchOpen = $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;
|
|
// Fix #10: Track previous group for DOM mount limit
|
|
if (activeGroupId !== id) {
|
|
previousGroupId = activeGroupId;
|
|
}
|
|
activeGroupId = id;
|
|
// Fix #16: Persist active_group selection
|
|
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
|
|
}
|
|
|
|
// ── 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);
|
|
});
|
|
|
|
// Fix #19: onDragStart/onDragMove/onDragEnd removed — no longer referenced from template
|
|
|
|
// ── 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 totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
|
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
|
|
|
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
|
|
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
|
|
let debugLog = $state<string[]>([]);
|
|
|
|
$effect(() => {
|
|
if (!DEBUG_ENABLED) return;
|
|
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];
|
|
}
|
|
function debugDown(e: MouseEvent) {
|
|
const el = e.target as HTMLElement;
|
|
debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`];
|
|
}
|
|
document.addEventListener('click', debugClick, true);
|
|
document.addEventListener('mousedown', debugDown, true);
|
|
return () => {
|
|
document.removeEventListener('click', debugClick, true);
|
|
document.removeEventListener('mousedown', debugDown, true);
|
|
};
|
|
});
|
|
|
|
// ── Toast ref for agent notifications ─────────────────────────
|
|
let toastRef: ToastContainer | undefined;
|
|
|
|
function showToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
|
|
toastRef?.addToast(message, variant);
|
|
}
|
|
|
|
// ── Global error boundary ──────────────────────────────────────
|
|
function setupErrorBoundary() {
|
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
|
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason);
|
|
console.error('[unhandled rejection]', e.reason);
|
|
showToast(`Unhandled error: ${msg.slice(0, 100)}`, 'error');
|
|
e.preventDefault();
|
|
});
|
|
window.addEventListener('error', (e: ErrorEvent) => {
|
|
console.error('[uncaught error]', e.error);
|
|
showToast(`Error: ${e.message.slice(0, 100)}`, 'error');
|
|
});
|
|
}
|
|
|
|
// ── Init ───────────────────────────────────────────────────────
|
|
onMount(() => {
|
|
// Wire agent toast callback
|
|
setAgentToastFn(showToast);
|
|
|
|
// Set up global error boundary
|
|
setupErrorBoundary();
|
|
|
|
// Fix #8: Load groups FIRST, then apply saved active_group.
|
|
// Other init tasks run in parallel.
|
|
const initTasks = [
|
|
themeStore.initTheme(appRpc).catch(console.error),
|
|
fontStore.initFonts(appRpc).catch(console.error),
|
|
keybindingStore.init(appRpc).catch(console.error),
|
|
// Sequential: groups.list -> active_group (depends on groups being loaded)
|
|
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => {
|
|
if (dbGroups.length > 0) groups = dbGroups;
|
|
// Now that groups are loaded, apply saved active_group
|
|
return appRpc.request["settings.get"]({ key: 'active_group' });
|
|
}).then(({ value }: { value: string | null }) => {
|
|
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
|
}).catch(console.error),
|
|
// Load projects from SQLite
|
|
appRpc.request["settings.getProjects"]({}).then(({ projects }: { projects: Array<{ id: string; config: string }> }) => {
|
|
if (projects.length > 0) {
|
|
const loaded: Project[] = projects.flatMap(({ config }) => {
|
|
try {
|
|
const p = JSON.parse(config) as Project;
|
|
return [{ ...p, status: p.status ?? 'idle', costUsd: p.costUsd ?? 0, tokens: p.tokens ?? 0, messages: p.messages ?? [] }];
|
|
} catch { return []; }
|
|
});
|
|
if (loaded.length > 0) PROJECTS = loaded;
|
|
}
|
|
}).catch(console.error),
|
|
];
|
|
|
|
Promise.allSettled(initTasks).then(() => {
|
|
appReady = true;
|
|
for (const p of PROJECTS) trackProject(p.id);
|
|
});
|
|
|
|
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());
|
|
|
|
// 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);
|
|
|
|
// Fix #18: Wire CommandPalette events to action handlers
|
|
function handlePaletteCommand(e: Event) {
|
|
const detail = (e as CustomEvent).detail;
|
|
switch (detail) {
|
|
case 'settings': settingsOpen = !settingsOpen; break;
|
|
case 'search': searchOpen = !searchOpen; break;
|
|
case 'new-project': showAddProject = true; break;
|
|
case 'toggle-sidebar': settingsOpen = !settingsOpen; break;
|
|
default: console.log(`[palette] unhandled command: ${detail}`);
|
|
}
|
|
}
|
|
window.addEventListener('palette-command', handlePaletteCommand);
|
|
|
|
const cleanup = keybindingStore.installListener();
|
|
return () => {
|
|
cleanup();
|
|
document.removeEventListener('keydown', handleSearchShortcut);
|
|
window.removeEventListener('palette-command', handlePaletteCommand);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<SplashScreen ready={appReady} />
|
|
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
|
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
|
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
|
|
<ToastContainer bind:this={toastRef} />
|
|
<NotifDrawer
|
|
open={drawerOpen}
|
|
{notifications}
|
|
onClear={clearNotifications}
|
|
onClose={() => drawerOpen = false}
|
|
/>
|
|
|
|
<div
|
|
class="app-shell"
|
|
role="presentation"
|
|
>
|
|
<!-- 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}
|
|
|
|
<!-- Add group button -->
|
|
<button
|
|
class="group-btn add-group-btn"
|
|
onclick={() => showAddGroup = !showAddGroup}
|
|
aria-label="Add group"
|
|
title="Add group"
|
|
>
|
|
<span class="group-circle" aria-hidden="true">+</span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if showAddGroup}
|
|
<div class="add-group-form">
|
|
<input
|
|
class="add-group-input"
|
|
type="text"
|
|
placeholder="Group name"
|
|
bind:value={newGroupName}
|
|
onkeydown={(e) => { if (e.key === 'Enter') addGroup(); if (e.key === 'Escape') showAddGroup = false; }}
|
|
autofocus
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Add project button -->
|
|
<button
|
|
class="sidebar-icon"
|
|
onclick={() => showAddProject = !showAddProject}
|
|
aria-label="Add project"
|
|
title="Add project"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<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">
|
|
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
|
|
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
|
|
{#each PROJECTS as project (project.id)}
|
|
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
|
|
<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>
|
|
{/if}
|
|
{/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>
|
|
|
|
<!-- Add project card -->
|
|
<div class="add-card" role="listitem" style:display={showAddProject ? 'flex' : 'none'}>
|
|
<div class="add-card-form">
|
|
<input
|
|
class="add-input"
|
|
type="text"
|
|
placeholder="Project name"
|
|
bind:value={newProjectName}
|
|
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
|
/>
|
|
<input
|
|
class="add-input"
|
|
type="text"
|
|
placeholder="Working directory (e.g. ~/code/myproject)"
|
|
bind:value={newProjectCwd}
|
|
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
|
/>
|
|
<div class="add-card-actions">
|
|
<button class="add-cancel" onclick={() => showAddProject = false}>Cancel</button>
|
|
<button class="add-confirm" onclick={addProject}>Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete project confirmation -->
|
|
{#if projectToDelete}
|
|
<div class="delete-overlay" role="listitem">
|
|
<p class="delete-text">Delete project "{PROJECTS.find(p => p.id === projectToDelete)?.name}"?</p>
|
|
<div class="add-card-actions">
|
|
<button class="add-cancel" onclick={() => projectToDelete = null}>Cancel</button>
|
|
<button class="delete-confirm" onclick={confirmDeleteProject}>Delete</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</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 (health-backed) -->
|
|
<StatusBar
|
|
projectCount={filteredProjects.length}
|
|
{totalTokens}
|
|
totalCost={totalCost}
|
|
{sessionDuration}
|
|
groupName={activeGroup?.name ?? ''}
|
|
/>
|
|
|
|
{#if DEBUG_ENABLED && debugLog.length > 0}
|
|
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
|
|
<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>
|
|
{/if}
|
|
|
|
<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; }
|
|
|
|
.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;
|
|
}
|
|
|
|
/* ── Add group form ─────────────────────────────────────────── */
|
|
.add-group-form {
|
|
padding: 0.25rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.add-group-input {
|
|
width: 100%;
|
|
padding: 0.25rem;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-text);
|
|
font-size: 0.625rem;
|
|
font-family: var(--ui-font-family);
|
|
text-align: center;
|
|
}
|
|
.add-group-input:focus { outline: none; border-color: var(--ctp-blue); }
|
|
|
|
/* ── Add project card ──────────────────────────────────────── */
|
|
.add-card {
|
|
grid-column: 1 / -1;
|
|
flex-direction: column;
|
|
background: var(--ctp-base);
|
|
border: 1px dashed var(--ctp-surface1);
|
|
border-radius: 0.5rem;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.add-card-form { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
|
|
.add-input {
|
|
padding: 0.375rem 0.5rem;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-text);
|
|
font-size: 0.8125rem;
|
|
font-family: var(--ui-font-family);
|
|
}
|
|
.add-input:focus { outline: none; border-color: var(--ctp-blue); }
|
|
.add-input::placeholder { color: var(--ctp-overlay0); }
|
|
|
|
.add-card-actions { display: flex; gap: 0.375rem; justify-content: flex-end; }
|
|
|
|
.add-cancel, .add-confirm, .delete-confirm {
|
|
padding: 0.25rem 0.625rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
font-family: var(--ui-font-family);
|
|
}
|
|
|
|
.add-cancel { background: transparent; border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); }
|
|
.add-cancel:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
|
.add-confirm { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border: 1px solid var(--ctp-green); color: var(--ctp-green); }
|
|
.add-confirm:hover { background: color-mix(in srgb, var(--ctp-green) 35%, transparent); }
|
|
|
|
.delete-overlay {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
background: var(--ctp-base);
|
|
border: 1px solid var(--ctp-red);
|
|
border-radius: 0.5rem;
|
|
}
|
|
|
|
.delete-text { font-size: 0.875rem; color: var(--ctp-text); margin: 0; }
|
|
.delete-confirm { background: color-mix(in srgb, var(--ctp-red) 20%, transparent); border: 1px solid var(--ctp-red); color: var(--ctp-red); }
|
|
.delete-confirm:hover { background: color-mix(in srgb, var(--ctp-red) 35%, transparent); }
|
|
|
|
/* Status bar styles are in StatusBar.svelte */
|
|
</style>
|