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("simplify");
|
||||||
expect(commandNames).toContain("loop");
|
expect(commandNames).toContain("loop");
|
||||||
expect(commandNames).toContain("batch");
|
expect(commandNames).toContain("batch");
|
||||||
|
expect(commandNames).toContain("memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has 10 commands total", () => {
|
it("has 11 commands total", () => {
|
||||||
expect(slashCommands.length).toBe(10);
|
expect(slashCommands.length).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each command has required properties", () => {
|
it("each command has required properties", () => {
|
||||||
|
|||||||
@@ -281,6 +281,20 @@ export const slashCommands: SlashCommand[] = [
|
|||||||
await invoke("send_prompt", { conversationId, message });
|
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",
|
name: "skill",
|
||||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
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">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
interface MemoryFileInfo {
|
||||||
path: string;
|
path: string;
|
||||||
heading: string | null;
|
heading: string | null;
|
||||||
@@ -17,7 +25,6 @@
|
|||||||
let fileContent: string = $state("");
|
let fileContent: string = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let isPanelOpen = $state(false);
|
|
||||||
|
|
||||||
async function loadMemoryFiles() {
|
async function loadMemoryFiles() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -58,37 +65,20 @@
|
|||||||
return file.heading ?? getFileName(file.path);
|
return file.heading ?? getFileName(file.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel() {
|
async function sendMemoryCommand() {
|
||||||
isPanelOpen = !isPanelOpen;
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
if (isPanelOpen && memoryFiles.length === 0) {
|
if (!conversationId) return;
|
||||||
loadMemoryFiles();
|
await invoke("send_prompt", { conversationId, message: "/memory" });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
// Don't load on mount - only when panel is opened
|
if (isOpen && memoryFiles.length === 0) {
|
||||||
|
loadMemoryFiles();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
{#if isOpen}
|
||||||
<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}
|
|
||||||
<div class="memory-panel">
|
<div class="memory-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
@@ -108,22 +98,56 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h3>Memory Files</h3>
|
<h3>Memory Files</h3>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
<div class="header-actions">
|
||||||
<svg
|
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
|
||||||
class="close-icon"
|
<svg
|
||||||
fill="none"
|
class="action-icon"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 24 24"
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
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>
|
</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>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
@@ -230,34 +254,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
.memory-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -300,6 +296,32 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.close-btn {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||||
import WorkflowPanel from "./WorkflowPanel.svelte";
|
import WorkflowPanel from "./WorkflowPanel.svelte";
|
||||||
|
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
|
||||||
import { injectTextStore } from "$lib/stores/projectContext";
|
import { injectTextStore } from "$lib/stores/projectContext";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
let showChangelog = $state(false);
|
let showChangelog = $state(false);
|
||||||
let showTaskLoop = $state(false);
|
let showTaskLoop = $state(false);
|
||||||
let showWorkflowPanel = $state(false);
|
let showWorkflowPanel = $state(false);
|
||||||
|
let showMemoryPanel = $state(false);
|
||||||
|
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
@@ -176,6 +178,19 @@
|
|||||||
<span>Session History</span>
|
<span>Session History</span>
|
||||||
</button>
|
</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 -->
|
<!-- To-Do List -->
|
||||||
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
|
<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">
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -547,6 +562,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showMemoryPanel}
|
||||||
|
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => (showMemoryPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showWorkflowPanel}
|
{#if showWorkflowPanel}
|
||||||
<WorkflowPanel
|
<WorkflowPanel
|
||||||
onClose={() => (showWorkflowPanel = false)}
|
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,
|
handleNewUserMessage,
|
||||||
} from "$lib/notifications/rules";
|
} from "$lib/notifications/rules";
|
||||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||||
|
import { toastStore } from "$lib/stores/toasts";
|
||||||
|
|
||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
@@ -431,6 +432,16 @@ export async function initializeTauriListeners() {
|
|||||||
parent_tool_use_id
|
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);
|
unlisteners.push(outputUnlisten);
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -37,11 +37,11 @@
|
|||||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||||
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
|
||||||
import AchievementsPanel from "$lib/components/AchievementsPanel.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 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 { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||||
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
||||||
|
|
||||||
@@ -85,7 +85,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
|
||||||
let achievementPanelOpen = $state(false);
|
let achievementPanelOpen = $state(false);
|
||||||
let currentCharacterState: CharacterState = $state("idle");
|
let currentCharacterState: CharacterState = $state("idle");
|
||||||
let compactModeActive = $state(false);
|
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() {
|
async function handleInterrupt() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -483,9 +495,7 @@
|
|||||||
window.addEventListener("keydown", handleGlobalKeydown);
|
window.addEventListener("keydown", handleGlobalKeydown);
|
||||||
|
|
||||||
// Check for updates on startup
|
// Check for updates on startup
|
||||||
if (config.update_checks_enabled) {
|
await checkForUpdates();
|
||||||
updateNotification?.checkForUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply compact mode if saved (resize window)
|
// Apply compact mode if saved (resize window)
|
||||||
if (config.compact_mode) {
|
if (config.compact_mode) {
|
||||||
@@ -584,13 +594,11 @@
|
|||||||
<PermissionModal />
|
<PermissionModal />
|
||||||
<UserQuestionModal />
|
<UserQuestionModal />
|
||||||
<ConfigSidebar />
|
<ConfigSidebar />
|
||||||
<MemoryBrowserPanel />
|
|
||||||
<AchievementNotification />
|
|
||||||
<AchievementsPanel
|
<AchievementsPanel
|
||||||
bind:isOpen={achievementPanelOpen}
|
bind:isOpen={achievementPanelOpen}
|
||||||
onClose={() => (achievementPanelOpen = false)}
|
onClose={() => (achievementPanelOpen = false)}
|
||||||
/>
|
/>
|
||||||
<UpdateNotification bind:this={updateNotification} />
|
<ToastContainer />
|
||||||
<CloseAppConfirmModal
|
<CloseAppConfirmModal
|
||||||
isOpen={closeConfirmModalOpen}
|
isOpen={closeConfirmModalOpen}
|
||||||
{hasActiveConversation}
|
{hasActiveConversation}
|
||||||
|
|||||||
Reference in New Issue
Block a user