generated from nhcarrigan/template
Compare commits
11 Commits
v1.8.0
..
7e45c685d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e45c685d3
|
|||
|
514e137590
|
|||
|
fd3122e080
|
|||
|
66c65a6ab8
|
|||
| 19e28b7ec7 | |||
| 08f7ca2d55 | |||
| c5d1df351c | |||
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 |
@@ -20,14 +20,21 @@ When working with issues, pull requests, or other repository operations for this
|
||||
When asked to commit changes for this project:
|
||||
|
||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
|
||||
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||
- **Always ask for confirmation** before committing
|
||||
- **Always ask for confirmation** before pushing
|
||||
|
||||
Example commit command:
|
||||
|
||||
```bash
|
||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
|
||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
||||
```
|
||||
|
||||
Example push command:
|
||||
|
||||
```bash
|
||||
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -31,6 +31,9 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -126,6 +129,9 @@ pub struct HikariConfig {
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub trusted_workspaces: Vec<String>,
|
||||
|
||||
@@ -135,6 +141,9 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_background_image_opacity")]
|
||||
pub background_image_opacity: f32,
|
||||
|
||||
#[serde(default)]
|
||||
pub show_thinking_blocks: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -169,9 +178,11 @@ impl Default for HikariConfig {
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: None,
|
||||
trusted_workspaces: Vec::new(),
|
||||
background_image_path: None,
|
||||
background_image_opacity: 0.3,
|
||||
show_thinking_blocks: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,6 +297,7 @@ mod tests {
|
||||
assert!(!config.use_worktree);
|
||||
assert!(!config.disable_1m_context);
|
||||
assert!(config.trusted_workspaces.is_empty());
|
||||
assert!(!config.show_thinking_blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,9 +332,11 @@ mod tests {
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: true,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: Some(32000),
|
||||
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||
background_image_opacity: 0.25,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
||||
const DRAFTS_STORE_KEY: &str = "drafts";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub saved_at: String,
|
||||
}
|
||||
|
||||
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(DRAFTS_STORE_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
||||
store.set(DRAFTS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
|
||||
let draft = Draft {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content,
|
||||
saved_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
drafts.push(draft.clone());
|
||||
save_all_drafts(&app, &drafts)?;
|
||||
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
drafts.retain(|d| d.id != draft_id);
|
||||
save_all_drafts(&app, &drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
||||
save_all_drafts(&app, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
||||
Draft {
|
||||
id: id.to_string(),
|
||||
content: content.to_string(),
|
||||
saved_at: saved_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_serialization() {
|
||||
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
||||
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
||||
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, draft.id);
|
||||
assert_eq!(parsed.content, draft.content);
|
||||
assert_eq!(parsed.saved_at, draft.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_clone() {
|
||||
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.content, cloned.content);
|
||||
assert_eq!(original.saved_at, cloned.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_newest_first() {
|
||||
let mut drafts = [
|
||||
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
||||
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
|
||||
assert_eq!(drafts[0].id, "b");
|
||||
assert_eq!(drafts[1].id, "c");
|
||||
assert_eq!(drafts[2].id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retain_excludes_deleted() {
|
||||
let mut drafts = vec![
|
||||
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let target_id = "delete-me".to_string();
|
||||
drafts.retain(|d| d.id != target_id);
|
||||
|
||||
assert_eq!(drafts.len(), 2);
|
||||
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_id() {
|
||||
let drafts = [
|
||||
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let found = drafts.iter().find(|d| d.id == "draft-2");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().content, "Second draft");
|
||||
|
||||
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiline_content() {
|
||||
let content = "Line 1\nLine 2\nLine 3";
|
||||
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
||||
|
||||
assert!(draft.content.contains('\n'));
|
||||
assert_eq!(draft.content.split('\n').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_after_delete_all() {
|
||||
let mut drafts = vec![
|
||||
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.clear();
|
||||
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uuid_format() {
|
||||
// UUIDs should be non-empty and contain hyphens
|
||||
let id = Uuid::new_v4().to_string();
|
||||
assert!(!id.is_empty());
|
||||
assert!(id.contains('-'));
|
||||
assert_eq!(id.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_is_rfc3339() {
|
||||
let ts = Utc::now().to_rfc3339();
|
||||
// RFC 3339 timestamps contain T and + or Z
|
||||
assert!(ts.contains('T'));
|
||||
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod config;
|
||||
mod cost_tracking;
|
||||
mod debug_logger;
|
||||
mod discord_rpc;
|
||||
mod drafts;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod process_ext;
|
||||
@@ -28,6 +29,7 @@ use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use debug_logger::TauriLogLayer;
|
||||
use discord_rpc::DiscordRpcManager;
|
||||
use drafts::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
@@ -214,6 +216,10 @@ pub fn run() {
|
||||
remove_mcp_server,
|
||||
add_mcp_server,
|
||||
get_mcp_server_details,
|
||||
list_drafts,
|
||||
save_draft,
|
||||
delete_draft,
|
||||
delete_all_drafts,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -111,6 +111,9 @@ pub struct WslBridge {
|
||||
conversation_id: Option<String>,
|
||||
/// Set to true once the `system:init` message arrives, false at the start of every new session.
|
||||
received_init: Arc<AtomicBool>,
|
||||
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
|
||||
/// the disconnect was intentional and should not emit a second Disconnected event.
|
||||
intentional_stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -124,6 +127,7 @@ impl WslBridge {
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +141,7 @@ impl WslBridge {
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: Some(conversation_id),
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +296,11 @@ impl WslBridge {
|
||||
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
|
||||
}
|
||||
|
||||
// Set max output tokens if specified
|
||||
if let Some(max_tokens) = options.max_output_tokens {
|
||||
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
|
||||
}
|
||||
|
||||
cmd
|
||||
} else {
|
||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||
@@ -338,6 +348,11 @@ impl WslBridge {
|
||||
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
|
||||
}
|
||||
|
||||
// Set max output tokens if specified
|
||||
if let Some(max_tokens) = options.max_output_tokens {
|
||||
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
|
||||
}
|
||||
|
||||
claude_cmd.push_str(
|
||||
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||
);
|
||||
@@ -406,8 +421,9 @@ impl WslBridge {
|
||||
self.stdin = stdin;
|
||||
*self.process.lock() = Some(child);
|
||||
|
||||
// Reset the init flag so the watchdog and stdout handler start fresh.
|
||||
// Reset flags so the watchdog and stdout handler start fresh.
|
||||
self.received_init.store(false, Ordering::SeqCst);
|
||||
self.intentional_stop.store(false, Ordering::SeqCst);
|
||||
|
||||
// Note: We no longer reset stats here - stats persist across reconnects
|
||||
// Stats are only reset when explicitly disconnecting via stop()
|
||||
@@ -425,8 +441,16 @@ impl WslBridge {
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let received_init_clone = self.received_init.clone();
|
||||
let intentional_stop_clone = self.intentional_stop.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
|
||||
handle_stdout(
|
||||
stdout,
|
||||
app_clone,
|
||||
stats_clone,
|
||||
conv_id,
|
||||
received_init_clone,
|
||||
intentional_stop_clone,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,6 +567,11 @@ impl WslBridge {
|
||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||
// Extract the process first so the MutexGuard is dropped before we mutably
|
||||
// borrow `self` again via estimate_interrupted_request_cost.
|
||||
|
||||
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||
// a second Disconnected event after stdout closes due to the kill.
|
||||
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||
|
||||
let maybe_process = self.process.lock().take();
|
||||
if let Some(mut process) = maybe_process {
|
||||
// Estimate cost for interrupted request before killing
|
||||
@@ -674,6 +703,9 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, app: &AppHandle) {
|
||||
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||
// a second Disconnected event after stdout closes due to the kill.
|
||||
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||
if let Some(mut process) = self.process.lock().take() {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
@@ -729,6 +761,7 @@ fn handle_stdout(
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
received_init: Arc<AtomicBool>,
|
||||
intentional_stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
@@ -749,6 +782,12 @@ fn handle_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
// If this was an intentional stop (stop()/interrupt() was called), the caller already
|
||||
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
|
||||
if intentional_stop.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If stdout closed before system:init arrived the process exited without initialising.
|
||||
// Emit an error line so the user understands why the connection failed.
|
||||
if !received_init.load(Ordering::SeqCst) {
|
||||
@@ -765,6 +804,23 @@ fn handle_stdout(
|
||||
);
|
||||
}
|
||||
|
||||
// If Claude exited while a prompt was in-flight, the user's message was never processed.
|
||||
// Emit a specific error so they know to resend their prompt.
|
||||
let had_pending_request = stats.read().current_request_input.is_some();
|
||||
if had_pending_request {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
max_output_tokens: config.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,6 +140,7 @@ async function startNewConversation(): Promise<void> {
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
max_output_tokens: config.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* CliVersion Component Tests
|
||||
*
|
||||
* Tests the version comparison logic used by the CliVersion component,
|
||||
* which compares the installed CLI version against the supported version.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays the installed Claude CLI version
|
||||
* - Displays the highest audited/supported CLI version
|
||||
* - Shows a warning when the installed version is ahead of or behind supported
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Installed version is fetched and displayed on mount
|
||||
* - [ ] "current" badge shows in green when versions match
|
||||
* - [ ] "ahead" badge shows in amber when installed is newer than supported
|
||||
* - [ ] "behind" badge shows in red when installed is older than supported
|
||||
* - [ ] Warning message appears for "ahead" and "behind" states
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aVal = aParts[i] ?? 0;
|
||||
const bVal = bParts[i] ?? 0;
|
||||
if (aVal > bVal) return 1;
|
||||
if (aVal < bVal) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("SUPPORTED_CLI_VERSION", () => {
|
||||
it("is defined and non-empty", () => {
|
||||
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
|
||||
});
|
||||
|
||||
it("matches the expected audited version", () => {
|
||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareVersions", () => {
|
||||
describe("equal versions", () => {
|
||||
it("returns 0 for identical versions", () => {
|
||||
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for the supported CLI version against itself", () => {
|
||||
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for 0.0.0 vs 0.0.0", () => {
|
||||
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("major version differences", () => {
|
||||
it("returns 1 when a has a higher major version", () => {
|
||||
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower major version", () => {
|
||||
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minor version differences", () => {
|
||||
it("returns 1 when a has a higher minor version", () => {
|
||||
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower minor version", () => {
|
||||
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch version differences", () => {
|
||||
it("returns 1 when a has a higher patch version", () => {
|
||||
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower patch version", () => {
|
||||
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("major version takes precedence", () => {
|
||||
it("returns 1 when a has a higher major but lower minor", () => {
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower major but higher minor", () => {
|
||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unequal segment counts", () => {
|
||||
it("treats missing segments as 0 (a shorter than b)", () => {
|
||||
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("treats missing segments as 0 (a longer than b)", () => {
|
||||
expect(compareVersions("1.0.0", "1.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("correctly compares when a has an extra non-zero segment", () => {
|
||||
expect(compareVersions("1.0.1", "1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("correctly compares when b has an extra non-zero segment", () => {
|
||||
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supported CLI version comparisons", () => {
|
||||
it("returns 1 for a version ahead of supported", () => {
|
||||
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 for a version behind supported", () => {
|
||||
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns 0 for exactly the supported version", () => {
|
||||
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Interrupted");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
@@ -533,6 +534,25 @@
|
||||
context window
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max Output Tokens -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||
Max output tokens
|
||||
</label>
|
||||
<input
|
||||
id="max-output-tokens"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Default (32000)"
|
||||
bind:value={config.max_output_tokens}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
|
||||
being cut off mid-reply
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Greeting Section -->
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ConversationTabs Component Tests
|
||||
*
|
||||
* Tests the connection status colour mapping and unread message detection
|
||||
* logic used by the ConversationTabs component.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays one tab per conversation
|
||||
* - Each tab shows a coloured dot for its connection state
|
||||
* - Inactive tabs with new messages show an animated blue dot badge
|
||||
* - Tabs can be renamed by double-clicking
|
||||
* - Tabs can be reordered by drag-and-drop
|
||||
* - New tabs created with Ctrl+T, closed with Ctrl+W
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Connected tabs show a green dot
|
||||
* - [ ] Connecting tabs show a yellow dot
|
||||
* - [ ] Disconnected tabs show a red dot
|
||||
* - [ ] Active tab never shows the unread badge
|
||||
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
|
||||
* - [ ] Switching to a tab clears the unread indicator
|
||||
* - [ ] Double-clicking a tab name enables inline editing
|
||||
* - [ ] Tabs can be dragged to reorder
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
||||
|
||||
function getConnectionStatusColor(status: ConnectionStatus | string): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500";
|
||||
case "disconnected":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnreadMessages(
|
||||
id: string,
|
||||
conversationLineCount: number,
|
||||
activeConversationId: string | null,
|
||||
lastSeenMessageCount: Map<string, number>
|
||||
): boolean {
|
||||
if (id === activeConversationId) return false;
|
||||
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
|
||||
return conversationLineCount > lastSeen;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getConnectionStatusColor", () => {
|
||||
it("returns green for connected status", () => {
|
||||
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
|
||||
});
|
||||
|
||||
it("returns yellow for connecting status", () => {
|
||||
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
|
||||
});
|
||||
|
||||
it("returns red for disconnected status", () => {
|
||||
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
|
||||
});
|
||||
|
||||
it("returns grey for unknown status (fallback)", () => {
|
||||
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
|
||||
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUnreadMessages", () => {
|
||||
it("returns false for the active conversation regardless of message count", () => {
|
||||
const lastSeen = new Map([["tab-1", 0]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when an inactive tab has more messages than last seen", () => {
|
||||
const lastSeen = new Map([["tab-1", 5]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when an inactive tab has no new messages", () => {
|
||||
const lastSeen = new Map([["tab-1", 10]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when an inactive tab has fewer messages than last seen", () => {
|
||||
const lastSeen = new Map([["tab-1", 15]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a tab with no last-seen record as having 0 messages seen", () => {
|
||||
const lastSeen = new Map<string, number>();
|
||||
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
|
||||
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an untracked tab with 0 messages", () => {
|
||||
const lastSeen = new Map<string, number>();
|
||||
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles null activeConversationId (no active tab)", () => {
|
||||
const lastSeen = new Map([["tab-1", 3]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let confirmingDeleteId = $state<string | null>(null);
|
||||
let confirmingAll = $state(false);
|
||||
|
||||
const drafts = $derived(draftsStore.drafts);
|
||||
const isLoading = $derived(draftsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
draftsStore.loadDrafts();
|
||||
});
|
||||
|
||||
function handleInsert(draft: Draft): void {
|
||||
onInsert(draft.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(draftId: string): Promise<void> {
|
||||
await draftsStore.deleteDraft(draftId);
|
||||
confirmingDeleteId = null;
|
||||
}
|
||||
|
||||
async function handleDeleteAll(): Promise<void> {
|
||||
await draftsStore.deleteAllDrafts();
|
||||
confirmingAll = false;
|
||||
}
|
||||
|
||||
function truncateContent(content: string): string {
|
||||
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="draft-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Saved Drafts
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $drafts.length > 0}
|
||||
{#if confirmingAll}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={handleDeleteAll}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm Delete All
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingAll = false)}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingAll = true)}
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
||||
</div>
|
||||
{:else if $drafts.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Use "Save as Draft" to store messages for later
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $drafts as draft (draft.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
||||
{draftsStore.formatTimestamp(draft.saved_at)}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
||||
>
|
||||
{truncateContent(draft.content)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(draft)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Insert this draft"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{#if confirmingDeleteId === draft.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(draft.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = draft.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete draft"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* HighlightedText Component Tests
|
||||
*
|
||||
* Tests the text-splitting logic used by the HighlightedText component,
|
||||
* which highlights search query matches within a string.
|
||||
*
|
||||
* What this component does:
|
||||
* - Splits text into an array of {text, isMatch} parts
|
||||
* - Matches are case-insensitive
|
||||
* - Special regex characters in the query are escaped
|
||||
* - Non-matching text is preserved verbatim around matches
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Matching text is highlighted (yellow background) in the terminal
|
||||
* - [ ] Highlighting is case-insensitive
|
||||
* - [ ] Multiple matches on the same line all get highlighted
|
||||
* - [ ] Non-matching text renders normally alongside matches
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
interface TextPart {
|
||||
text: string;
|
||||
isMatch: boolean;
|
||||
}
|
||||
|
||||
function getHighlightedParts(text: string, query: string): TextPart[] {
|
||||
if (!query) {
|
||||
return [{ text, isMatch: false }];
|
||||
}
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
const parts: TextPart[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex, match.index),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
parts.push({
|
||||
text: match[1],
|
||||
isMatch: true,
|
||||
});
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getHighlightedParts", () => {
|
||||
describe("empty query", () => {
|
||||
it("returns the whole text as a single non-match when query is empty string", () => {
|
||||
const result = getHighlightedParts("hello world", "");
|
||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||
});
|
||||
|
||||
it("returns an empty non-match part when both text and query are empty", () => {
|
||||
const result = getHighlightedParts("", "");
|
||||
expect(result).toEqual([{ text: "", isMatch: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no match", () => {
|
||||
it("returns the whole text as a single non-match when query is not found", () => {
|
||||
const result = getHighlightedParts("hello world", "xyz");
|
||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("single match", () => {
|
||||
it("returns three parts for a match in the middle", () => {
|
||||
const result = getHighlightedParts("hello world foo", "world");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
{ text: " foo", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns two parts for a match at the start", () => {
|
||||
const result = getHighlightedParts("hello world", "hello");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello", isMatch: true },
|
||||
{ text: " world", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns two parts for a match at the end", () => {
|
||||
const result = getHighlightedParts("hello world", "world");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a single match part when the entire text matches", () => {
|
||||
const result = getHighlightedParts("hello", "hello");
|
||||
expect(result).toEqual([{ text: "hello", isMatch: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple matches", () => {
|
||||
it("returns interleaved match and non-match parts for multiple occurrences", () => {
|
||||
const result = getHighlightedParts("foo bar foo", "foo");
|
||||
expect(result).toEqual([
|
||||
{ text: "foo", isMatch: true },
|
||||
{ text: " bar ", isMatch: false },
|
||||
{ text: "foo", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles adjacent matches correctly", () => {
|
||||
const result = getHighlightedParts("aaa", "a");
|
||||
expect(result).toEqual([
|
||||
{ text: "a", isMatch: true },
|
||||
{ text: "a", isMatch: true },
|
||||
{ text: "a", isMatch: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("case-insensitive matching", () => {
|
||||
it("matches uppercase query against lowercase text", () => {
|
||||
const result = getHighlightedParts("hello world", "WORLD");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches lowercase query against uppercase text", () => {
|
||||
const result = getHighlightedParts("HELLO WORLD", "hello");
|
||||
expect(result).toEqual([
|
||||
{ text: "HELLO", isMatch: true },
|
||||
{ text: " WORLD", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the original casing of the matched text", () => {
|
||||
const result = getHighlightedParts("Hello World", "hello");
|
||||
const matchPart = result.find((p) => p.isMatch);
|
||||
expect(matchPart?.text).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("special regex character escaping", () => {
|
||||
it("treats a dot in the query as a literal dot, not a wildcard", () => {
|
||||
const result = getHighlightedParts("v1.2.3 v123", "1.2");
|
||||
const matchParts = result.filter((p) => p.isMatch);
|
||||
expect(matchParts).toHaveLength(1);
|
||||
expect(matchParts[0].text).toBe("1.2");
|
||||
});
|
||||
|
||||
it("handles a query with parentheses", () => {
|
||||
const result = getHighlightedParts("fn(args)", "(args)");
|
||||
expect(result).toEqual([
|
||||
{ text: "fn", isMatch: false },
|
||||
{ text: "(args)", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a query with a plus sign", () => {
|
||||
const result = getHighlightedParts("a+b=c", "+");
|
||||
expect(result).toEqual([
|
||||
{ text: "a", isMatch: false },
|
||||
{ text: "+", isMatch: true },
|
||||
{ text: "b=c", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a query with a question mark", () => {
|
||||
const result = getHighlightedParts("is it true?", "?");
|
||||
expect(result).toEqual([
|
||||
{ text: "is it true", isMatch: false },
|
||||
{ text: "?", isMatch: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,9 @@
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import { draftsStore } from "$lib/stores/drafts";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -52,6 +54,7 @@
|
||||
let showSnippetLibrary = $state(false);
|
||||
let showQuickActions = $state(false);
|
||||
let showClipboardHistory = $state(false);
|
||||
let showDraftPanel = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
// Cost estimation for pre-submission display
|
||||
@@ -164,6 +167,25 @@
|
||||
attachments = storedAttachments;
|
||||
});
|
||||
|
||||
// Per-tab draft persistence — restore the draft text whenever the active
|
||||
// conversation changes, and save it back on every keystroke.
|
||||
claudeStore.activeConversationId.subscribe((conversationId) => {
|
||||
if (conversationId) {
|
||||
const conv = get(claudeStore.conversations).get(conversationId);
|
||||
inputValue = conv?.draftText ?? "";
|
||||
} else {
|
||||
inputValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
function clearInput() {
|
||||
inputValue = "";
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, "");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
// If input is empty, allow history navigation again
|
||||
// Otherwise, mark that user has manually typed
|
||||
@@ -176,6 +198,12 @@
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
// Save the current draft so it persists if the user switches tabs.
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, inputValue);
|
||||
}
|
||||
|
||||
if (isSlashCommand(inputValue)) {
|
||||
matchingCommands = getMatchingCommands(inputValue);
|
||||
showCommandMenu = matchingCommands.length > 0;
|
||||
@@ -195,7 +223,7 @@
|
||||
async function executeSlashCommand(): Promise<boolean> {
|
||||
const { command, args } = parseSlashCommand(inputValue);
|
||||
if (command) {
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
await command.execute(args);
|
||||
@@ -228,7 +256,7 @@
|
||||
"error",
|
||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||
);
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,7 +272,7 @@
|
||||
userHasTyped = false;
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
|
||||
// Capture attachments before clearing
|
||||
const currentAttachments = [...attachments];
|
||||
@@ -326,7 +354,7 @@ User: ${formattedMessage}`;
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
||||
characterState.setState("idle");
|
||||
|
||||
// Show connecting status while we reconnect
|
||||
@@ -703,6 +731,22 @@ User: ${formattedMessage}`;
|
||||
userHasTyped = true;
|
||||
}
|
||||
|
||||
function handleDraftInsert(content: string): void {
|
||||
inputValue = content;
|
||||
userHasTyped = true;
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsDraft(): Promise<void> {
|
||||
const content = inputValue.trim();
|
||||
if (!content) return;
|
||||
await draftsStore.saveDraft(content);
|
||||
clearInput();
|
||||
}
|
||||
|
||||
function handleClipboardInsert(content: string): void {
|
||||
// Insert clipboard content at cursor position or append to input
|
||||
if (inputValue.trim()) {
|
||||
@@ -919,6 +963,29 @@ User: ${formattedMessage}`;
|
||||
<span>Clipboard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDraftPanel = true)}
|
||||
class="control-button"
|
||||
title="Saved Drafts"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Drafts</span>
|
||||
</button>
|
||||
|
||||
<CliVersion />
|
||||
<SystemClock />
|
||||
</div>
|
||||
@@ -959,6 +1026,29 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSaveAsDraft}
|
||||
disabled={!inputValue.trim()}
|
||||
class="attach-button"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -1024,6 +1114,10 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDraftPanel}
|
||||
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
||||
{/if}
|
||||
|
||||
{#if contextMenuShow && textareaElement}
|
||||
<TextInputContextMenu
|
||||
x={contextMenuX}
|
||||
|
||||
@@ -281,10 +281,16 @@
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ul) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-content :global(li) {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let permissions: PermissionRequest[] = $state([]);
|
||||
let permissions: PermissionRequest[] = [];
|
||||
let selectedPermissions = new SvelteSet<string>();
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
let grantedToolsList: string[] = [];
|
||||
let workingDirectory = "";
|
||||
|
||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||
permissions = perms;
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
@@ -185,6 +186,7 @@
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -344,6 +346,7 @@
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* StatusBar Component Tests
|
||||
*
|
||||
* Tests the connection status colour and text helpers used by the
|
||||
* StatusBar component to display the current Claude connection state.
|
||||
*
|
||||
* What this component does:
|
||||
* - Shows a coloured indicator dot for the connection state
|
||||
* - Shows a text label for the connection state
|
||||
* - Provides connect/disconnect buttons
|
||||
* - Contains the working directory input and browse button
|
||||
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Green dot and "Connected" label when Claude is running
|
||||
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
|
||||
* - [ ] Red dot and "Error" label on connection error
|
||||
* - [ ] Grey dot and "Disconnected" label when not connected
|
||||
* - [ ] Directory input is hidden when connected, visible when disconnected
|
||||
* - [ ] Connect button transitions to Disconnect button on connection
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
|
||||
|
||||
function getStatusColor(connectionStatus: ConnectionStatus): string {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(connectionStatus: ConnectionStatus): string {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getStatusColor", () => {
|
||||
it("returns green for connected status", () => {
|
||||
expect(getStatusColor("connected")).toBe("bg-green-500");
|
||||
});
|
||||
|
||||
it("returns animated yellow for connecting status", () => {
|
||||
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
|
||||
});
|
||||
|
||||
it("returns red for error status", () => {
|
||||
expect(getStatusColor("error")).toBe("bg-red-500");
|
||||
});
|
||||
|
||||
it("returns grey for disconnected status", () => {
|
||||
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusText", () => {
|
||||
it("returns 'Connected' for connected status", () => {
|
||||
expect(getStatusText("connected")).toBe("Connected");
|
||||
});
|
||||
|
||||
it("returns 'Connecting...' for connecting status", () => {
|
||||
expect(getStatusText("connecting")).toBe("Connecting...");
|
||||
});
|
||||
|
||||
it("returns 'Error' for error status", () => {
|
||||
expect(getStatusText("error")).toBe("Error");
|
||||
});
|
||||
|
||||
it("returns 'Disconnected' for disconnected status", () => {
|
||||
expect(getStatusText("disconnected")).toBe("Disconnected");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||
import ToolCallBlock from "./ToolCallBlock.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
@@ -208,22 +209,6 @@
|
||||
if (!currentConversationId) return;
|
||||
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||
}
|
||||
|
||||
// Collapsible tool lines
|
||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||
let expandedToolLines: Record<string, boolean> = {};
|
||||
|
||||
function isToolContentLong(content: string): boolean {
|
||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||
}
|
||||
|
||||
function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
function toggleToolLine(id: string) {
|
||||
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -258,6 +243,18 @@
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
{/if}
|
||||
{:else if line.type === "tool"}
|
||||
<div
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<ToolCallBlock
|
||||
toolName={line.toolName ?? null}
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
timestamp={line.timestamp}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
@@ -296,9 +293,6 @@
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "compact-prompt"}
|
||||
<button class="compact-action-btn" onclick={handleCompact}>
|
||||
⚡ Compact Conversation
|
||||
@@ -330,22 +324,6 @@
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
|
||||
<span class="tool-collapsible">
|
||||
<HighlightedText
|
||||
content={expandedToolLines[line.id]
|
||||
? maskPaths(line.content, hidePaths)
|
||||
: truncateToolContent(maskPaths(line.content, hidePaths))}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="tool-toggle-btn"
|
||||
onclick={() => toggleToolLine(line.id)}
|
||||
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
|
||||
>
|
||||
{expandedToolLines[line.id] ? "▲" : "▼"}
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
@@ -501,28 +479,4 @@
|
||||
.terminal-line {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-collapsible {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.tool-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
cursor: pointer;
|
||||
font-size: 0.7em;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tool-toggle-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
toolName: string | null;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { toolName, content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tool-block">
|
||||
<button class="tool-header" onclick={toggleExpanded} type="button">
|
||||
<span class="tool-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="tool-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
{#if toolName}
|
||||
<span class="tool-name">[{toolName}]</span>
|
||||
{/if}
|
||||
<span class="tool-label">{content}</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="tool-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
color: var(--terminal-tool-name, #ddd6fe);
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Notification Rules Tests
|
||||
*
|
||||
* Tests the connection status change handler, which fires a connection
|
||||
* notification sound exactly once per reconnect cycle.
|
||||
*
|
||||
* What this module does:
|
||||
* - Tracks the previous connection status in module-level state
|
||||
* - Fires a notification only when transitioning from a non-connected
|
||||
* state (disconnected/connecting) to "connected"
|
||||
* - Ignores the initial connection (null → connected) to avoid noisy
|
||||
* notifications on app start
|
||||
* - Provides no-op handlers for tool execution and user messages
|
||||
* (reserved for future notification rules)
|
||||
* - cleanupNotificationRules() resets tracking state on teardown
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { mockNotifyConnection } = vi.hoisted(() => ({
|
||||
mockNotifyConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./notificationManager", () => ({
|
||||
notificationManager: {
|
||||
notifyConnection: mockNotifyConnection,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
handleConnectionStatusChange,
|
||||
handleToolExecution,
|
||||
handleNewUserMessage,
|
||||
initializeNotificationRules,
|
||||
cleanupNotificationRules,
|
||||
} from "./rules";
|
||||
|
||||
// ---
|
||||
|
||||
describe("handleConnectionStatusChange", () => {
|
||||
beforeEach(() => {
|
||||
mockNotifyConnection.mockReset();
|
||||
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
|
||||
});
|
||||
|
||||
describe("initial connection (null → status)", () => {
|
||||
it("does not notify on first connection (null → connected)", () => {
|
||||
// previousConnectionStatus is null (falsy), so condition is not met
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
|
||||
handleConnectionStatusChange("disconnected");
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify when entering connecting from initial state (null → connecting)", () => {
|
||||
handleConnectionStatusChange("connecting");
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection (disconnected → connected)", () => {
|
||||
it("notifies when reconnecting after a disconnection", () => {
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("notifies exactly once per reconnect", () => {
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection (connecting → connected)", () => {
|
||||
it("notifies when transitioning from connecting to connected", () => {
|
||||
handleConnectionStatusChange("connecting");
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("already connected (connected → connected)", () => {
|
||||
it("does not notify when already connected", () => {
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected"); // First connection — notifies
|
||||
mockNotifyConnection.mockReset();
|
||||
|
||||
handleConnectionStatusChange("connected"); // Second — same status, no notify
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnecting (connected → disconnected)", () => {
|
||||
it("does not notify when disconnecting", () => {
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected");
|
||||
mockNotifyConnection.mockReset();
|
||||
|
||||
handleConnectionStatusChange("disconnected");
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple reconnect cycles", () => {
|
||||
it("notifies once per reconnect cycle", () => {
|
||||
// First cycle
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockNotifyConnection.mockReset();
|
||||
|
||||
// Second cycle
|
||||
handleConnectionStatusChange("disconnected");
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupNotificationRules", () => {
|
||||
it("resets state so the next connection is treated as the first", () => {
|
||||
// Establish a known previous status
|
||||
handleConnectionStatusChange("disconnected");
|
||||
// Now cleanup
|
||||
cleanupNotificationRules();
|
||||
// After cleanup, previousConnectionStatus is null again
|
||||
// So the next "connected" should NOT notify (treated as initial connection)
|
||||
handleConnectionStatusChange("connected");
|
||||
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op handlers", () => {
|
||||
it("handleToolExecution does not throw", () => {
|
||||
expect(() => handleToolExecution("Bash")).not.toThrow();
|
||||
});
|
||||
|
||||
it("handleNewUserMessage does not throw", () => {
|
||||
expect(() => handleNewUserMessage()).not.toThrow();
|
||||
});
|
||||
|
||||
it("initializeNotificationRules does not throw", () => {
|
||||
expect(() => initializeNotificationRules()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,8 @@
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { notificationManager } from "./notificationManager";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
|
||||
// Track previous states to detect transitions
|
||||
let previousCharacterState: CharacterState | null = null;
|
||||
// Track previous connection status to detect transitions
|
||||
let previousConnectionStatus: ConnectionStatus | null = null;
|
||||
let taskStartTime: number | null = null;
|
||||
let hasNotifiedTaskStart = false;
|
||||
|
||||
export function handleCharacterStateChange(newState: CharacterState): void {
|
||||
// Detect state transitions
|
||||
if (previousCharacterState === newState) return;
|
||||
|
||||
// Task completion: any state -> success
|
||||
if (newState === "success" && previousCharacterState !== null) {
|
||||
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
|
||||
// Only notify for tasks that took more than 2 seconds
|
||||
if (taskDuration > 2000) {
|
||||
notificationManager.notifySuccess();
|
||||
}
|
||||
taskStartTime = null;
|
||||
}
|
||||
|
||||
// Error occurred
|
||||
if (newState === "error" && previousCharacterState !== "error") {
|
||||
notificationManager.notifyError();
|
||||
}
|
||||
|
||||
// Permission needed
|
||||
if (newState === "permission") {
|
||||
notificationManager.notifyPermission();
|
||||
}
|
||||
|
||||
// Starting long tasks - only notify once per response
|
||||
if (
|
||||
(newState === "coding" || newState === "searching") &&
|
||||
previousCharacterState !== "coding" &&
|
||||
previousCharacterState !== "searching" &&
|
||||
!hasNotifiedTaskStart
|
||||
) {
|
||||
taskStartTime = Date.now();
|
||||
hasNotifiedTaskStart = true;
|
||||
notificationManager.notifyTaskStart();
|
||||
}
|
||||
|
||||
previousCharacterState = newState;
|
||||
}
|
||||
|
||||
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
||||
// Only notify on successful connection after being disconnected
|
||||
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
|
||||
// But we could add specific rules here if needed
|
||||
}
|
||||
|
||||
// Reset notification state for a new response
|
||||
export function handleNewUserMessage(): void {
|
||||
hasNotifiedTaskStart = false;
|
||||
}
|
||||
// No-op: sound tracking is now per-conversation in tauri.ts
|
||||
export function handleNewUserMessage(): void {}
|
||||
|
||||
// Store unsubscribe functions
|
||||
let unsubscribeCharacterState: (() => void) | null = null;
|
||||
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
|
||||
export function initializeNotificationRules(): void {}
|
||||
|
||||
// Initialize listeners
|
||||
export function initializeNotificationRules(): void {
|
||||
// Clean up any existing subscriptions first
|
||||
cleanupNotificationRules();
|
||||
|
||||
// Subscribe to character state changes
|
||||
unsubscribeCharacterState = characterState.subscribe((state) => {
|
||||
handleCharacterStateChange(state);
|
||||
});
|
||||
|
||||
// We'll connect to connection status in the next step
|
||||
}
|
||||
|
||||
// Cleanup function to prevent duplicate listeners
|
||||
// Cleanup — reset connection tracking on teardown
|
||||
export function cleanupNotificationRules(): void {
|
||||
if (unsubscribeCharacterState) {
|
||||
unsubscribeCharacterState();
|
||||
unsubscribeCharacterState = null;
|
||||
}
|
||||
|
||||
// Reset state tracking
|
||||
previousCharacterState = null;
|
||||
previousConnectionStatus = null;
|
||||
taskStartTime = null;
|
||||
hasNotifiedTaskStart = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotificationType } from "$lib/notifications/types";
|
||||
|
||||
const mockPlay = vi.fn();
|
||||
|
||||
vi.mock("$lib/notifications", () => ({
|
||||
soundPlayer: {
|
||||
play: mockPlay,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("achievement sounds", () => {
|
||||
beforeEach(() => {
|
||||
mockPlay.mockReset();
|
||||
});
|
||||
|
||||
describe("playAchievementSound", () => {
|
||||
it("plays the achievement notification sound", async () => {
|
||||
const { playAchievementSound } = await import("./achievement");
|
||||
playAchievementSound();
|
||||
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testAchievementSound", () => {
|
||||
it("calls playAchievementSound without throwing", async () => {
|
||||
const { testAchievementSound } = await import("./achievement");
|
||||
expect(() => testAchievementSound()).not.toThrow();
|
||||
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||
});
|
||||
|
||||
it("catches errors from the sound player gracefully", async () => {
|
||||
mockPlay.mockImplementation(() => {
|
||||
throw new Error("Audio not available");
|
||||
});
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { testAchievementSound } = await import("./achievement");
|
||||
expect(() => testAchievementSound()).not.toThrow();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error playing achievement sound:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { characterState, characterInfo } from "./character";
|
||||
|
||||
describe("characterState store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
characterState.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts in idle state", () => {
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setState", () => {
|
||||
it("sets the character state", () => {
|
||||
characterState.setState("thinking");
|
||||
expect(get(characterState)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("can set any valid state", () => {
|
||||
const states = [
|
||||
"idle",
|
||||
"thinking",
|
||||
"typing",
|
||||
"coding",
|
||||
"searching",
|
||||
"mcp",
|
||||
"permission",
|
||||
"success",
|
||||
"error",
|
||||
] as const;
|
||||
for (const state of states) {
|
||||
characterState.setState(state);
|
||||
expect(get(characterState)).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels any active temporary state timer", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.setState("thinking");
|
||||
|
||||
// Advance past the temporary state duration — should stay as thinking
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(get(characterState)).toBe("thinking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTemporaryState", () => {
|
||||
it("sets the character state immediately", () => {
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
expect(get(characterState)).toBe("success");
|
||||
});
|
||||
|
||||
it("reverts to idle after the specified duration", () => {
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses 2000ms as the default duration", () => {
|
||||
characterState.setTemporaryState("error");
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(get(characterState)).toBe("error");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("cancels a previous temporary state timer when a new one is set", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.setTemporaryState("error", 1000);
|
||||
|
||||
// First timer would have fired at 5000ms but was cancelled
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets the state to idle", () => {
|
||||
characterState.setState("thinking");
|
||||
characterState.reset();
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("cancels any pending temporary state timer", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.reset();
|
||||
|
||||
// Should now be idle and should NOT revert again after timer fires
|
||||
expect(get(characterState)).toBe("idle");
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("characterInfo derived store", () => {
|
||||
beforeEach(() => {
|
||||
characterState.reset();
|
||||
});
|
||||
|
||||
it("returns the info object for the current state", () => {
|
||||
const info = get(characterInfo);
|
||||
expect(info).toBeDefined();
|
||||
expect(typeof info.label).toBe("string");
|
||||
});
|
||||
|
||||
it("updates when the character state changes", () => {
|
||||
characterState.setState("thinking");
|
||||
const thinkingInfo = get(characterInfo);
|
||||
|
||||
characterState.setState("idle");
|
||||
const idleInfo = get(characterInfo);
|
||||
|
||||
expect(thinkingInfo.label).not.toBe(idleInfo.label);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,15 @@ export const claudeStore = {
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||
|
||||
// Sound tracking
|
||||
resetSoundState: conversationsStore.resetSoundState,
|
||||
setTaskStartTime: conversationsStore.setTaskStartTime,
|
||||
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
|
||||
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
|
||||
|
||||
// Draft text (per-tab input persistence)
|
||||
setDraftText: conversationsStore.setDraftText,
|
||||
|
||||
// Conversation management
|
||||
createConversation: conversationsStore.createConversation,
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Clipboard Store Tests
|
||||
*
|
||||
* Tests the pure helper functions from the clipboard store:
|
||||
* - detectLanguage: identifies programming language from code content
|
||||
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
||||
*
|
||||
* What this store does:
|
||||
* - Maintains a history of clipboard entries (code snippets)
|
||||
* - Auto-detects the language of captured content
|
||||
* - Supports filtering by language and search query
|
||||
* - Pinned entries persist across clear operations
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Clipboard entries appear when code is copied during a session
|
||||
* - [ ] Language is detected and labelled correctly
|
||||
* - [ ] Pinned entries survive "Clear history"
|
||||
* - [ ] Language filter dropdown shows only languages present in history
|
||||
* - [ ] Search query filters by content, language, and source
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mirror: detectLanguage from clipboard.ts
|
||||
function detectLanguage(content: string): string | null {
|
||||
const patterns: [RegExp, string][] = [
|
||||
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
|
||||
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
|
||||
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
|
||||
[/^(package|import|func|type|var|const)\s/m, "go"],
|
||||
[/<\?php/m, "php"],
|
||||
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
|
||||
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
|
||||
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
|
||||
[/^---\s*\n/m, "yaml"],
|
||||
[/^\s*#\s*(include|define|ifdef)/m, "c"],
|
||||
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
|
||||
[/^\$[\w_]+\s*=/m, "bash"],
|
||||
];
|
||||
|
||||
for (const [pattern, lang] of patterns) {
|
||||
if (pattern.test(content)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mirror: formatTimestamp from clipboard.ts
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("detectLanguage", () => {
|
||||
describe("TypeScript detection", () => {
|
||||
it("detects import statements", () => {
|
||||
expect(detectLanguage("import React from 'react';")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects export statements", () => {
|
||||
expect(detectLanguage("export function foo() {}")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects const declarations", () => {
|
||||
expect(detectLanguage("const x = 1;")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects interface declarations", () => {
|
||||
expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects type aliases", () => {
|
||||
expect(detectLanguage("type MyType = string | number;")).toBe("typescript");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Python detection", () => {
|
||||
it("detects def statements", () => {
|
||||
expect(detectLanguage("def foo():\n pass")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects async def statements", () => {
|
||||
expect(detectLanguage("async def bar():\n pass")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects from imports", () => {
|
||||
expect(detectLanguage("from os import path")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects the __name__ guard", () => {
|
||||
expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rust detection", () => {
|
||||
it("detects fn declarations", () => {
|
||||
expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects impl blocks", () => {
|
||||
expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects struct declarations", () => {
|
||||
expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects enum declarations", () => {
|
||||
expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects mod declarations", () => {
|
||||
expect(detectLanguage("mod utils;")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects pub visibility", () => {
|
||||
expect(detectLanguage("pub fn exported() {}")).toBe("rust");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Go detection", () => {
|
||||
it("detects package declarations", () => {
|
||||
expect(detectLanguage("package main")).toBe("go");
|
||||
});
|
||||
|
||||
it("detects func declarations", () => {
|
||||
expect(detectLanguage("func main() {}")).toBe("go");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PHP detection", () => {
|
||||
it("detects the PHP open tag", () => {
|
||||
expect(detectLanguage("<?php\necho 'hello';")).toBe("php");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL detection", () => {
|
||||
it("detects SELECT statements", () => {
|
||||
expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
||||
});
|
||||
|
||||
it("detects INSERT statements", () => {
|
||||
expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql");
|
||||
});
|
||||
|
||||
it("detects CREATE statements", () => {
|
||||
expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
||||
});
|
||||
|
||||
it("detects SQL case-insensitively", () => {
|
||||
expect(detectLanguage("select * from users")).toBe("sql");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML detection", () => {
|
||||
it("detects DOCTYPE declarations", () => {
|
||||
expect(detectLanguage("<!DOCTYPE html>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects html tags", () => {
|
||||
expect(detectLanguage("<html><body></body></html>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects div tags", () => {
|
||||
expect(detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects span tags", () => {
|
||||
expect(detectLanguage("<span>text</span>")).toBe("html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON detection", () => {
|
||||
it("detects JSON object syntax", () => {
|
||||
expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
||||
});
|
||||
|
||||
it("detects JSON with hyphenated keys", () => {
|
||||
expect(detectLanguage('{"my-key": "value"}')).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML detection", () => {
|
||||
it("detects YAML document separator", () => {
|
||||
expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("C detection", () => {
|
||||
it("detects #include directives", () => {
|
||||
expect(detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
||||
});
|
||||
|
||||
it("detects #define directives", () => {
|
||||
expect(detectLanguage("#define MAX 100")).toBe("c");
|
||||
});
|
||||
|
||||
it("detects #ifdef directives", () => {
|
||||
expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Java detection", () => {
|
||||
it("detects public class declarations", () => {
|
||||
expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
||||
});
|
||||
|
||||
it("detects private static methods", () => {
|
||||
expect(detectLanguage("private static void helper() {}")).toBe("java");
|
||||
});
|
||||
|
||||
it("detects protected interface declarations", () => {
|
||||
expect(detectLanguage("protected interface Bar {}")).toBe("java");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash detection", () => {
|
||||
it("detects shell variable assignments", () => {
|
||||
expect(detectLanguage("$HOME=/usr/local")).toBe("bash");
|
||||
});
|
||||
|
||||
it("detects variable assignments with underscores", () => {
|
||||
expect(detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown content", () => {
|
||||
it("returns null for plain text", () => {
|
||||
expect(detectLanguage("Hello, world!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(detectLanguage("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mathematical expressions", () => {
|
||||
expect(detectLanguage("1 + 1 = 2")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a markdown heading", () => {
|
||||
expect(detectLanguage("# My Heading\nSome text")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
const NOW = new Date("2026-03-03T12:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("'Just now' threshold (< 1 minute)", () => {
|
||||
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
||||
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns 'Just now' for the current moment", () => {
|
||||
const ts = NOW.toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
||||
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
||||
it("returns '1m ago' at exactly 1 minute", () => {
|
||||
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("1m ago");
|
||||
});
|
||||
|
||||
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
||||
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns '59m ago' just before the 1-hour threshold", () => {
|
||||
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("59m ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xh ago' threshold (1–23 hours)", () => {
|
||||
it("returns '1h ago' at exactly 1 hour", () => {
|
||||
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("1h ago");
|
||||
});
|
||||
|
||||
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
||||
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("returns '23h ago' just before the 1-day threshold", () => {
|
||||
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("23h ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xd ago' threshold (1–6 days)", () => {
|
||||
it("returns '1d ago' at exactly 1 day", () => {
|
||||
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("1d ago");
|
||||
});
|
||||
|
||||
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
||||
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("3d ago");
|
||||
});
|
||||
|
||||
it("returns '6d ago' just before the 7-day threshold", () => {
|
||||
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
||||
expect(formatTimestamp(ts)).toBe("6d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locale date string (7+ days ago)", () => {
|
||||
it("returns a locale date string for a 2-week-old timestamp", () => {
|
||||
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
||||
const result = formatTimestamp(ts);
|
||||
// Should not be a relative time string
|
||||
expect(result).not.toContain("m ago");
|
||||
expect(result).not.toContain("h ago");
|
||||
expect(result).not.toContain("d ago");
|
||||
expect(result).not.toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns a locale date string for a 1-month-old timestamp", () => {
|
||||
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
||||
const result = formatTimestamp(ts);
|
||||
expect(result).not.toContain("ago");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,7 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
@@ -247,6 +248,7 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
@@ -797,6 +799,7 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface HikariConfig {
|
||||
use_worktree: boolean;
|
||||
// Disable 1M context window
|
||||
disable_1m_context: boolean;
|
||||
// Max output tokens for Claude Code responses
|
||||
max_output_tokens: number | null;
|
||||
// Workspaces the user has explicitly trusted
|
||||
trusted_workspaces: string[];
|
||||
// Background image settings
|
||||
@@ -98,6 +100,7 @@ const defaultConfig: HikariConfig = {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
|
||||
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
|
||||
expect(pendingRetryMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft text persistence", () => {
|
||||
it("initialises draft text as empty string", () => {
|
||||
const conversation = { draftText: "" };
|
||||
expect(conversation.draftText).toBe("");
|
||||
});
|
||||
|
||||
it("stores draft text per conversation", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { draftText: "Hello world" }],
|
||||
["conv-2", { draftText: "" }],
|
||||
]);
|
||||
|
||||
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
|
||||
expect(conversations.get("conv-2")?.draftText).toBe("");
|
||||
});
|
||||
|
||||
it("updates draft text independently per conversation", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { draftText: "Draft A" }],
|
||||
["conv-2", { draftText: "Draft B" }],
|
||||
]);
|
||||
|
||||
const convA = conversations.get("conv-1");
|
||||
if (convA) convA.draftText = "Updated A";
|
||||
|
||||
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
|
||||
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
|
||||
});
|
||||
|
||||
it("clears draft text after submission", () => {
|
||||
const conversation = { draftText: "My prompt" };
|
||||
|
||||
conversation.draftText = "";
|
||||
|
||||
expect(conversation.draftText).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,92 @@ export interface Conversation {
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
startedAt: Date;
|
||||
taskStartTime: number | null;
|
||||
successSoundFired: boolean;
|
||||
taskStartSoundFired: boolean;
|
||||
draftText: string;
|
||||
}
|
||||
|
||||
const TAB_NAMES = [
|
||||
// Cosmic & celestial
|
||||
"Starfall",
|
||||
"Moonbeam",
|
||||
"Nebula",
|
||||
"Aurora",
|
||||
"Stardust",
|
||||
"Solstice",
|
||||
"Comet",
|
||||
"Eclipse",
|
||||
"Zenith",
|
||||
"Celestia",
|
||||
"Nova",
|
||||
"Quasar",
|
||||
"Lyra",
|
||||
"Andromeda",
|
||||
"Twilight",
|
||||
// Magical & fantastical
|
||||
"Camelot",
|
||||
"Reverie",
|
||||
"Arcane",
|
||||
"Spellbound",
|
||||
"Mirage",
|
||||
"Oracle",
|
||||
"Seraphim",
|
||||
"Ethereal",
|
||||
"Labyrinth",
|
||||
"Enchantment",
|
||||
// Nature & cosy
|
||||
"Sakura",
|
||||
"Ember",
|
||||
"Cascade",
|
||||
"Zephyr",
|
||||
"Serendipity",
|
||||
"Solace",
|
||||
"Blossom",
|
||||
"Whisper",
|
||||
"Dewdrop",
|
||||
"Sunbeam",
|
||||
"Willow",
|
||||
"Clover",
|
||||
"Honeybee",
|
||||
"Buttercup",
|
||||
"Dandelion",
|
||||
// Japanese/anime-inspired
|
||||
"Tsukimi",
|
||||
"Hanami",
|
||||
"Yozora",
|
||||
"Hoshi",
|
||||
"Koharu",
|
||||
"Akari",
|
||||
"Midori",
|
||||
// Adventure & epic
|
||||
"Odyssey",
|
||||
"Wanderer",
|
||||
"Horizon",
|
||||
"Voyage",
|
||||
"Pathfinder",
|
||||
"Frontier",
|
||||
// Whimsical & sweet
|
||||
"Bubblegum",
|
||||
"Marshmallow",
|
||||
"Daydream",
|
||||
"Whimsy",
|
||||
"Jellybean",
|
||||
"Sprinkle",
|
||||
"Cupcake",
|
||||
// Dreamy & poetic
|
||||
"Revenant",
|
||||
"Elysium",
|
||||
"Halcyon",
|
||||
"Ephemera",
|
||||
"Serenade",
|
||||
"Lullaby",
|
||||
"Nocturne",
|
||||
"Rhapsody",
|
||||
];
|
||||
|
||||
function pickRandomTabName(): string {
|
||||
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -59,7 +145,7 @@ function createConversationsStore() {
|
||||
const id = generateConversationId();
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${conversationCounter}`,
|
||||
name: name ?? pickRandomTabName(),
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected",
|
||||
@@ -75,6 +161,10 @@ function createConversationsStore() {
|
||||
attachments: [],
|
||||
summary: null,
|
||||
startedAt: new Date(),
|
||||
taskStartTime: null,
|
||||
successSoundFired: false,
|
||||
taskStartSoundFired: false,
|
||||
draftText: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +173,7 @@ function createConversationsStore() {
|
||||
function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const initialConversation = createNewConversation("Main");
|
||||
const initialConversation = createNewConversation();
|
||||
conversations.update((convs) => {
|
||||
convs.set(initialConversation.id, initialConversation);
|
||||
return convs;
|
||||
@@ -196,7 +286,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -219,7 +309,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -364,9 +454,15 @@ function createConversationsStore() {
|
||||
if (currentId !== id) {
|
||||
activeConversationId.set(id);
|
||||
|
||||
// Update the global character state to match the conversation's state
|
||||
// Update the global character state to match the conversation's state.
|
||||
// Map success/error → idle since those are transient states that have
|
||||
// already been displayed — restoring them would re-trigger sound rules.
|
||||
if (targetConv) {
|
||||
characterState.setState(targetConv.characterState);
|
||||
const stateToRestore =
|
||||
targetConv.characterState === "success" || targetConv.characterState === "error"
|
||||
? "idle"
|
||||
: targetConv.characterState;
|
||||
characterState.setState(stateToRestore);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -816,6 +912,59 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
// Sound tracking methods
|
||||
resetSoundState: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartTime = null;
|
||||
conv.successSoundFired = false;
|
||||
conv.taskStartSoundFired = false;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setTaskStartTime: (conversationId: string, time: number | null) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartTime = time;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
markSuccessSoundFired: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.successSoundFired = true;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
markTaskStartSoundFired: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartSoundFired = true;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setDraftText: (conversationId: string, text: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.draftText = text;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatCost,
|
||||
formatAlertType,
|
||||
getAlertMessage,
|
||||
type AlertType,
|
||||
type CostAlert,
|
||||
} from "./costTracking";
|
||||
|
||||
describe("formatCost", () => {
|
||||
it("formats amounts below $0.01 to 4 decimal places", () => {
|
||||
expect(formatCost(0)).toBe("$0.0000");
|
||||
expect(formatCost(0.001)).toBe("$0.0010");
|
||||
expect(formatCost(0.0099)).toBe("$0.0099");
|
||||
});
|
||||
|
||||
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
|
||||
expect(formatCost(0.01)).toBe("$0.010");
|
||||
expect(formatCost(0.123)).toBe("$0.123");
|
||||
expect(formatCost(0.999)).toBe("$0.999");
|
||||
});
|
||||
|
||||
it("formats amounts $1 and above to 2 decimal places", () => {
|
||||
expect(formatCost(1)).toBe("$1.00");
|
||||
expect(formatCost(1.5)).toBe("$1.50");
|
||||
expect(formatCost(100.99)).toBe("$100.99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAlertType", () => {
|
||||
it("formats Daily as Today", () => {
|
||||
expect(formatAlertType("Daily")).toBe("Today");
|
||||
});
|
||||
|
||||
it("formats Weekly as This Week", () => {
|
||||
expect(formatAlertType("Weekly")).toBe("This Week");
|
||||
});
|
||||
|
||||
it("formats Monthly as This Month", () => {
|
||||
expect(formatAlertType("Monthly")).toBe("This Month");
|
||||
});
|
||||
|
||||
it("handles all AlertType values", () => {
|
||||
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
|
||||
const results = types.map(formatAlertType);
|
||||
expect(results).toEqual(["Today", "This Week", "This Month"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAlertMessage", () => {
|
||||
it("generates a message for a Daily alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Daily",
|
||||
threshold: 1.0,
|
||||
current_cost: 1.5,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("Today");
|
||||
expect(message).toContain("$1.50");
|
||||
expect(message).toContain("$1.00");
|
||||
});
|
||||
|
||||
it("generates a message for a Weekly alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Weekly",
|
||||
threshold: 5.0,
|
||||
current_cost: 6.0,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("This Week");
|
||||
expect(message).toContain("$6.00");
|
||||
expect(message).toContain("$5.00");
|
||||
});
|
||||
|
||||
it("generates a message for a Monthly alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Monthly",
|
||||
threshold: 20.0,
|
||||
current_cost: 25.0,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("This Month");
|
||||
expect(message).toContain("$25.00");
|
||||
expect(message).toContain("$20.00");
|
||||
});
|
||||
|
||||
it("includes threshold and current cost in the message", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Daily",
|
||||
threshold: 0.005,
|
||||
current_cost: 0.007,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("$0.0070");
|
||||
expect(message).toContain("$0.0050");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { draftsStore, type Draft } from "./drafts";
|
||||
|
||||
const makeDraft = (id: string, content: string, saved_at: string): Draft => ({
|
||||
id,
|
||||
content,
|
||||
saved_at,
|
||||
});
|
||||
|
||||
describe("Draft interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const draft: Draft = {
|
||||
id: "draft-123",
|
||||
content: "Hello world",
|
||||
saved_at: "2026-01-01T00:00:00+00:00",
|
||||
};
|
||||
|
||||
expect(draft.id).toBe("draft-123");
|
||||
expect(draft.content).toBe("Hello world");
|
||||
expect(draft.saved_at).toBe("2026-01-01T00:00:00+00:00");
|
||||
});
|
||||
|
||||
it("supports multiline content", () => {
|
||||
const draft: Draft = {
|
||||
id: "multi",
|
||||
content: "Line 1\nLine 2\nLine 3",
|
||||
saved_at: "2026-01-01T00:00:00+00:00",
|
||||
};
|
||||
|
||||
expect(draft.content.includes("\n")).toBe(true);
|
||||
expect(draft.content.split("\n")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof draftsStore.loadDrafts).toBe("function");
|
||||
expect(typeof draftsStore.saveDraft).toBe("function");
|
||||
expect(typeof draftsStore.deleteDraft).toBe("function");
|
||||
expect(typeof draftsStore.deleteAllDrafts).toBe("function");
|
||||
expect(typeof draftsStore.formatTimestamp).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof draftsStore.drafts.subscribe).toBe("function");
|
||||
expect(typeof draftsStore.isLoading.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDrafts", () => {
|
||||
it("loads drafts from backend", async () => {
|
||||
const mockDrafts: Draft[] = [
|
||||
makeDraft("draft-1", "Hello world", "2026-01-01T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_drafts", mockDrafts);
|
||||
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_drafts");
|
||||
expect(get(draftsStore.drafts)).toEqual(mockDrafts);
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_drafts", new Error("Failed to load"));
|
||||
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load drafts:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading during load", async () => {
|
||||
const loadingStates: boolean[] = [];
|
||||
const unsubscribe = draftsStore.isLoading.subscribe((val) => loadingStates.push(val));
|
||||
|
||||
setMockInvokeResult("list_drafts", []);
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
unsubscribe();
|
||||
expect(loadingStates).toContain(true);
|
||||
expect(loadingStates.at(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveDraft", () => {
|
||||
it("saves draft and reloads list", async () => {
|
||||
const mockDraft = makeDraft("new-id", "My draft content", "2026-01-01T00:00:00+00:00");
|
||||
|
||||
setMockInvokeResult("save_draft", mockDraft);
|
||||
setMockInvokeResult("list_drafts", [mockDraft]);
|
||||
|
||||
const result = await draftsStore.saveDraft("My draft content");
|
||||
|
||||
expect(result).toEqual(mockDraft);
|
||||
expect(invoke).toHaveBeenCalledWith("save_draft", { content: "My draft content" });
|
||||
});
|
||||
|
||||
it("returns null on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_draft", new Error("Save failed"));
|
||||
|
||||
const result = await draftsStore.saveDraft("content");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save draft:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteDraft", () => {
|
||||
it("deletes draft by ID and reloads", async () => {
|
||||
setMockInvokeResult("delete_draft", undefined);
|
||||
setMockInvokeResult("list_drafts", []);
|
||||
|
||||
const result = await draftsStore.deleteDraft("draft-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_draft", { draftId: "draft-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_draft", new Error("Delete failed"));
|
||||
|
||||
const result = await draftsStore.deleteDraft("draft-123");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete draft:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllDrafts", () => {
|
||||
it("deletes all drafts and clears store", async () => {
|
||||
// First populate the store
|
||||
setMockInvokeResult("list_drafts", [makeDraft("d1", "Draft 1", "2026-01-01T00:00:00+00:00")]);
|
||||
await draftsStore.loadDrafts();
|
||||
expect(get(draftsStore.drafts)).toHaveLength(1);
|
||||
|
||||
setMockInvokeResult("delete_all_drafts", undefined);
|
||||
const result = await draftsStore.deleteAllDrafts();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_all_drafts");
|
||||
expect(get(draftsStore.drafts)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles delete-all errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_all_drafts", new Error("Delete all failed"));
|
||||
|
||||
const result = await draftsStore.deleteAllDrafts();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete all drafts:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
it("formats a valid ISO timestamp", () => {
|
||||
const result = draftsStore.formatTimestamp("2026-01-15T14:30:00+00:00");
|
||||
// Should produce a human-readable string (locale-dependent)
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to raw string on invalid timestamp", () => {
|
||||
const invalid = "not-a-date";
|
||||
const result = draftsStore.formatTimestamp(invalid);
|
||||
expect(result).toBe(invalid);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft content handling", () => {
|
||||
it("supports content with special characters", () => {
|
||||
const draft = makeDraft(
|
||||
"special",
|
||||
"echo \"Hello\" && echo 'World'",
|
||||
"2026-01-01T00:00:00+00:00"
|
||||
);
|
||||
expect(draft.content).toContain('"');
|
||||
expect(draft.content).toContain("'");
|
||||
expect(draft.content).toContain("&&");
|
||||
});
|
||||
|
||||
it("supports very long content", () => {
|
||||
const longContent = "a".repeat(1000);
|
||||
const draft = makeDraft("long", longContent, "2026-01-01T00:00:00+00:00");
|
||||
expect(draft.content).toHaveLength(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Draft {
|
||||
id: string;
|
||||
content: string;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function createDraftsStore() {
|
||||
const drafts = writable<Draft[]>([]);
|
||||
const isLoading = writable(false);
|
||||
|
||||
async function loadDrafts(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const list = await invoke<Draft[]>("list_drafts");
|
||||
drafts.set(list);
|
||||
} catch (error) {
|
||||
console.error("Failed to load drafts:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft(content: string): Promise<Draft | null> {
|
||||
try {
|
||||
const draft = await invoke<Draft>("save_draft", { content });
|
||||
await loadDrafts();
|
||||
return draft;
|
||||
} catch (error) {
|
||||
console.error("Failed to save draft:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(draftId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_draft", { draftId });
|
||||
await loadDrafts();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete draft:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllDrafts(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_all_drafts");
|
||||
drafts.set([]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all drafts:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return isoString;
|
||||
}
|
||||
try {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
drafts: { subscribe: drafts.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
loadDrafts,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
deleteAllDrafts,
|
||||
formatTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export const draftsStore = createDraftsStore();
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
setSavedHistory,
|
||||
getShouldRestoreHistory,
|
||||
getSavedHistory,
|
||||
clearHistoryRestore,
|
||||
} from "./historyRestore";
|
||||
|
||||
describe("historyRestore module", () => {
|
||||
beforeEach(() => {
|
||||
clearHistoryRestore();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("shouldRestoreHistory is false by default", () => {
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
|
||||
it("savedHistory is null by default", () => {
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setShouldRestoreHistory", () => {
|
||||
it("sets shouldRestoreHistory to true", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
expect(getShouldRestoreHistory()).toBe(true);
|
||||
});
|
||||
|
||||
it("sets shouldRestoreHistory to false", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
setShouldRestoreHistory(false);
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSavedHistory", () => {
|
||||
it("sets the saved history string", () => {
|
||||
setSavedHistory("some history content");
|
||||
expect(getSavedHistory()).toBe("some history content");
|
||||
});
|
||||
|
||||
it("sets the saved history to null", () => {
|
||||
setSavedHistory("some history content");
|
||||
setSavedHistory(null);
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearHistoryRestore", () => {
|
||||
it("resets shouldRestoreHistory to false", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
clearHistoryRestore();
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
|
||||
it("resets savedHistory to null", () => {
|
||||
setSavedHistory("some content");
|
||||
clearHistoryRestore();
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears both values at once", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
setSavedHistory("history");
|
||||
clearHistoryRestore();
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { messageMode, getCurrentMode } from "./messageMode";
|
||||
|
||||
describe("messageMode store", () => {
|
||||
beforeEach(() => {
|
||||
messageMode.reset();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("defaults to chat mode", () => {
|
||||
expect(get(messageMode)).toBe("chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("sets the mode to the given value", () => {
|
||||
messageMode.set("plan");
|
||||
expect(get(messageMode)).toBe("plan");
|
||||
});
|
||||
|
||||
it("can set any arbitrary mode string", () => {
|
||||
messageMode.set("auto");
|
||||
expect(get(messageMode)).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets mode back to chat", () => {
|
||||
messageMode.set("plan");
|
||||
messageMode.reset();
|
||||
expect(get(messageMode)).toBe("chat");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentMode", () => {
|
||||
beforeEach(() => {
|
||||
messageMode.reset();
|
||||
});
|
||||
|
||||
it("returns chat when in default state", () => {
|
||||
expect(getCurrentMode()).toBe("chat");
|
||||
});
|
||||
|
||||
it("returns the currently set mode", () => {
|
||||
messageMode.set("plan");
|
||||
expect(getCurrentMode()).toBe("plan");
|
||||
});
|
||||
|
||||
it("returns chat after a reset", () => {
|
||||
messageMode.set("auto");
|
||||
messageMode.reset();
|
||||
expect(getCurrentMode()).toBe("chat");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const mockSetEnabled = vi.fn();
|
||||
const mockSetGlobalVolume = vi.fn();
|
||||
|
||||
vi.mock("$lib/notifications", () => ({
|
||||
soundPlayer: {
|
||||
setEnabled: mockSetEnabled,
|
||||
setGlobalVolume: mockSetGlobalVolume,
|
||||
},
|
||||
}));
|
||||
|
||||
// We need to control the config store's emitted values
|
||||
const configWritable = writable({
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
});
|
||||
|
||||
vi.mock("./config", () => ({
|
||||
configStore: {
|
||||
config: { subscribe: configWritable.subscribe },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("notifications sync store", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockSetEnabled.mockReset();
|
||||
mockSetGlobalVolume.mockReset();
|
||||
configWritable.set({ notifications_enabled: true, notification_volume: 0.7 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Re-import to clean up any lingering subscriptions
|
||||
const { cleanupNotificationSync } = await import("./notifications");
|
||||
cleanupNotificationSync();
|
||||
});
|
||||
|
||||
describe("initNotificationSync", () => {
|
||||
it("syncs soundPlayer enabled state from config on init", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("syncs soundPlayer volume from config on init", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7);
|
||||
});
|
||||
|
||||
it("updates soundPlayer when config changes", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
mockSetGlobalVolume.mockReset();
|
||||
|
||||
configWritable.set({ notifications_enabled: false, notification_volume: 0.3 });
|
||||
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3);
|
||||
});
|
||||
|
||||
it("does not register a duplicate subscription when called twice", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
initNotificationSync();
|
||||
|
||||
// Both calls should only produce one subscription (one initial sync)
|
||||
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupNotificationSync", () => {
|
||||
it("stops reacting to config changes after cleanup", async () => {
|
||||
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
cleanupNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
configWritable.set({ notifications_enabled: false, notification_volume: 0.5 });
|
||||
|
||||
expect(mockSetEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is safe to call when not initialised", async () => {
|
||||
const { cleanupNotificationSync } = await import("./notifications");
|
||||
expect(() => cleanupNotificationSync()).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows re-initialisation after cleanup", async () => {
|
||||
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
cleanupNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
initNotificationSync();
|
||||
|
||||
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { searchState, isSearchActive, searchQuery } from "./search";
|
||||
|
||||
describe("searchState store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts with empty query", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.query).toBe("");
|
||||
});
|
||||
|
||||
it("starts inactive", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("starts with zero match count", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.matchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("starts with zero current match index", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setQuery", () => {
|
||||
it("sets the query string", () => {
|
||||
searchState.setQuery("hello");
|
||||
expect(get(searchState).query).toBe("hello");
|
||||
});
|
||||
|
||||
it("activates the store when query is non-empty", () => {
|
||||
searchState.setQuery("hello");
|
||||
expect(get(searchState).isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("deactivates the store when query is empty", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.setQuery("");
|
||||
expect(get(searchState).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("resets currentMatchIndex to 0 on each query change", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.setQuery("world");
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMatchCount", () => {
|
||||
it("updates the match count", () => {
|
||||
searchState.setMatchCount(7);
|
||||
expect(get(searchState).matchCount).toBe(7);
|
||||
});
|
||||
|
||||
it("does not reset currentMatchIndex", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.setMatchCount(10);
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextMatch", () => {
|
||||
it("advances to the next match index", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps around to 0 after the last match", () => {
|
||||
searchState.setMatchCount(3);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("stays at 0 when match count is 0", () => {
|
||||
searchState.setMatchCount(0);
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousMatch", () => {
|
||||
it("goes to the previous match index", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps around to last match when at index 0", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(4);
|
||||
});
|
||||
|
||||
it("stays at 0 when match count is 0", () => {
|
||||
searchState.setMatchCount(0);
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("resets query to empty string", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.clear();
|
||||
expect(get(searchState).query).toBe("");
|
||||
});
|
||||
|
||||
it("resets isActive to false", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.clear();
|
||||
expect(get(searchState).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("resets matchCount to 0", () => {
|
||||
searchState.setMatchCount(10);
|
||||
searchState.clear();
|
||||
expect(get(searchState).matchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("resets currentMatchIndex to 0", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.clear();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSearchActive derived store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
it("is false initially", () => {
|
||||
expect(get(isSearchActive)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when query is set", () => {
|
||||
searchState.setQuery("test");
|
||||
expect(get(isSearchActive)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false after clearing", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.clear();
|
||||
expect(get(isSearchActive)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchQuery derived store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
it("is empty string initially", () => {
|
||||
expect(get(searchQuery)).toBe("");
|
||||
});
|
||||
|
||||
it("reflects the current query", () => {
|
||||
searchState.setQuery("my search");
|
||||
expect(get(searchQuery)).toBe("my search");
|
||||
});
|
||||
|
||||
it("is empty after clearing", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.clear();
|
||||
expect(get(searchQuery)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
estimateMessageCost,
|
||||
formatTokenCount,
|
||||
MODEL_PRICING,
|
||||
checkBudget,
|
||||
getBudgetStatusMessage,
|
||||
getRemainingTokenBudget,
|
||||
getRemainingCostBudget,
|
||||
} from "./stats";
|
||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
||||
import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
|
||||
|
||||
// Helper function to create ToolTokenStats for tests
|
||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||
@@ -600,4 +604,201 @@ describe("stats store", () => {
|
||||
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 500,
|
||||
session_cost_usd: 0.5,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns ok when budget is disabled", () => {
|
||||
const result = checkBudget(baseStats, false, 100, 1.0, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
|
||||
it("returns ok when under all budgets", () => {
|
||||
const result = checkBudget(baseStats, true, 10000, 10.0, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
|
||||
it("returns exceeded when token budget is reached", () => {
|
||||
const result = checkBudget(baseStats, true, 1000, null, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||
});
|
||||
|
||||
it("returns warning when token usage is above threshold", () => {
|
||||
// session tokens = 1000, budget = 1100, threshold = 0.8 → 1000/1100 ≈ 0.909 > 0.8
|
||||
const result = checkBudget(baseStats, true, 1100, null, 0.8);
|
||||
expect(result.type).toBe("warning");
|
||||
if (result.type === "warning") {
|
||||
expect(result.budget_type).toBe("token");
|
||||
expect(result.percent_used).toBeGreaterThan(80);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns exceeded when cost budget is reached", () => {
|
||||
const result = checkBudget(baseStats, true, null, 0.5, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "cost" });
|
||||
});
|
||||
|
||||
it("returns warning when cost usage is above threshold", () => {
|
||||
// session cost = 0.5, budget = 0.55, threshold = 0.8 → 0.5/0.55 ≈ 0.909 > 0.8
|
||||
const result = checkBudget(baseStats, true, null, 0.55, 0.8);
|
||||
expect(result.type).toBe("warning");
|
||||
if (result.type === "warning") {
|
||||
expect(result.budget_type).toBe("cost");
|
||||
}
|
||||
});
|
||||
|
||||
it("checks token budget before cost budget", () => {
|
||||
// Both budgets exceeded, but token should be reported first
|
||||
const result = checkBudget(baseStats, true, 500, 0.1, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||
});
|
||||
|
||||
it("returns ok when no budgets are set", () => {
|
||||
const result = checkBudget(baseStats, true, null, null, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetStatusMessage", () => {
|
||||
it("returns null for ok status", () => {
|
||||
const status: BudgetStatus = { type: "ok" };
|
||||
expect(getBudgetStatusMessage(status)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns exceeded message for token budget", () => {
|
||||
const status: BudgetStatus = { type: "exceeded", budget_type: "token" };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("token");
|
||||
expect(message).toContain("exceeded");
|
||||
});
|
||||
|
||||
it("returns exceeded message for cost budget", () => {
|
||||
const status: BudgetStatus = { type: "exceeded", budget_type: "cost" };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("cost");
|
||||
expect(message).toContain("exceeded");
|
||||
});
|
||||
|
||||
it("returns warning message with percentage for token budget", () => {
|
||||
const status: BudgetStatus = { type: "warning", budget_type: "token", percent_used: 85.5 };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("token");
|
||||
expect(message).toContain("86%");
|
||||
});
|
||||
|
||||
it("returns warning message with percentage for cost budget", () => {
|
||||
const status: BudgetStatus = { type: "warning", budget_type: "cost", percent_used: 90 };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("cost");
|
||||
expect(message).toContain("90%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemainingTokenBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 300,
|
||||
session_output_tokens: 200,
|
||||
session_cost_usd: 0,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns null when no token budget is set", () => {
|
||||
expect(getRemainingTokenBudget(baseStats, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the remaining tokens when under budget", () => {
|
||||
// session tokens = 500, budget = 1000 → remaining = 500
|
||||
expect(getRemainingTokenBudget(baseStats, 1000)).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 0 when at or over budget", () => {
|
||||
expect(getRemainingTokenBudget(baseStats, 500)).toBe(0);
|
||||
expect(getRemainingTokenBudget(baseStats, 400)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemainingCostBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0.3,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns null when no cost budget is set", () => {
|
||||
expect(getRemainingCostBudget(baseStats, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the remaining cost when under budget", () => {
|
||||
// session cost = 0.3, budget = 1.0 → remaining = 0.7
|
||||
expect(getRemainingCostBudget(baseStats, 1.0)).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
it("returns 0 when at or over budget", () => {
|
||||
expect(getRemainingCostBudget(baseStats, 0.3)).toBe(0);
|
||||
expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { emitMockEvent } from "../../../vitest.setup";
|
||||
import { todos, initializeTodoListener, cleanupTodoListener } from "./todos";
|
||||
import type { TodoItem } from "./todos";
|
||||
|
||||
describe("todos store", () => {
|
||||
beforeEach(() => {
|
||||
cleanupTodoListener();
|
||||
todos.clear();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts with an empty list", () => {
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set and clear", () => {
|
||||
it("can set todos directly", () => {
|
||||
const items: TodoItem[] = [
|
||||
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||
{ content: "Task 2", status: "in_progress", activeForm: "Doing task 2" },
|
||||
];
|
||||
todos.set(items);
|
||||
expect(get(todos)).toEqual(items);
|
||||
});
|
||||
|
||||
it("clear resets to empty array", () => {
|
||||
todos.set([{ content: "Task", status: "completed", activeForm: "Doing task" }]);
|
||||
todos.clear();
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("can update todos with a callback", () => {
|
||||
const initial: TodoItem[] = [
|
||||
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||
];
|
||||
todos.set(initial);
|
||||
todos.update((current) => [
|
||||
...current,
|
||||
{ content: "Task 2", status: "completed", activeForm: "Doing task 2" },
|
||||
]);
|
||||
expect(get(todos)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeTodoListener", () => {
|
||||
it("listens for claude:todo-update events", async () => {
|
||||
await initializeTodoListener();
|
||||
|
||||
const newTodos: TodoItem[] = [
|
||||
{ content: "Backend task", status: "in_progress", activeForm: "Running backend task" },
|
||||
];
|
||||
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||
|
||||
expect(get(todos)).toEqual(newTodos);
|
||||
});
|
||||
|
||||
it("does not register a duplicate listener when called twice", async () => {
|
||||
await initializeTodoListener();
|
||||
await initializeTodoListener();
|
||||
|
||||
const newTodos: TodoItem[] = [
|
||||
{ content: "Task", status: "pending", activeForm: "Doing task" },
|
||||
];
|
||||
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||
|
||||
// Should only have been set once (not doubled)
|
||||
expect(get(todos)).toEqual(newTodos);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupTodoListener", () => {
|
||||
it("stops listening for events after cleanup", async () => {
|
||||
await initializeTodoListener();
|
||||
cleanupTodoListener();
|
||||
|
||||
emitMockEvent("claude:todo-update", {
|
||||
todos: [{ content: "Task", status: "pending", activeForm: "Doing task" }],
|
||||
});
|
||||
|
||||
// Should not have been updated since we cleaned up
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is safe to call when not initialised", () => {
|
||||
expect(() => cleanupTodoListener()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+63
-1
@@ -21,6 +21,7 @@ import {
|
||||
handleConnectionStatusChange,
|
||||
handleNewUserMessage,
|
||||
} from "$lib/notifications/rules";
|
||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||
|
||||
interface StateChangePayload {
|
||||
state: CharacterState;
|
||||
@@ -220,7 +221,7 @@ export async function initializeTauriListeners() {
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
|
||||
);
|
||||
|
||||
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||
@@ -270,6 +271,67 @@ export async function initializeTauriListeners() {
|
||||
|
||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||
|
||||
// Per-conversation sound tracking — fires for any tab (active or background).
|
||||
// All sounds are driven from state-change events rather than a global store
|
||||
// subscription, so background tabs receive their sounds correctly and
|
||||
// switching tabs never replays a sound that has already fired.
|
||||
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
|
||||
if (resolvedConversationId) {
|
||||
const conv = get(claudeStore.conversations).get(resolvedConversationId);
|
||||
if (conv) {
|
||||
const previousState = conv.characterState;
|
||||
|
||||
// New response starting — clear all per-task sound flags.
|
||||
// Only reset when entering from a clean-slate state, not mid-task.
|
||||
// Transitioning from coding/searching/mcp/typing → thinking means we're
|
||||
// still within the same task (between tool calls), so the sound must not replay.
|
||||
const cleanSlateStates: CharacterState[] = ["idle", "success", "error"];
|
||||
if (mappedState === "thinking" && cleanSlateStates.includes(previousState)) {
|
||||
claudeStore.resetSoundState(resolvedConversationId);
|
||||
}
|
||||
|
||||
// Record when a long-running phase begins (used for the 2-second
|
||||
// minimum duration check before playing the completion sound).
|
||||
if (
|
||||
(mappedState === "coding" || mappedState === "searching") &&
|
||||
previousState !== "coding" &&
|
||||
previousState !== "searching"
|
||||
) {
|
||||
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
|
||||
}
|
||||
|
||||
// Task-start sound — fires once when work enters a long-running phase.
|
||||
if (
|
||||
(mappedState === "coding" || mappedState === "searching") &&
|
||||
previousState !== "coding" &&
|
||||
previousState !== "searching" &&
|
||||
!conv.taskStartSoundFired
|
||||
) {
|
||||
notificationManager.notifyTaskStart();
|
||||
claudeStore.markTaskStartSoundFired(resolvedConversationId);
|
||||
}
|
||||
|
||||
// Error sound — fires each time a new error state is entered.
|
||||
if (mappedState === "error" && previousState !== "error") {
|
||||
notificationManager.notifyError();
|
||||
}
|
||||
|
||||
// Permission sound — fires each time a permission request arrives.
|
||||
if (mappedState === "permission") {
|
||||
notificationManager.notifyPermission();
|
||||
}
|
||||
|
||||
// Completion sound — fires once per task after sufficient duration.
|
||||
if (mappedState === "success" && !conv.successSoundFired) {
|
||||
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
|
||||
if (duration > 2000) {
|
||||
notificationManager.notifySuccess();
|
||||
}
|
||||
claudeStore.markSuccessSoundFired(resolvedConversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update the conversation's state
|
||||
if (conversation_id) {
|
||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted");
|
||||
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
return Promise.resolve([]);
|
||||
case "list_clipboard_entries":
|
||||
return Promise.resolve([]);
|
||||
case "list_drafts":
|
||||
return Promise.resolve([]);
|
||||
case "cleanup_temp_files":
|
||||
return Promise.resolve();
|
||||
case "validate_directory":
|
||||
|
||||
Reference in New Issue
Block a user