generated from nhcarrigan/template
feat: add feature to monitor background agents (#125)
Also includes a fix to persist configuration across reconnects. Reviewed-on: #125 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #125.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// We need a reactive subscription to agents for the active conversation
|
||||
let agents: AgentInfo[] = $state([]);
|
||||
let agentsUnsubscribe: (() => void) | null = null;
|
||||
|
||||
// Track active conversation reactively
|
||||
let currentConversationId = $state<string | null>("");
|
||||
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||
currentConversationId = id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-subscribe when conversation changes
|
||||
if (agentsUnsubscribe) {
|
||||
agentsUnsubscribe();
|
||||
}
|
||||
if (currentConversationId) {
|
||||
const store = getAgentsForConversation(currentConversationId);
|
||||
agentsUnsubscribe = store.subscribe((value) => {
|
||||
agents = value;
|
||||
});
|
||||
} else {
|
||||
agents = [];
|
||||
}
|
||||
});
|
||||
|
||||
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||
|
||||
// Organize agents into a tree structure based on parent_tool_use_id
|
||||
const agentTree = $derived.by(() => {
|
||||
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||
|
||||
// Group children by their parent
|
||||
agents.forEach((agent) => {
|
||||
if (agent.parentToolUseId) {
|
||||
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||
siblings.push(agent);
|
||||
childrenMap.set(agent.parentToolUseId, siblings);
|
||||
}
|
||||
});
|
||||
|
||||
return { topLevel, childrenMap };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||
conversationIdUnsubscribe();
|
||||
});
|
||||
|
||||
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||
const end = endedAt || now;
|
||||
const durationMs = end - startedAt;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getSubagentTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Explore: "Explorer",
|
||||
"general-purpose": "General",
|
||||
Plan: "Planner",
|
||||
Bash: "Shell",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "errored":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKillAll() {
|
||||
if (!currentConversationId) return;
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to kill Claude process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearCompleted() {
|
||||
if (currentConversationId) {
|
||||
agentStore.clearCompleted(currentConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the tree for rendering with depth information
|
||||
const flattenedAgents = $derived.by(() => {
|
||||
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||
const { topLevel, childrenMap } = agentTree;
|
||||
|
||||
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||
result.push({ agent, depth });
|
||||
const children = childrenMap.get(agent.toolUseId);
|
||||
if (children) {
|
||||
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||
|
||||
<div
|
||||
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||
{#if runningAgents.length > 0}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||
>
|
||||
{runningAgents.length} running
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close agent monitor"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={handleKillAll}
|
||||
disabled={runningAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Kills the entire Claude Code process to stop all agents"
|
||||
>
|
||||
Kill All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearCompleted}
|
||||
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent list -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{#if agents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No agents detected yet</p>
|
||||
<p class="text-xs mt-1 opacity-70">
|
||||
Agents will appear here when Claude uses the Task tool
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||
<div
|
||||
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||
'running'
|
||||
? 'border-l-2 border-l-blue-500'
|
||||
: agent.status === 'errored'
|
||||
? 'border-l-2 border-l-red-500'
|
||||
: 'border-l-2 border-l-green-500'}"
|
||||
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||
>
|
||||
<!-- Agent header -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if depth > 0}
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||
agent.status
|
||||
)}"
|
||||
>
|
||||
{getSubagentTypeLabel(agent.subagentType)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] {agent.status === 'running'
|
||||
? 'text-blue-400'
|
||||
: 'text-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if agent.durationMs !== undefined}
|
||||
{Math.floor(agent.durationMs / 1000)}s
|
||||
{:else}
|
||||
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||
{/if}
|
||||
{#if agent.status === "running"}
|
||||
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Agent description -->
|
||||
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
{#if agent.status === "running"}
|
||||
<span class="text-[10px] text-blue-400">Running...</span>
|
||||
{:else if agent.status === "completed"}
|
||||
<span class="text-[10px] text-green-400">Completed</span>
|
||||
{:else}
|
||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer summary -->
|
||||
{#if agents.length > 0}
|
||||
<div
|
||||
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||
>
|
||||
{agents.length} total ·
|
||||
{runningAgents.length} running ·
|
||||
{completedAgents.length} completed ·
|
||||
{erroredAgents.length} errored
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -355,6 +355,10 @@ User: ${formattedMessage}`;
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,16 +57,20 @@
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: newGrantedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after permission grant
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import { runningAgentCount } from "$lib/stores/agents";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
@@ -48,8 +50,10 @@
|
||||
let showSessionHistory = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
let showAgentMonitor = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -466,6 +470,29 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''}"
|
||||
title="Agent Monitor"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeAgentCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
||||
>
|
||||
{activeAgentCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
@@ -638,3 +665,7 @@
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -209,8 +209,30 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
|
||||
@@ -96,16 +96,20 @@
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: grantedToolsList,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
|
||||
Reference in New Issue
Block a user