@@ -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)}
/>
-
+