generated from nhcarrigan/template
a4e6788573
## Summary This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit. - **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157 - **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153 - **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50) - **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155 - **"Prompt is too long" handling** — Detects this error in assistant messages and shows a ⚡ Compact Conversation button to send `/compact` directly. Closes #158 - **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156 - **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150 - **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151 - **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154 ## Test plan - [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green - [ ] Auth status: open settings and verify the Account section shows correct login info - [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches - [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly - [ ] Compact button: unit tests cover detection; button renders correctly in terminal - [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet - [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args - [ ] ConfigChange: hook events display as `[config]` lines rather than errors - [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ` ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #159 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
349 lines
12 KiB
Svelte
349 lines
12 KiB
Svelte
<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 });
|
|
// Mark all running agents as errored after killing the process
|
|
agentStore.markAllErrored(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}
|
|
<img
|
|
src={agent.characterAvatar}
|
|
alt={agent.characterName}
|
|
class="w-5 h-5 rounded-full object-cover"
|
|
/>
|
|
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
|
{agent.characterName}
|
|
</span>
|
|
<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>
|
|
|
|
<!-- Last assistant message snippet -->
|
|
{#if agent.lastAssistantMessage}
|
|
<p
|
|
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
|
title={agent.lastAssistantMessage}
|
|
>
|
|
{agent.lastAssistantMessage}
|
|
</p>
|
|
{/if}
|
|
</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}
|