generated from nhcarrigan/template
feat: major feature additions and improvements (#135)
## Summary This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements! ## New Features ✨ ### Plugin & MCP Management (#133, #134) - **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins - **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration - **Marketplace Management**: Add/remove plugin marketplaces from GitHub - Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.) - Beautiful UI with proper loading states, error handling, and theme support ### Visual Todo List Panel (#132) - Real-time todo list display when Hikari uses the `TodoWrite` tool - Shows pending/in-progress/completed status with visual indicators - Progress bar and completion count - Automatically clears on disconnect - Theme-aware styling ### Clear Session History Button (#130) - "Clear All Sessions" button in Session History panel - Confirmation dialog with session count - Keyboard support and accessibility features - Gives users control over disk usage ### CLI Version Display (#131) - Displays Claude CLI version in status bar - Auto-polls every 30 seconds for updates - Useful for debugging and feature compatibility ## Bug Fixes 🐛 ### Stats Panel Scrolling (#136) - **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint - Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long - Prevents content from overflowing off screen ### Agent Monitor Fixes (#122) - **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing - **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect - **Fixed "Kill All" button**: Now properly marks all agents as errored - **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted - Comprehensive tests for agent lifecycle management ### Discord RPC Cleanup (#129) - Removed file-based logging for Discord RPC - Replaced with proper `tracing` framework usage - Reduces disk usage and eliminates maintenance burden ### Close Modal Bug Fix (#128) - Fixed close confirmation modal not triggering after Discord RPC refactor - Removed frontend calls to deleted `log_discord_rpc` command - Modal now works correctly after all operations ### Responsive Design Fixes (#118) - Fixed top navigation icons getting cut off at small screen widths - Fixed Connect button disappearing on narrow screens - Fixed bottom status info (clock, CLI version) getting cut off - Added flex-wrap and mobile-optimised layouts - Icons-only mode on screens < 640px - Vertical stacking on screens < 768px ## Testing Improvements 🧪 ### Comprehensive Test Coverage (#114) - **417 backend tests** (up from 408) - **387 frontend tests** (up from 363) - **61%+ backend code coverage** - Added E2E integration tests for cross-platform notification commands - New test files: `agents.test.ts`, comprehensive CLI parsing tests - Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs` - Console mocking for cleaner test output - Fixed flaky frontend tests ### Testing Documentation - Updated CLAUDE.md with comprehensive testing guidelines - Documented mocking approaches (console mocking, E2E command structure testing) - Added step-by-step guide for adding tests to new features - Goal to maintain ~100% test coverage documented ## Closes Closes #114 Closes #118 Closes #122 Closes #128 Closes #129 Closes #130 Closes #131 Closes #132 Closes #133 Closes #134 Closes #136 ## Technical Details - All new backend commands properly registered in `lib.rs` - CLI output parsing with comprehensive test coverage - Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands) - Theme-aware UI components using CSS variables throughout - Proper TypeScript types for all new stores and components - ESLint and Prettier compliant - All Clippy warnings addressed ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #135 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #135.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||
import { get } from "svelte/store";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
|
||||
describe("agents store", () => {
|
||||
const conversationId = "test-conversation-1";
|
||||
const otherConversationId = "test-conversation-2";
|
||||
|
||||
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
|
||||
toolUseId: "toolu_test123",
|
||||
description: "Test agent",
|
||||
subagentType: "Explore",
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all conversations by subscribing and getting state
|
||||
let state: Record<string, AgentInfo[]> = {};
|
||||
const unsub = agentStore.subscribe((s) => {
|
||||
state = s;
|
||||
});
|
||||
unsub();
|
||||
|
||||
// Clear each conversation
|
||||
for (const convId of Object.keys(state)) {
|
||||
agentStore.clearConversation(convId);
|
||||
}
|
||||
});
|
||||
|
||||
describe("addAgent", () => {
|
||||
it("adds an agent to a conversation", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]).toEqual(agent);
|
||||
});
|
||||
|
||||
it("adds multiple agents to the same conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents[0]).toEqual(agent1);
|
||||
expect(agents[1]).toEqual(agent2);
|
||||
});
|
||||
|
||||
it("keeps agents in different conversations separate", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(1);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents1[0]).toEqual(agent1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAgentId", () => {
|
||||
it("updates the agent_id for a specific agent", () => {
|
||||
const agent = createMockAgent({ agentId: undefined });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBe("agent-abc123");
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if tool_use_id doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endAgent", () => {
|
||||
it("marks an agent as completed", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
|
||||
});
|
||||
|
||||
it("marks an agent as errored", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
});
|
||||
|
||||
it("calculates duration correctly", () => {
|
||||
const startTime = Date.now() - 5000; // 5 seconds ago
|
||||
const agent = createMockAgent({ startedAt: startTime, status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
|
||||
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if agent doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("running"); // Status unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAllErrored", () => {
|
||||
it("marks all running agents as errored", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[1].status).toBe("errored");
|
||||
expect(agents[1].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.markAllErrored("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if conversation has no running agents", () => {
|
||||
const agent = createMockAgent({ status: "completed" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed"); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCompleted", () => {
|
||||
it("removes completed and errored agents", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearCompleted("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("clears all agents if all are completed", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConversation", () => {
|
||||
it("removes all agents from a conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only removes agents from the specified conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(0);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearConversation("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runningAgentCount", () => {
|
||||
it("counts running agents across all conversations", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
agentStore.addAgent(otherConversationId, agent4);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(3); // 2 from first conversation + 1 from second
|
||||
});
|
||||
|
||||
it("returns 0 when no agents are running", () => {
|
||||
const agent1 = createMockAgent({ status: "completed" });
|
||||
const agent2 = createMockAgent({ status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when agents complete", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(1);
|
||||
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when conversation is cleared", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -238,6 +239,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -720,6 +722,9 @@ describe("config store", () => {
|
||||
it("handles save errors gracefully without losing data", async () => {
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
// Mock console.error to suppress expected error output
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Set initial config
|
||||
await configStore.updateConfig({ font_size: 14 });
|
||||
|
||||
@@ -731,6 +736,12 @@ describe("config store", () => {
|
||||
|
||||
// Original config should still be accessible
|
||||
expect(configStore.getConfig().font_size).toBe(14);
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -773,6 +784,7 @@ describe("config store", () => {
|
||||
budget_action: "block",
|
||||
budget_warning_threshold: 0.9,
|
||||
discord_rpc_enabled: false,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface HikariConfig {
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
// Thinking blocks settings
|
||||
show_thinking_blocks: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -84,6 +86,7 @@ const defaultConfig: HikariConfig = {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -297,6 +300,10 @@ export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
export const showThinkingBlocks = derived(
|
||||
configStore.config,
|
||||
($config) => $config.show_thinking_blocks
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
|
||||
export interface ConversationSummary {
|
||||
generatedAt: Date;
|
||||
@@ -333,6 +334,10 @@ function createConversationsStore() {
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
// Clean up agent tracking for this conversation
|
||||
// This prevents the badge from persisting after tab close
|
||||
agentStore.clearConversation(id);
|
||||
|
||||
conversations.update((c) => {
|
||||
c.delete(id);
|
||||
return c;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
interface TodoUpdatePayload {
|
||||
todos: TodoItem[];
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
// Create the writable store
|
||||
const { subscribe, set, update } = writable<TodoItem[]>([]);
|
||||
|
||||
// Listen for todo updates from the backend
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
export async function initializeTodoListener(): Promise<void> {
|
||||
if (unlisten) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
unlisten = await listen<TodoUpdatePayload>("claude:todo-update", (event) => {
|
||||
set(event.payload.todos);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupTodoListener(): void {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Export the store
|
||||
export const todos = {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
clear: () => set([]),
|
||||
};
|
||||
Reference in New Issue
Block a user