generated from nhcarrigan/template
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:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user