generated from nhcarrigan/template
feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: #221 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #221.
This commit is contained in:
@@ -43,6 +43,22 @@ describe("agents store", () => {
|
||||
expect(agents[0]).toMatchObject(agent);
|
||||
});
|
||||
|
||||
it("preserves model field when provided", () => {
|
||||
const agent = createMockAgent({ model: "claude-opus-4-6" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].model).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("leaves model undefined when not provided", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("assigns a character name and avatar to added agents", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
@@ -121,6 +137,28 @@ describe("agents store", () => {
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates agentType when provided alongside agentId", () => {
|
||||
const agent = createMockAgent({ agentId: undefined });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBe("agent-abc123");
|
||||
expect(agents[0].agentType).toBe("general-purpose");
|
||||
});
|
||||
|
||||
it("does not set agentType when not provided", () => {
|
||||
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");
|
||||
expect(agents[0].agentType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endAgent", () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ function createAgentStore() {
|
||||
});
|
||||
},
|
||||
|
||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string, agentType?: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
@@ -36,6 +36,7 @@ function createAgentStore() {
|
||||
updated[agentIndex] = {
|
||||
...updated[agentIndex],
|
||||
agentId,
|
||||
...(agentType !== undefined ? { agentType } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const claudeStore = {
|
||||
grantedTools: conversationsStore.grantedTools,
|
||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||
attachments: conversationsStore.attachments,
|
||||
worktreeInfo: conversationsStore.worktreeInfo,
|
||||
|
||||
// New conversation-aware subscriptions
|
||||
conversations: conversationsStore.conversations,
|
||||
@@ -70,6 +71,9 @@ export const claudeStore = {
|
||||
// Draft text (per-tab input persistence)
|
||||
setDraftText: conversationsStore.setDraftText,
|
||||
|
||||
// Worktree info (per-conversation)
|
||||
setWorktreeInfo: conversationsStore.setWorktreeInfo,
|
||||
|
||||
// Conversation management
|
||||
createConversation: conversationsStore.createConversation,
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
|
||||
@@ -220,6 +220,11 @@ describe("config store", () => {
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -279,6 +284,11 @@ describe("config store", () => {
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -893,6 +903,11 @@ describe("config store", () => {
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -81,6 +81,16 @@ export interface HikariConfig {
|
||||
task_loop_auto_commit: boolean;
|
||||
task_loop_commit_prefix: string;
|
||||
task_loop_include_summary: boolean;
|
||||
// Disable cron scheduling
|
||||
disable_cron: boolean;
|
||||
// Git instructions setting
|
||||
include_git_instructions: boolean;
|
||||
// Claude.ai MCP servers setting
|
||||
enable_claudeai_mcp_servers: boolean;
|
||||
// Auto-memory directory
|
||||
auto_memory_directory: string | null;
|
||||
// Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.)
|
||||
model_overrides: Record<string, string> | null;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -134,6 +144,11 @@ const defaultConfig: HikariConfig = {
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -562,6 +562,54 @@ describe("draft text persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktreeInfo state management", () => {
|
||||
it("initialises worktreeInfo as null", () => {
|
||||
const conversation = { worktreeInfo: null };
|
||||
expect(conversation.worktreeInfo).toBeNull();
|
||||
});
|
||||
|
||||
it("stores worktreeInfo when a worktree is created", () => {
|
||||
const info = {
|
||||
name: "worktree-abc",
|
||||
path: "/tmp/worktrees/worktree-abc",
|
||||
branch: "feat/my-feature",
|
||||
original_repo_directory: "/home/naomi/code/project",
|
||||
};
|
||||
const conversation = { worktreeInfo: null as typeof info | null };
|
||||
conversation.worktreeInfo = info;
|
||||
|
||||
expect(conversation.worktreeInfo?.branch).toBe("feat/my-feature");
|
||||
expect(conversation.worktreeInfo?.name).toBe("worktree-abc");
|
||||
expect(conversation.worktreeInfo?.original_repo_directory).toBe("/home/naomi/code/project");
|
||||
});
|
||||
|
||||
it("clears worktreeInfo when a worktree is removed", () => {
|
||||
const info = {
|
||||
name: "worktree-abc",
|
||||
path: "/tmp/worktrees/worktree-abc",
|
||||
branch: "feat/my-feature",
|
||||
original_repo_directory: "/home/naomi/code/project",
|
||||
};
|
||||
const conversation = { worktreeInfo: info as typeof info | null };
|
||||
conversation.worktreeInfo = null;
|
||||
|
||||
expect(conversation.worktreeInfo).toBeNull();
|
||||
});
|
||||
|
||||
it("stores worktreeInfo independently per conversation", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { worktreeInfo: null as { branch: string } | null }],
|
||||
["conv-2", { worktreeInfo: null as { branch: string } | null }],
|
||||
]);
|
||||
|
||||
const conv1 = conversations.get("conv-1");
|
||||
if (conv1) conv1.worktreeInfo = { branch: "feat/one" };
|
||||
|
||||
expect(conversations.get("conv-1")?.worktreeInfo?.branch).toBe("feat/one");
|
||||
expect(conversations.get("conv-2")?.worktreeInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessing state management", () => {
|
||||
it("starts as false by default", () => {
|
||||
const conversation = { id: "conv-1", isProcessing: false };
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Attachment,
|
||||
} from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { WorktreeInfo } from "$lib/types/worktree";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
@@ -41,6 +42,7 @@ export interface Conversation {
|
||||
successSoundFired: boolean;
|
||||
taskStartSoundFired: boolean;
|
||||
draftText: string;
|
||||
worktreeInfo: WorktreeInfo | null;
|
||||
}
|
||||
|
||||
const TAB_NAMES = [
|
||||
@@ -165,6 +167,7 @@ function createConversationsStore() {
|
||||
successSoundFired: false,
|
||||
taskStartSoundFired: false,
|
||||
draftText: "",
|
||||
worktreeInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +223,7 @@ function createConversationsStore() {
|
||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
|
||||
|
||||
return {
|
||||
// Expose derived stores for compatibility
|
||||
@@ -235,6 +239,7 @@ function createConversationsStore() {
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||
attachments: { subscribe: attachments.subscribe },
|
||||
worktreeInfo: { subscribe: worktreeInfo.subscribe },
|
||||
|
||||
// New conversation-specific stores
|
||||
conversations: { subscribe: conversations.subscribe },
|
||||
@@ -976,6 +981,16 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
setWorktreeInfo: (conversationId: string, info: WorktreeInfo | null) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.worktreeInfo = info;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { memoryBrowserStore } from "./memoryBrowser";
|
||||
|
||||
beforeEach(() => {
|
||||
memoryBrowserStore.close();
|
||||
});
|
||||
|
||||
describe("memoryBrowserStore", () => {
|
||||
it("initialises with panel closed", () => {
|
||||
const state = get(memoryBrowserStore);
|
||||
expect(state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("open() sets isOpen to true", () => {
|
||||
memoryBrowserStore.open();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("close() sets isOpen to false", () => {
|
||||
memoryBrowserStore.open();
|
||||
memoryBrowserStore.close();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle() opens the panel when closed", () => {
|
||||
memoryBrowserStore.close();
|
||||
memoryBrowserStore.toggle();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("toggle() closes the panel when open", () => {
|
||||
memoryBrowserStore.open();
|
||||
memoryBrowserStore.toggle();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("calling open() when already open keeps it open", () => {
|
||||
memoryBrowserStore.open();
|
||||
memoryBrowserStore.open();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("calling close() when already closed keeps it closed", () => {
|
||||
memoryBrowserStore.close();
|
||||
memoryBrowserStore.close();
|
||||
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
interface MemoryBrowserState {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function createMemoryBrowserStore() {
|
||||
const { subscribe, update } = writable<MemoryBrowserState>({
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
open: () => update((state) => ({ ...state, isOpen: true })),
|
||||
close: () => update((state) => ({ ...state, isOpen: false })),
|
||||
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
|
||||
};
|
||||
}
|
||||
|
||||
export const memoryBrowserStore = createMemoryBrowserStore();
|
||||
@@ -59,12 +59,52 @@ const makeConversation = () => ({
|
||||
|
||||
describe("sessionsStore - loadSessions", () => {
|
||||
it("loads sessions from backend and updates the store", async () => {
|
||||
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||
const sessionList = [
|
||||
{
|
||||
id: "session-1",
|
||||
name: "Test",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_sessions", sessionList);
|
||||
await sessionsStore.loadSessions();
|
||||
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
||||
});
|
||||
|
||||
it("sorts sessions by last_activity_at descending", async () => {
|
||||
const sessionList = [
|
||||
{
|
||||
id: "older",
|
||||
name: "Older",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "newest",
|
||||
name: "Newest",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "middle",
|
||||
name: "Middle",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-02T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_sessions", sessionList);
|
||||
await sessionsStore.loadSessions();
|
||||
const sorted = get(sessionsStore.sessions);
|
||||
expect(sorted[0].id).toBe("newest");
|
||||
expect(sorted[1].id).toBe("middle");
|
||||
expect(sorted[2].id).toBe("older");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
||||
@@ -128,12 +168,44 @@ describe("sessionsStore - searchSessions", () => {
|
||||
});
|
||||
|
||||
it("searches with the given query", async () => {
|
||||
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||
const results = [
|
||||
{
|
||||
id: "session-1",
|
||||
name: "Test",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("search_sessions", results);
|
||||
await sessionsStore.searchSessions("test");
|
||||
expect(get(sessionsStore.sessions)).toEqual(results);
|
||||
});
|
||||
|
||||
it("sorts search results by last_activity_at descending", async () => {
|
||||
const results = [
|
||||
{
|
||||
id: "older",
|
||||
name: "Older",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "newest",
|
||||
name: "Newest",
|
||||
message_count: 1,
|
||||
preview: "...",
|
||||
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("search_sessions", results);
|
||||
await sessionsStore.searchSessions("query");
|
||||
const sorted = get(sessionsStore.sessions);
|
||||
expect(sorted[0].id).toBe("newest");
|
||||
expect(sorted[1].id).toBe("older");
|
||||
});
|
||||
|
||||
it("updates searchQuery store", async () => {
|
||||
setMockInvokeResult("search_sessions", []);
|
||||
await sessionsStore.searchSessions("hello");
|
||||
@@ -187,6 +259,94 @@ describe("sessionsStore - saveConversation", () => {
|
||||
const conv = { ...makeConversation(), terminalLines: [] };
|
||||
await sessionsStore.saveConversation(conv as never);
|
||||
});
|
||||
|
||||
it("uses the most recent user message as the preview", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
|
||||
const conv = {
|
||||
...makeConversation(),
|
||||
terminalLines: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "First message",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Reply one",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "user",
|
||||
content: "Most recent prompt",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "assistant",
|
||||
content: "Reply two",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
await sessionsStore.saveConversation(conv as never);
|
||||
|
||||
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||
expect(capturedSession.preview).toBe("Most recent prompt");
|
||||
});
|
||||
|
||||
it("truncates long preview text at 150 characters", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
|
||||
const longContent = "A".repeat(200);
|
||||
const conv = {
|
||||
...makeConversation(),
|
||||
terminalLines: [
|
||||
{ id: "1", type: "user", content: longContent, timestamp: new Date(), toolName: undefined },
|
||||
],
|
||||
};
|
||||
await sessionsStore.saveConversation(conv as never);
|
||||
|
||||
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||
expect(capturedSession.preview).toBe("A".repeat(150) + "...");
|
||||
});
|
||||
|
||||
it("uses 'Empty conversation' as preview when there are no user messages", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
|
||||
const conv = {
|
||||
...makeConversation(),
|
||||
terminalLines: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Only assistant message",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
await sessionsStore.saveConversation(conv as never);
|
||||
|
||||
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||
expect(capturedSession.preview).toBe("Empty conversation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
||||
|
||||
+17
-11
@@ -378,7 +378,11 @@ function createSessionsStore() {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("list_sessions");
|
||||
sessions.set(result);
|
||||
sessions.set(
|
||||
result.sort(
|
||||
(a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} finally {
|
||||
@@ -395,15 +399,13 @@ function createSessionsStore() {
|
||||
tool_name: line.toolName,
|
||||
}));
|
||||
|
||||
const userAndAssistantMessages = conversation.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
const previewContent =
|
||||
userAndAssistantMessages
|
||||
.slice(0, 3)
|
||||
.map((m) => m.content)
|
||||
.join(" ")
|
||||
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
|
||||
const userMessages = conversation.terminalLines.filter((line) => line.type === "user");
|
||||
const mostRecentUserMessage = userMessages.at(-1);
|
||||
const previewContent = mostRecentUserMessage
|
||||
? mostRecentUserMessage.content.length > 150
|
||||
? mostRecentUserMessage.content.slice(0, 150) + "..."
|
||||
: mostRecentUserMessage.content
|
||||
: "Empty conversation";
|
||||
|
||||
const session: SavedSession = {
|
||||
id: conversation.id,
|
||||
@@ -458,7 +460,11 @@ function createSessionsStore() {
|
||||
const result = await invoke<SessionListItem[]>("search_sessions", {
|
||||
query,
|
||||
});
|
||||
sessions.set(result);
|
||||
sessions.set(
|
||||
result.sort(
|
||||
(a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to search sessions:", error);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { getAchievementRarity, getRarityColour, toastStore } from "./toasts";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
// ---
|
||||
|
||||
describe("getAchievementRarity", () => {
|
||||
describe("legendary tier", () => {
|
||||
it("classifies TokenMaster as legendary", () => {
|
||||
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("epic tier", () => {
|
||||
it("classifies CodeMachine as epic", () => {
|
||||
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
||||
});
|
||||
|
||||
it("classifies Unstoppable as epic", () => {
|
||||
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rare tier", () => {
|
||||
it("classifies BlossomingCoder as rare", () => {
|
||||
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies CodeWizard as rare", () => {
|
||||
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies MasterBuilder as rare", () => {
|
||||
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies EnduranceChamp as rare", () => {
|
||||
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies DeepDive as rare", () => {
|
||||
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
||||
});
|
||||
|
||||
it("classifies CreativeCoder as rare", () => {
|
||||
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
||||
});
|
||||
});
|
||||
|
||||
describe("common tier", () => {
|
||||
it("classifies unknown IDs as common", () => {
|
||||
expect(getAchievementRarity("FirstChat")).toBe("common");
|
||||
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
||||
expect(getAchievementRarity("")).toBe("common");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRarityColour", () => {
|
||||
it("returns yellow-to-orange gradient for legendary", () => {
|
||||
expect(getRarityColour("legendary")).toBe("from-yellow-400 to-orange-500");
|
||||
});
|
||||
|
||||
it("returns purple-to-pink gradient for epic", () => {
|
||||
expect(getRarityColour("epic")).toBe("from-purple-400 to-pink-500");
|
||||
});
|
||||
|
||||
it("returns blue-to-indigo gradient for rare", () => {
|
||||
expect(getRarityColour("rare")).toBe("from-blue-400 to-indigo-500");
|
||||
});
|
||||
|
||||
it("returns green-to-emerald gradient for common", () => {
|
||||
expect(getRarityColour("common")).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
|
||||
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
||||
expect(getRarityColour("mythic")).toBe("from-green-400 to-emerald-500");
|
||||
expect(getRarityColour("")).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
|
||||
describe("end-to-end rarity pipeline", () => {
|
||||
it("produces the correct colour for a legendary achievement", () => {
|
||||
const colour = getRarityColour(getAchievementRarity("TokenMaster"));
|
||||
expect(colour).toBe("from-yellow-400 to-orange-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for an epic achievement", () => {
|
||||
const colour = getRarityColour(getAchievementRarity("CodeMachine"));
|
||||
expect(colour).toBe("from-purple-400 to-pink-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for a rare achievement", () => {
|
||||
const colour = getRarityColour(getAchievementRarity("CodeWizard"));
|
||||
expect(colour).toBe("from-blue-400 to-indigo-500");
|
||||
});
|
||||
|
||||
it("produces the correct colour for a common achievement", () => {
|
||||
const colour = getRarityColour(getAchievementRarity("FirstChat"));
|
||||
expect(colour).toBe("from-green-400 to-emerald-500");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---
|
||||
|
||||
describe("toastStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Clear all toasts before each test
|
||||
const current = get(toastStore);
|
||||
for (const toast of current) {
|
||||
toastStore.remove(toast.id);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("addInfo", () => {
|
||||
it("adds an info toast with the correct fields", () => {
|
||||
toastStore.addInfo("Hello world", "🌍");
|
||||
const toasts = get(toastStore);
|
||||
expect(toasts).toHaveLength(1);
|
||||
const toast = toasts[0];
|
||||
expect(toast.kind).toBe("info");
|
||||
if (toast.kind === "info") {
|
||||
expect(toast.message).toBe("Hello world");
|
||||
expect(toast.icon).toBe("🌍");
|
||||
expect(typeof toast.id).toBe("string");
|
||||
expect(toast.id.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a default icon when none is provided", () => {
|
||||
toastStore.addInfo("Default icon test");
|
||||
const toasts = get(toastStore);
|
||||
const toast = toasts[0];
|
||||
if (toast.kind === "info") {
|
||||
expect(toast.icon).toBe("ℹ️");
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-dismisses after 4000ms", () => {
|
||||
toastStore.addInfo("Auto-dismiss test");
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(3999);
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(get(toastStore)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAchievement", () => {
|
||||
const mockAchievement: AchievementUnlockedEvent["achievement"] = {
|
||||
id: "FirstMessage",
|
||||
name: "First Message",
|
||||
description: "Sent your first message",
|
||||
icon: "💬",
|
||||
unlocked_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
it("adds an achievement toast with the correct fields", () => {
|
||||
toastStore.addAchievement(mockAchievement);
|
||||
const toasts = get(toastStore);
|
||||
expect(toasts).toHaveLength(1);
|
||||
const toast = toasts[0];
|
||||
expect(toast.kind).toBe("achievement");
|
||||
if (toast.kind === "achievement") {
|
||||
expect(toast.achievement).toEqual(mockAchievement);
|
||||
expect(typeof toast.id).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-dismisses after 5000ms", () => {
|
||||
toastStore.addAchievement(mockAchievement);
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(get(toastStore)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addUpdate", () => {
|
||||
it("adds a persistent update toast with the correct fields", () => {
|
||||
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
|
||||
const toasts = get(toastStore);
|
||||
expect(toasts).toHaveLength(1);
|
||||
const toast = toasts[0];
|
||||
expect(toast.kind).toBe("update");
|
||||
if (toast.kind === "update") {
|
||||
expect(toast.latestVersion).toBe("2.0.0");
|
||||
expect(toast.currentVersion).toBe("1.9.0");
|
||||
expect(toast.releaseUrl).toBe("https://example.com/release");
|
||||
expect(typeof toast.id).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-dismiss after a long time", () => {
|
||||
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("removes a toast by id", () => {
|
||||
toastStore.addInfo("To be removed");
|
||||
const toasts = get(toastStore);
|
||||
expect(toasts).toHaveLength(1);
|
||||
const id = toasts[0].id;
|
||||
|
||||
toastStore.remove(id);
|
||||
expect(get(toastStore)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not affect other toasts when removing by id", () => {
|
||||
toastStore.addInfo("First toast");
|
||||
toastStore.addInfo("Second toast");
|
||||
const toasts = get(toastStore);
|
||||
expect(toasts).toHaveLength(2);
|
||||
|
||||
toastStore.remove(toasts[0].id);
|
||||
const remaining = get(toastStore);
|
||||
expect(remaining).toHaveLength(1);
|
||||
if (remaining[0].kind === "info") {
|
||||
expect(remaining[0].message).toBe("Second toast");
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op when the id does not exist", () => {
|
||||
toastStore.addInfo("Existing toast");
|
||||
toastStore.remove("non-existent-id");
|
||||
expect(get(toastStore)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
export interface InfoToast {
|
||||
id: string;
|
||||
kind: "info";
|
||||
message: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface AchievementToast {
|
||||
id: string;
|
||||
kind: "achievement";
|
||||
achievement: AchievementUnlockedEvent["achievement"];
|
||||
}
|
||||
|
||||
export interface UpdateToast {
|
||||
id: string;
|
||||
kind: "update";
|
||||
latestVersion: string;
|
||||
currentVersion: string;
|
||||
releaseUrl: string;
|
||||
}
|
||||
|
||||
export type Toast = InfoToast | AchievementToast | UpdateToast;
|
||||
|
||||
export function getAchievementRarity(id: string): string {
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
|
||||
export function getRarityColour(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
return "from-purple-400 to-pink-500";
|
||||
case "rare":
|
||||
return "from-blue-400 to-indigo-500";
|
||||
default:
|
||||
return "from-green-400 to-emerald-500";
|
||||
}
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function remove(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function addInfo(message: string, icon = "ℹ️") {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: InfoToast = { id, kind: "info", message, icon };
|
||||
update((toasts) => [...toasts, toast]);
|
||||
setTimeout(() => remove(id), 4000);
|
||||
}
|
||||
|
||||
function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: AchievementToast = { id, kind: "achievement", achievement };
|
||||
update((toasts) => [...toasts, toast]);
|
||||
setTimeout(() => remove(id), 5000);
|
||||
}
|
||||
|
||||
function addUpdate(latestVersion: string, currentVersion: string, releaseUrl: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: UpdateToast = { id, kind: "update", latestVersion, currentVersion, releaseUrl };
|
||||
update((toasts) => [...toasts, toast]);
|
||||
// Update toasts are persistent — no auto-dismiss
|
||||
}
|
||||
|
||||
return { subscribe, addInfo, addAchievement, addUpdate, remove };
|
||||
}
|
||||
|
||||
export const toastStore = createToastStore();
|
||||
Reference in New Issue
Block a user