feat(v2): add sidecar crash detection, restart UI, and auto-scroll lock

Phase 3 polish: dispatcher listens for sidecar-exited events and marks
running sessions as error. AgentPane shows "Restart Sidecar" button on
error. Auto-scroll disables when user scrolls >50px from bottom with
"Scroll to bottom" button. Added agent_restart Tauri command.
This commit is contained in:
Hibryda 2026-03-06 12:19:35 +01:00
parent da6d7272ee
commit 5ca035d438
4 changed files with 104 additions and 11 deletions

View file

@ -159,6 +159,12 @@ impl SidecarManager {
self.send_message(&msg)
}
pub fn restart(&self, app: &AppHandle) -> Result<(), String> {
log::info!("Restarting sidecar");
let _ = self.shutdown();
self.start(app)
}
pub fn shutdown(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap();
if let Some(ref mut child) = *child_lock {

View file

@ -25,6 +25,10 @@ export async function isAgentReady(): Promise<boolean> {
return invoke<boolean>('agent_ready');
}
export async function restartAgent(): Promise<void> {
return invoke('agent_restart');
}
export interface SidecarMessage {
type: string;
sessionId?: string;

View file

@ -1,7 +1,7 @@
// Agent Dispatcher — connects sidecar bridge events to agent store
// Single listener that routes sidecar messages to the correct agent session
import { onSidecarMessage, type SidecarMessage } from './adapters/agent-bridge';
import { onSidecarMessage, onSidecarExited, type SidecarMessage } from './adapters/agent-bridge';
import { adaptSDKMessage } from './adapters/sdk-messages';
import type { InitContent, CostContent } from './adapters/sdk-messages';
import {
@ -10,14 +10,29 @@ import {
setAgentModel,
appendAgentMessages,
updateAgentCost,
getAgentSessions,
} from './stores/agents.svelte';
let unlistenFn: (() => void) | null = null;
let unlistenMsg: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
// Sidecar liveness — checked by UI components
let sidecarAlive = true;
export function isSidecarAlive(): boolean {
return sidecarAlive;
}
export function setSidecarAlive(alive: boolean): void {
sidecarAlive = alive;
}
export async function startAgentDispatcher(): Promise<void> {
if (unlistenFn) return;
if (unlistenMsg) return;
sidecarAlive = true;
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => {
sidecarAlive = true;
unlistenFn = await onSidecarMessage((msg: SidecarMessage) => {
const sessionId = msg.sessionId;
if (!sessionId) return;
@ -39,10 +54,19 @@ export async function startAgentDispatcher(): Promise<void> {
break;
case 'agent_log':
// Debug logging — could route to a log panel later
break;
}
});
unlistenExit = await onSidecarExited(() => {
sidecarAlive = false;
// Mark all running sessions as errored
for (const session of getAgentSessions()) {
if (session.status === 'running' || session.status === 'starting') {
updateAgentStatus(session.id, 'error', 'Sidecar crashed');
}
}
});
}
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
@ -76,15 +100,18 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
}
}
// Append all messages to the session history
if (messages.length > 0) {
appendAgentMessages(sessionId, messages);
}
}
export function stopAgentDispatcher(): void {
if (unlistenFn) {
unlistenFn();
unlistenFn = null;
if (unlistenMsg) {
unlistenMsg();
unlistenMsg = null;
}
if (unlistenExit) {
unlistenExit();
unlistenExit = null;
}
}

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { queryAgent, stopAgent, isAgentReady } from '../../adapters/agent-bridge';
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
import {
getAgentSession,
createAgentSession,
removeAgentSession,
type AgentSession,
} from '../../stores/agents.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import type {
AgentMessage,
TextContent,
@ -30,6 +31,7 @@
let inputPrompt = $state(initialPrompt);
let scrollContainer: HTMLDivElement | undefined = $state();
let autoScroll = $state(true);
let restarting = $state(false);
onMount(async () => {
if (initialPrompt) {
@ -73,12 +75,31 @@
stopAgent(sessionId).catch(() => {});
}
async function handleRestart() {
restarting = true;
try {
await restartAgent();
setSidecarAlive(true);
} catch {
// Still dead
} finally {
restarting = false;
}
}
function scrollToBottom() {
if (autoScroll && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
function handleScroll() {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// Lock auto-scroll if user scrolled up more than 50px from bottom
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
}
// Auto-scroll when new messages arrive
$effect(() => {
if (session?.messages.length) {
@ -121,7 +142,7 @@
</form>
</div>
{:else}
<div class="messages" bind:this={scrollContainer}>
<div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
{#each session.messages as msg (msg.id)}
<div class="message msg-{msg.type}">
{#if msg.type === 'init'}
@ -173,6 +194,9 @@
<div class="running-indicator">
<span class="pulse"></span>
<span>Running...</span>
{#if !autoScroll}
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
{/if}
<button class="stop-btn" onclick={handleStop}>Stop</button>
</div>
{:else if session.status === 'done'}
@ -184,6 +208,11 @@
{:else if session.status === 'error'}
<div class="error-bar">
<span>Error: {session.error ?? 'Unknown'}</span>
{#if session.error?.includes('Sidecar') || session.error?.includes('crashed')}
<button class="restart-btn" onclick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Sidecar'}
</button>
{/if}
</div>
{/if}
</div>
@ -407,11 +436,38 @@
.stop-btn:hover { opacity: 0.9; }
.scroll-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 3px;
padding: 2px 8px;
font-size: 10px;
cursor: pointer;
}
.scroll-btn:hover { color: var(--text-primary); }
.restart-btn {
margin-left: auto;
background: var(--ctp-peach);
color: var(--ctp-crust);
border: none;
border-radius: 3px;
padding: 2px 10px;
font-size: 11px;
cursor: pointer;
}
.restart-btn:hover { opacity: 0.9; }
.restart-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.done-bar, .error-bar {
display: flex;
gap: 12px;
font-size: 11px;
font-family: var(--font-mono);
align-items: center;
}
.done-bar { color: var(--ctp-green); }