feat: add auto-memory panel, /memory command, and unified toast system

- Add /memory CLI built-in slash command
- Refactor MemoryBrowserPanel to accept isOpen/onClose props
- Add Send /memory and Refresh buttons to MemoryBrowserPanel header
- Add Memory Manager entry to NavMenu
- Create unified ToastContainer replacing AchievementNotification and
  UpdateNotification with a single stacked toast system
- Add toasts store with info (4s), achievement (5s), and persistent
  update toast types
- Move getAchievementRarity and getRarityColour helpers to toasts store
- Detect auto-memory writes in tauri.ts output listener and fire toast
- Remove action buttons from update toast; version is now a direct link

Closes #212
This commit is contained in:
2026-03-12 22:45:03 -07:00
committed by Naomi Carrigan
parent 02c8d6c990
commit 8f278da304
12 changed files with 691 additions and 528 deletions
+3 -2
View File
@@ -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", () => {
+14
View File
@@ -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/",
@@ -1,202 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
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";
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
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";
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" 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"
></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
@@ -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");
});
});
});
+94 -72
View File
@@ -1,8 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import Markdown from "./Markdown.svelte";
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
interface MemoryFileInfo {
path: string;
heading: string | null;
@@ -17,7 +25,6 @@
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
async function loadMemoryFiles() {
isLoading = true;
@@ -58,37 +65,20 @@
return file.heading ?? getFileName(file.path);
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
async function sendMemoryCommand() {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("send_prompt", { conversationId, message: "/memory" });
}
onMount(() => {
// Don't load on mount - only when panel is opened
$effect(() => {
if (isOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
});
</script>
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span class="label">Memory</span>
</button>
{#if isPanelOpen}
{#if isOpen}
<div class="memory-panel">
<div class="panel-header">
<div class="header-title">
@@ -108,22 +98,56 @@
</svg>
<h3>Memory Files</h3>
</div>
<button class="close-btn" onclick={togglePanel} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div class="header-actions">
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
<svg
class="action-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button onclick={loadMemoryFiles} class="action-btn" title="Refresh">
<svg
class="action-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button class="close-btn" onclick={onClose} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="panel-content">
@@ -230,34 +254,6 @@
{/if}
<style>
.memory-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.memory-toggle:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.memory-panel {
position: fixed;
top: 0;
@@ -300,6 +296,32 @@
color: var(--text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.action-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.action-icon {
width: 1.25rem;
height: 1.25rem;
}
.close-btn {
padding: 0.5rem;
background: transparent;
+19
View File
@@ -24,6 +24,7 @@
import ChangelogPanel from "./ChangelogPanel.svelte";
import TaskLoopPanel from "./TaskLoopPanel.svelte";
import WorkflowPanel from "./WorkflowPanel.svelte";
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -69,6 +70,7 @@
let showChangelog = $state(false);
let showTaskLoop = $state(false);
let showWorkflowPanel = $state(false);
let showMemoryPanel = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
@@ -176,6 +178,19 @@
<span>Session History</span>
</button>
<!-- Memory Manager -->
<button onclick={menuAction(() => (showMemoryPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span>Memory Manager</span>
</button>
<!-- To-Do List -->
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -547,6 +562,10 @@
/>
{/if}
{#if showMemoryPanel}
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => (showMemoryPanel = false)} />
{/if}
{#if showWorkflowPanel}
<WorkflowPanel
onClose={() => (showWorkflowPanel = false)}
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toastStore, getAchievementRarity, getRarityColour } from "$lib/stores/toasts";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
const toasts = toastStore;
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
toastStore.addAchievement(event.payload.achievement);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
</script>
<div class="fixed top-20 right-4 z-50 flex flex-col gap-3 items-end">
{#each $toasts as toast (toast.id)}
<div in:fly={{ x: 300, duration: 500, easing: cubicOut }} out:fade={{ duration: 300 }}>
{#if toast.kind === "info"}
<!-- Info toast -->
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 shadow-lg flex items-center gap-2 max-w-sm"
>
<span class="text-xl shrink-0">{toast.icon}</span>
<span class="text-sm text-[var(--text-primary)] flex-1">{toast.message}</span>
<button
onclick={() => toastStore.remove(toast.id)}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors shrink-0"
aria-label="Dismiss"
>
<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>
{:else if toast.kind === "achievement"}
{@const rarity = getAchievementRarity(toast.achievement.id)}
{@const colour = getRarityColour(rarity)}
<!-- Achievement toast -->
<div class="relative p-[2px] rounded-lg overflow-hidden max-w-sm">
<!-- Animated gradient border -->
<div class="absolute inset-0 bg-gradient-to-r {colour} animate-pulse"></div>
<!-- Main content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={() => toastStore.remove(toast.id)}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" 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 class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{toast.achievement.icon}</div>
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div
class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"
>
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{toast.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{toast.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {colour} text-white capitalize"
>
{rarity}
</span>
</div>
</div>
</div>
<!-- Confetti particles -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {colour} rounded-full animate-fall"
style="left: {(confettiIndex * 11) % 100}%; animation-delay: {(confettiIndex *
0.3) %
2}s; animation-duration: {2 + ((confettiIndex * 0.25) % 2)}s;"
></div>
{/each}
</div>
</div>
</div>
{:else if toast.kind === "update"}
<!-- Update toast -->
<div
class="bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg p-4 shadow-lg max-w-sm"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<button
onclick={() => openUrl(toast.releaseUrl)}
class="text-[var(--accent-primary)] font-mono hover:underline text-sm"
>
{toast.latestVersion}
</button>
<p class="text-[var(--text-muted)] text-xs mt-1">
Current version: {toast.currentVersion}
</p>
</div>
<button
onclick={() => toastStore.remove(toast.id)}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors shrink-0"
aria-label="Dismiss"
>
<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>
</div>
{/if}
</div>
{/each}
</div>
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
@@ -1,89 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "$lib/types/messages";
import { configStore } from "$lib/stores/config";
let updateInfo = $state<UpdateInfo | null>(null);
let dismissed = $state(false);
export async function checkForUpdates() {
// Check if update checks are enabled
const config = configStore.getConfig();
if (!config.update_checks_enabled) {
return;
}
try {
const info = await invoke<UpdateInfo>("check_for_updates");
if (info.has_update) {
updateInfo = info;
dismissed = false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Failed to check for updates:", errorMessage);
}
}
function dismiss() {
dismissed = true;
}
async function openRelease() {
if (updateInfo?.release_url) {
await openUrl(updateInfo.release_url);
}
}
</script>
{#if updateInfo && !dismissed}
<div
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<p class="text-[var(--text-secondary)] text-sm mb-2">
A new version of Hikari Desktop is available:
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
</p>
<p class="text-[var(--text-muted)] text-xs mb-3">
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
View Release
</button>
<button
onclick={dismiss}
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
>
Later
</button>
</div>
</div>
<button
onclick={dismiss}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
{/if}
+245
View File
@@ -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);
});
});
});
+88
View File
@@ -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();
+11
View File
@@ -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);
+18 -10
View File
@@ -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<UpdateInfo>("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 @@
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
<ToastContainer />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}