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:
parent
da6d7272ee
commit
5ca035d438
4 changed files with 104 additions and 11 deletions
|
|
@ -159,6 +159,12 @@ impl SidecarManager {
|
||||||
self.send_message(&msg)
|
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> {
|
pub fn shutdown(&self) -> Result<(), String> {
|
||||||
let mut child_lock = self.child.lock().unwrap();
|
let mut child_lock = self.child.lock().unwrap();
|
||||||
if let Some(ref mut child) = *child_lock {
|
if let Some(ref mut child) = *child_lock {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export async function isAgentReady(): Promise<boolean> {
|
||||||
return invoke<boolean>('agent_ready');
|
return invoke<boolean>('agent_ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restartAgent(): Promise<void> {
|
||||||
|
return invoke('agent_restart');
|
||||||
|
}
|
||||||
|
|
||||||
export interface SidecarMessage {
|
export interface SidecarMessage {
|
||||||
type: string;
|
type: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Agent Dispatcher — connects sidecar bridge events to agent store
|
// Agent Dispatcher — connects sidecar bridge events to agent store
|
||||||
// Single listener that routes sidecar messages to the correct agent session
|
// 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 { adaptSDKMessage } from './adapters/sdk-messages';
|
||||||
import type { InitContent, CostContent } from './adapters/sdk-messages';
|
import type { InitContent, CostContent } from './adapters/sdk-messages';
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,14 +10,29 @@ import {
|
||||||
setAgentModel,
|
setAgentModel,
|
||||||
appendAgentMessages,
|
appendAgentMessages,
|
||||||
updateAgentCost,
|
updateAgentCost,
|
||||||
|
getAgentSessions,
|
||||||
} from './stores/agents.svelte';
|
} 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> {
|
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;
|
const sessionId = msg.sessionId;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
|
@ -39,10 +54,19 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'agent_log':
|
case 'agent_log':
|
||||||
// Debug logging — could route to a log panel later
|
|
||||||
break;
|
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 {
|
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) {
|
if (messages.length > 0) {
|
||||||
appendAgentMessages(sessionId, messages);
|
appendAgentMessages(sessionId, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAgentDispatcher(): void {
|
export function stopAgentDispatcher(): void {
|
||||||
if (unlistenFn) {
|
if (unlistenMsg) {
|
||||||
unlistenFn();
|
unlistenMsg();
|
||||||
unlistenFn = null;
|
unlistenMsg = null;
|
||||||
|
}
|
||||||
|
if (unlistenExit) {
|
||||||
|
unlistenExit();
|
||||||
|
unlistenExit = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { queryAgent, stopAgent, isAgentReady } from '../../adapters/agent-bridge';
|
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
|
||||||
import {
|
import {
|
||||||
getAgentSession,
|
getAgentSession,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
removeAgentSession,
|
removeAgentSession,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
} from '../../stores/agents.svelte';
|
} from '../../stores/agents.svelte';
|
||||||
|
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||||
import type {
|
import type {
|
||||||
AgentMessage,
|
AgentMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
|
|
@ -30,6 +31,7 @@
|
||||||
let inputPrompt = $state(initialPrompt);
|
let inputPrompt = $state(initialPrompt);
|
||||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||||
let autoScroll = $state(true);
|
let autoScroll = $state(true);
|
||||||
|
let restarting = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (initialPrompt) {
|
if (initialPrompt) {
|
||||||
|
|
@ -73,12 +75,31 @@
|
||||||
stopAgent(sessionId).catch(() => {});
|
stopAgent(sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRestart() {
|
||||||
|
restarting = true;
|
||||||
|
try {
|
||||||
|
await restartAgent();
|
||||||
|
setSidecarAlive(true);
|
||||||
|
} catch {
|
||||||
|
// Still dead
|
||||||
|
} finally {
|
||||||
|
restarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
if (autoScroll && scrollContainer) {
|
if (autoScroll && scrollContainer) {
|
||||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
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
|
// Auto-scroll when new messages arrive
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (session?.messages.length) {
|
if (session?.messages.length) {
|
||||||
|
|
@ -121,7 +142,7 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="messages" bind:this={scrollContainer}>
|
<div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
|
||||||
{#each session.messages as msg (msg.id)}
|
{#each session.messages as msg (msg.id)}
|
||||||
<div class="message msg-{msg.type}">
|
<div class="message msg-{msg.type}">
|
||||||
{#if msg.type === 'init'}
|
{#if msg.type === 'init'}
|
||||||
|
|
@ -173,6 +194,9 @@
|
||||||
<div class="running-indicator">
|
<div class="running-indicator">
|
||||||
<span class="pulse"></span>
|
<span class="pulse"></span>
|
||||||
<span>Running...</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>
|
<button class="stop-btn" onclick={handleStop}>Stop</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if session.status === 'done'}
|
{:else if session.status === 'done'}
|
||||||
|
|
@ -184,6 +208,11 @@
|
||||||
{:else if session.status === 'error'}
|
{:else if session.status === 'error'}
|
||||||
<div class="error-bar">
|
<div class="error-bar">
|
||||||
<span>Error: {session.error ?? 'Unknown'}</span>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -407,11 +436,38 @@
|
||||||
|
|
||||||
.stop-btn:hover { opacity: 0.9; }
|
.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 {
|
.done-bar, .error-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.done-bar { color: var(--ctp-green); }
|
.done-bar { color: var(--ctp-green); }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue