diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 3f80d3e..c91d967 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -92,10 +92,11 @@ describe("slashCommands", () => { expect(commandNames).toContain("simplify"); expect(commandNames).toContain("loop"); expect(commandNames).toContain("batch"); + expect(commandNames).toContain("memory"); }); - it("has 10 commands total", () => { - expect(slashCommands.length).toBe(10); + it("has 11 commands total", () => { + expect(slashCommands.length).toBe(11); }); it("each command has required properties", () => { diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index e5597a7..e5cc471 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -281,6 +281,20 @@ export const slashCommands: SlashCommand[] = [ await invoke("send_prompt", { conversationId, message }); }, }, + { + name: "memory", + description: "View and manage auto-memory (Claude Code built-in)", + usage: "/memory", + source: "cli", + execute: async () => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + await invoke("send_prompt", { conversationId, message: "/memory" }); + }, + }, { name: "skill", description: "Invoke a Claude Code skill from ~/.claude/skills/", diff --git a/src/lib/components/AchievementNotification.svelte b/src/lib/components/AchievementNotification.svelte deleted file mode 100644 index 79e3751..0000000 --- a/src/lib/components/AchievementNotification.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - -{#if showNotification && currentAchievement} -
- -
- -
- - -
- - -
- -
-
{currentAchievement.achievement.icon}
- - -
-
- ✨ -
-
- ✨ -
-
- - -
-

- Achievement Unlocked! -

-

- {currentAchievement.achievement.name} -

-

- {currentAchievement.achievement.description} -

- - -
- - {getAchievementRarity(currentAchievement.achievement.id)} - -
-
-
- - -
- {#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)} -
- {/each} -
-
-
-
-{/if} - - diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts deleted file mode 100644 index ed971f2..0000000 --- a/src/lib/components/AchievementNotification.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * AchievementNotification Component Tests - * - * Tests the rarity classification and colour mapping logic used by the - * AchievementNotification component. - * - * What this component does: - * - Listens for "achievement:unlocked" Tauri events - * - Queues and displays achievement notifications one at a time - * - Each notification shows the achievement's name, icon, description, and rarity - * - A gradient border and badge colour correspond to the achievement's rarity - * - * Manual testing checklist: - * - [ ] Achievement notification slides in from the right - * - [ ] Notification auto-dismisses after 5 seconds - * - [ ] Dismiss button works immediately - * - [ ] Multiple achievements queue and display sequentially - * - [ ] Legendary achievements have a yellow-orange gradient - * - [ ] Epic achievements have a purple-pink gradient - * - [ ] Rare achievements have a blue-indigo gradient - * - [ ] Common achievements have a green-emerald gradient - */ - -import { describe, it, expect } from "vitest"; - -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"; -} - -function getRarityColor(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"; - } -} - -// --- - -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("getRarityColor", () => { - it("returns yellow-to-orange gradient for legendary", () => { - expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500"); - }); - - it("returns purple-to-pink gradient for epic", () => { - expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500"); - }); - - it("returns blue-to-indigo gradient for rare", () => { - expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500"); - }); - - it("returns green-to-emerald gradient for common", () => { - expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500"); - }); - - it("falls back to green-to-emerald gradient for unknown rarities", () => { - expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500"); - expect(getRarityColor("")).toBe("from-green-400 to-emerald-500"); - }); - - describe("end-to-end rarity pipeline", () => { - it("produces the correct colour for a legendary achievement", () => { - const color = getRarityColor(getAchievementRarity("TokenMaster")); - expect(color).toBe("from-yellow-400 to-orange-500"); - }); - - it("produces the correct colour for an epic achievement", () => { - const color = getRarityColor(getAchievementRarity("CodeMachine")); - expect(color).toBe("from-purple-400 to-pink-500"); - }); - - it("produces the correct colour for a rare achievement", () => { - const color = getRarityColor(getAchievementRarity("CodeWizard")); - expect(color).toBe("from-blue-400 to-indigo-500"); - }); - - it("produces the correct colour for a common achievement", () => { - const color = getRarityColor(getAchievementRarity("FirstChat")); - expect(color).toBe("from-green-400 to-emerald-500"); - }); - }); -}); diff --git a/src/lib/components/MemoryBrowserPanel.svelte b/src/lib/components/MemoryBrowserPanel.svelte index e41c461..72d1bb3 100644 --- a/src/lib/components/MemoryBrowserPanel.svelte +++ b/src/lib/components/MemoryBrowserPanel.svelte @@ -1,8 +1,16 @@ - - -{#if isPanelOpen} +{#if isOpen}
@@ -108,22 +98,56 @@

Memory Files

- +
+ + + +
@@ -230,34 +254,6 @@ {/if} diff --git a/src/lib/components/UpdateNotification.svelte b/src/lib/components/UpdateNotification.svelte deleted file mode 100644 index 4b75c19..0000000 --- a/src/lib/components/UpdateNotification.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -{#if updateInfo && !dismissed} -
-
-
🎉
-
-

Update Available!

-

- A new version of Hikari Desktop is available: - {updateInfo.latest_version} -

-

- Current version: {updateInfo.current_version} -

-
- - -
-
- -
-
-{/if} diff --git a/src/lib/stores/toasts.test.ts b/src/lib/stores/toasts.test.ts new file mode 100644 index 0000000..113baed --- /dev/null +++ b/src/lib/stores/toasts.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts new file mode 100644 index 0000000..897d357 --- /dev/null +++ b/src/lib/stores/toasts.ts @@ -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([]); + + 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(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index f37ef38..dc7506a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -23,6 +23,7 @@ import { handleNewUserMessage, } from "$lib/notifications/rules"; import { notificationManager } from "$lib/notifications/notificationManager"; +import { toastStore } from "$lib/stores/toasts"; interface StateChangePayload { state: CharacterState; @@ -431,6 +432,16 @@ export async function initializeTauriListeners() { parent_tool_use_id ); } + + // Detect auto-memory updates — tool writes to ~/.claude/ markdown files + if ( + line_type === "tool" && + content && + content.includes("/.claude/") && + content.includes(".md") + ) { + toastStore.addInfo("Auto-memory updated", "🧠"); + } }); unlisteners.push(outputUnlisten); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f07e2dc..5aae88d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -37,11 +37,11 @@ import PermissionModal from "$lib/components/PermissionModal.svelte"; import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; - import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; - import UpdateNotification from "$lib/components/UpdateNotification.svelte"; + import ToastContainer from "$lib/components/ToastContainer.svelte"; import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte"; - import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte"; + import type { UpdateInfo } from "$lib/types/messages"; + import { toastStore } from "$lib/stores/toasts"; import { debugConsoleStore } from "$lib/stores/debugConsole"; import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos"; @@ -85,7 +85,6 @@ } let initialized = false; - let updateNotification: UpdateNotification | undefined = $state(undefined); let achievementPanelOpen = $state(false); let currentCharacterState: CharacterState = $state("idle"); let compactModeActive = $state(false); @@ -336,6 +335,19 @@ } } + async function checkForUpdates() { + const config = configStore.getConfig(); + if (!config.update_checks_enabled) return; + try { + const info = await invoke("check_for_updates"); + if (info.has_update) { + toastStore.addUpdate(info.latest_version, info.current_version, info.release_url); + } + } catch (err) { + console.error("Failed to check for updates:", err); + } + } + async function handleInterrupt() { try { const conversationId = get(claudeStore.activeConversationId); @@ -483,9 +495,7 @@ window.addEventListener("keydown", handleGlobalKeydown); // Check for updates on startup - if (config.update_checks_enabled) { - updateNotification?.checkForUpdates(); - } + await checkForUpdates(); // Apply compact mode if saved (resize window) if (config.compact_mode) { @@ -584,13 +594,11 @@ - - (achievementPanelOpen = false)} /> - +