- Global unhandledrejection handler with IPC+network filtering - Agent dispatcher heartbeat uses handleInfraError (was fire-and-forget) - All stores: layout, workspace, anchors, theme, plugins, machines, wake-scheduler — silent failures replaced with handleInfraError - initGlobalErrorHandler() called in App.svelte onMount
423 lines
13 KiB
Svelte
423 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { initTheme } from './lib/stores/theme.svelte';
|
|
import { getSetting } from './lib/adapters/settings-bridge';
|
|
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
|
|
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
|
|
import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte';
|
|
import { registerProvider } from './lib/providers/registry.svelte';
|
|
import { CLAUDE_PROVIDER } from './lib/providers/claude';
|
|
import { CODEX_PROVIDER } from './lib/providers/codex';
|
|
import { OLLAMA_PROVIDER } from './lib/providers/ollama';
|
|
import { AIDER_PROVIDER } from './lib/providers/aider';
|
|
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
|
import { MemoraAdapter } from './lib/adapters/memora-bridge';
|
|
import {
|
|
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
|
|
getEnabledProjects, getAllWorkItems, getActiveProjectId,
|
|
triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle,
|
|
} from './lib/stores/workspace.svelte';
|
|
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
|
import { initGlobalErrorHandler } from './lib/utils/global-error-handler';
|
|
import { handleInfraError } from './lib/utils/handle-error';
|
|
import { pruneSeen } from './lib/adapters/btmsg-bridge';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
// Workspace components
|
|
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
|
|
import GroupAgentsPanel from './lib/components/Workspace/GroupAgentsPanel.svelte';
|
|
import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte';
|
|
import SettingsTab from './lib/components/Workspace/SettingsTab.svelte';
|
|
import SettingsPanel from './lib/settings/SettingsPanel.svelte';
|
|
import CommsTab from './lib/components/Workspace/CommsTab.svelte';
|
|
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
|
|
import SearchOverlay from './lib/components/Workspace/SearchOverlay.svelte';
|
|
|
|
// Shared
|
|
import StatusBar from './lib/components/StatusBar/StatusBar.svelte';
|
|
import ToastContainer from './lib/components/Notifications/ToastContainer.svelte';
|
|
import SplashScreen from './lib/components/SplashScreen.svelte';
|
|
|
|
// Detached mode (preserved from v2)
|
|
import TerminalPane from './lib/components/Terminal/TerminalPane.svelte';
|
|
import AgentPane from './lib/components/Agent/AgentPane.svelte';
|
|
|
|
let detached = isDetachedMode();
|
|
let detachedConfig = getDetachedConfig();
|
|
|
|
let paletteOpen = $state(false);
|
|
let searchOpen = $state(false);
|
|
let drawerOpen = $state(false);
|
|
let loaded = $state(false);
|
|
|
|
// Splash screen loading steps
|
|
let splashSteps = $state([
|
|
{ label: 'Initializing theme...', done: false },
|
|
{ label: 'Registering providers...', done: false },
|
|
{ label: 'Starting agent dispatcher...', done: false },
|
|
{ label: 'Connecting sidecar...', done: false },
|
|
{ label: 'Loading workspace...', done: false },
|
|
]);
|
|
|
|
function markStep(idx: number) {
|
|
splashSteps[idx] = { ...splashSteps[idx], done: true };
|
|
}
|
|
|
|
let activeTab = $derived(getActiveTab());
|
|
let panelContentEl: HTMLElement | undefined = $state();
|
|
let panelWidth = $state<string | undefined>(undefined);
|
|
|
|
// Measure the panel content's natural width
|
|
$effect(() => {
|
|
const el = panelContentEl;
|
|
void activeTab;
|
|
if (!el) { panelWidth = undefined; return; }
|
|
const frame = requestAnimationFrame(() => {
|
|
let maxW = 0;
|
|
const candidates = el.querySelectorAll('[style*="white-space"], h3, h4, input, .settings-list, .settings-tab');
|
|
for (const c of candidates) {
|
|
maxW = Math.max(maxW, c.scrollWidth);
|
|
}
|
|
const child = el.firstElementChild as HTMLElement;
|
|
if (child) {
|
|
const cs = getComputedStyle(child);
|
|
const mw = parseFloat(cs.minWidth);
|
|
if (!isNaN(mw)) maxW = Math.max(maxW, mw);
|
|
}
|
|
if (maxW > 0) {
|
|
panelWidth = `${maxW + 24}px`;
|
|
}
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
});
|
|
|
|
function toggleDrawer() {
|
|
drawerOpen = !drawerOpen;
|
|
}
|
|
|
|
onMount(() => {
|
|
// Global unhandled rejection safety net
|
|
initGlobalErrorHandler();
|
|
|
|
// Step 0: Theme
|
|
initTheme();
|
|
getSetting('project_max_aspect').then(v => {
|
|
if (v) document.documentElement.style.setProperty('--project-max-aspect', v);
|
|
});
|
|
markStep(0);
|
|
|
|
// Step 1: Providers
|
|
registerProvider(CLAUDE_PROVIDER);
|
|
registerProvider(CODEX_PROVIDER);
|
|
registerProvider(OLLAMA_PROVIDER);
|
|
registerProvider(AIDER_PROVIDER);
|
|
const memora = new MemoraAdapter();
|
|
registerMemoryAdapter(memora);
|
|
memora.checkAvailability();
|
|
markStep(1);
|
|
|
|
// Step 2: Agent dispatcher
|
|
startAgentDispatcher();
|
|
startHealthTick();
|
|
pruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup
|
|
markStep(2);
|
|
|
|
// Disable wake scheduler in test mode to prevent timer interference
|
|
invoke<boolean>('is_test_mode').then(isTest => {
|
|
if (isTest) disableWakeScheduler();
|
|
});
|
|
|
|
// Step 3: Sidecar (small delay to let sidecar report ready)
|
|
setTimeout(() => markStep(3), 300);
|
|
|
|
if (!detached) {
|
|
// Step 4: Workspace
|
|
loadWorkspace().then(() => {
|
|
markStep(4);
|
|
// Brief pause to show completed state before transition
|
|
setTimeout(() => { loaded = true; }, 400);
|
|
});
|
|
}
|
|
|
|
/** Check if event target is an editable element (input, textarea, contenteditable) */
|
|
function isEditing(e: KeyboardEvent): boolean {
|
|
const t = e.target as HTMLElement;
|
|
if (!t) return false;
|
|
const tag = t.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
|
if (t.isContentEditable) return true;
|
|
// xterm.js canvases and textareas should be considered editing
|
|
if (t.closest('.xterm')) return true;
|
|
return false;
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
// Ctrl+K — command palette (always active)
|
|
if (e.ctrlKey && !e.shiftKey && e.key === 'k') {
|
|
e.preventDefault();
|
|
paletteOpen = !paletteOpen;
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+F — global search overlay
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
|
|
e.preventDefault();
|
|
searchOpen = !searchOpen;
|
|
return;
|
|
}
|
|
|
|
// Alt+1..5 — quick-jump to project by index
|
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
|
|
e.preventDefault();
|
|
const projects = getAllWorkItems();
|
|
const idx = parseInt(e.key) - 1;
|
|
if (idx < projects.length) {
|
|
setActiveProject(projects[idx].id);
|
|
triggerFocusFlash(projects[idx].id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+1..9 — switch tab within focused project
|
|
if (e.ctrlKey && e.shiftKey && e.key >= '1' && e.key <= '9') {
|
|
// Allow Ctrl+Shift+K to pass through to its own handler
|
|
if (e.key === 'K') return;
|
|
e.preventDefault();
|
|
const projectId = getActiveProjectId();
|
|
if (projectId) {
|
|
const tabIdx = parseInt(e.key);
|
|
emitProjectTabSwitch(projectId, tabIdx);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+K — focus agent pane (switch to Model tab)
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'K' || e.key === 'k')) {
|
|
e.preventDefault();
|
|
const projectId = getActiveProjectId();
|
|
if (projectId) {
|
|
emitProjectTabSwitch(projectId, 1); // Model tab
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Vi-style navigation (skip when editing text)
|
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && !isEditing(e)) {
|
|
const projects = getAllWorkItems();
|
|
const currentId = getActiveProjectId();
|
|
const currentIdx = projects.findIndex(p => p.id === currentId);
|
|
|
|
// Ctrl+H — focus previous project (left)
|
|
if (e.key === 'h') {
|
|
e.preventDefault();
|
|
if (currentIdx > 0) {
|
|
setActiveProject(projects[currentIdx - 1].id);
|
|
triggerFocusFlash(projects[currentIdx - 1].id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+L — focus next project (right)
|
|
if (e.key === 'l') {
|
|
e.preventDefault();
|
|
if (currentIdx >= 0 && currentIdx < projects.length - 1) {
|
|
setActiveProject(projects[currentIdx + 1].id);
|
|
triggerFocusFlash(projects[currentIdx + 1].id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+J — toggle terminal section in focused project
|
|
if (e.key === 'j') {
|
|
e.preventDefault();
|
|
if (currentId) {
|
|
emitTerminalToggle(currentId);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ctrl+, — toggle settings panel
|
|
if (e.ctrlKey && e.key === ',') {
|
|
e.preventDefault();
|
|
if (getActiveTab() === 'settings' && drawerOpen) {
|
|
drawerOpen = false;
|
|
} else {
|
|
setActiveTab('settings');
|
|
drawerOpen = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+M — toggle messages panel
|
|
if (e.ctrlKey && !e.shiftKey && e.key === 'm') {
|
|
e.preventDefault();
|
|
if (getActiveTab() === 'comms' && drawerOpen) {
|
|
drawerOpen = false;
|
|
} else {
|
|
setActiveTab('comms');
|
|
drawerOpen = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+B — toggle sidebar
|
|
if (e.ctrlKey && !e.shiftKey && e.key === 'b') {
|
|
e.preventDefault();
|
|
drawerOpen = !drawerOpen;
|
|
return;
|
|
}
|
|
|
|
// Escape — close drawer
|
|
if (e.key === 'Escape' && drawerOpen) {
|
|
e.preventDefault();
|
|
drawerOpen = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeydown);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeydown);
|
|
stopAgentDispatcher();
|
|
stopHealthTick();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
{#if detached && detachedConfig}
|
|
<div class="detached-pane">
|
|
{#if detachedConfig.type === 'terminal' || detachedConfig.type === 'ssh'}
|
|
<TerminalPane
|
|
shell={detachedConfig.shell}
|
|
cwd={detachedConfig.cwd}
|
|
args={detachedConfig.args}
|
|
/>
|
|
{:else if detachedConfig.type === 'agent'}
|
|
<AgentPane
|
|
sessionId={detachedConfig.sessionId ?? crypto.randomUUID()}
|
|
cwd={detachedConfig.cwd}
|
|
/>
|
|
{:else}
|
|
<TerminalPane />
|
|
{/if}
|
|
</div>
|
|
{:else if loaded}
|
|
<div class="app-shell">
|
|
<div class="main-row">
|
|
<GlobalTabBar expanded={drawerOpen} ontoggle={toggleDrawer} />
|
|
|
|
{#if drawerOpen}
|
|
<aside class="sidebar-panel" style:width={panelWidth}>
|
|
<div class="panel-header">
|
|
<h2>{activeTab === 'comms' ? 'Messages' : 'Settings'}</h2>
|
|
<button class="panel-close" onclick={() => drawerOpen = false} title="Close sidebar (Ctrl+B)">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="panel-content" bind:this={panelContentEl}>
|
|
{#if activeTab === 'comms'}
|
|
<CommsTab />
|
|
{:else}
|
|
<SettingsPanel />
|
|
{/if}
|
|
</div>
|
|
</aside>
|
|
{/if}
|
|
|
|
<main class="workspace">
|
|
<GroupAgentsPanel />
|
|
<ProjectGrid />
|
|
</main>
|
|
</div>
|
|
|
|
<StatusBar />
|
|
</div>
|
|
|
|
<CommandPalette open={paletteOpen} onclose={() => paletteOpen = false} />
|
|
<SearchOverlay open={searchOpen} onclose={() => searchOpen = false} />
|
|
{:else}
|
|
<SplashScreen steps={splashSteps} />
|
|
{/if}
|
|
<ToastContainer />
|
|
|
|
<style>
|
|
.detached-pane {
|
|
height: 100vh;
|
|
width: 100vw;
|
|
background: var(--ctp-base);
|
|
}
|
|
|
|
.app-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
background: var(--ctp-base);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main-row {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar-panel {
|
|
min-width: 16em;
|
|
max-width: 50%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--ctp-base);
|
|
border-right: 1px solid var(--ctp-surface1);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--ctp-text);
|
|
margin: 0;
|
|
}
|
|
|
|
.panel-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.375rem;
|
|
height: 1.375rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-subtext0);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.panel-close:hover {
|
|
color: var(--ctp-text);
|
|
background: var(--ctp-surface0);
|
|
}
|
|
|
|
.panel-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.workspace {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
</style>
|