generated from nhcarrigan/template
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e35063ee78 | |||
|
542d2eb315
|
|||
| 4134e11c88 | |||
|
8220ab6b85
|
|||
| 452fe185df | |||
|
a690a4969b
|
|||
| 2816e33257 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.11.0",
|
"version": "1.13.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"@codemirror/lang-wast": "6.0.2",
|
"@codemirror/lang-wast": "6.0.2",
|
||||||
"@codemirror/lang-xml": "6.1.0",
|
"@codemirror/lang-xml": "6.1.0",
|
||||||
"@codemirror/lang-yaml": "6.1.2",
|
"@codemirror/lang-yaml": "6.1.2",
|
||||||
"@codemirror/language": "6.12.2",
|
"@codemirror/language": "6.12.3",
|
||||||
"@codemirror/legacy-modes": "6.5.2",
|
"@codemirror/legacy-modes": "6.5.2",
|
||||||
"@codemirror/state": "6.5.4",
|
"@codemirror/state": "6.5.4",
|
||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
|
|||||||
Generated
+29
-29
@@ -69,8 +69,8 @@ importers:
|
|||||||
specifier: 6.1.2
|
specifier: 6.1.2
|
||||||
version: 6.1.2
|
version: 6.1.2
|
||||||
'@codemirror/language':
|
'@codemirror/language':
|
||||||
specifier: 6.12.2
|
specifier: 6.12.3
|
||||||
version: 6.12.2
|
version: 6.12.3
|
||||||
'@codemirror/legacy-modes':
|
'@codemirror/legacy-modes':
|
||||||
specifier: 6.5.2
|
specifier: 6.5.2
|
||||||
version: 6.5.2
|
version: 6.5.2
|
||||||
@@ -310,8 +310,8 @@ packages:
|
|||||||
'@codemirror/lang-yaml@6.1.2':
|
'@codemirror/lang-yaml@6.1.2':
|
||||||
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
|
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
|
||||||
|
|
||||||
'@codemirror/language@6.12.2':
|
'@codemirror/language@6.12.3':
|
||||||
resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
|
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
|
||||||
|
|
||||||
'@codemirror/legacy-modes@6.5.2':
|
'@codemirror/legacy-modes@6.5.2':
|
||||||
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
||||||
@@ -2200,14 +2200,14 @@ snapshots:
|
|||||||
|
|
||||||
'@codemirror/autocomplete@6.20.0':
|
'@codemirror/autocomplete@6.20.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
|
|
||||||
'@codemirror/commands@6.10.2':
|
'@codemirror/commands@6.10.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
@@ -2216,20 +2216,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/lang-html': 6.4.11
|
'@codemirror/lang-html': 6.4.11
|
||||||
'@codemirror/lang-javascript': 6.2.4
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@codemirror/lang-cpp@6.0.3':
|
'@codemirror/lang-cpp@6.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/cpp': 1.1.5
|
'@lezer/cpp': 1.1.5
|
||||||
|
|
||||||
'@codemirror/lang-css@6.3.1':
|
'@codemirror/lang-css@6.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/css': 1.3.0
|
'@lezer/css': 1.3.0
|
||||||
@@ -2237,7 +2237,7 @@ snapshots:
|
|||||||
'@codemirror/lang-go@6.0.1':
|
'@codemirror/lang-go@6.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/go': 1.0.1
|
'@lezer/go': 1.0.1
|
||||||
@@ -2247,7 +2247,7 @@ snapshots:
|
|||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/lang-css': 6.3.1
|
'@codemirror/lang-css': 6.3.1
|
||||||
'@codemirror/lang-javascript': 6.2.4
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
@@ -2256,13 +2256,13 @@ snapshots:
|
|||||||
|
|
||||||
'@codemirror/lang-java@6.0.2':
|
'@codemirror/lang-java@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/java': 1.1.3
|
'@lezer/java': 1.1.3
|
||||||
|
|
||||||
'@codemirror/lang-javascript@6.2.4':
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/lint': 6.9.3
|
'@codemirror/lint': 6.9.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
@@ -2271,13 +2271,13 @@ snapshots:
|
|||||||
|
|
||||||
'@codemirror/lang-json@6.0.2':
|
'@codemirror/lang-json@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/json': 1.0.3
|
'@lezer/json': 1.0.3
|
||||||
|
|
||||||
'@codemirror/lang-less@6.0.2':
|
'@codemirror/lang-less@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/lang-css': 6.3.1
|
'@codemirror/lang-css': 6.3.1
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
@@ -2286,7 +2286,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/lang-html': 6.4.11
|
'@codemirror/lang-html': 6.4.11
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
@@ -2295,7 +2295,7 @@ snapshots:
|
|||||||
'@codemirror/lang-php@6.0.2':
|
'@codemirror/lang-php@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/lang-html': 6.4.11
|
'@codemirror/lang-html': 6.4.11
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/php': 1.0.5
|
'@lezer/php': 1.0.5
|
||||||
@@ -2303,20 +2303,20 @@ snapshots:
|
|||||||
'@codemirror/lang-python@6.2.1':
|
'@codemirror/lang-python@6.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/python': 1.1.18
|
'@lezer/python': 1.1.18
|
||||||
|
|
||||||
'@codemirror/lang-rust@6.0.2':
|
'@codemirror/lang-rust@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/rust': 1.0.2
|
'@lezer/rust': 1.0.2
|
||||||
|
|
||||||
'@codemirror/lang-sass@6.0.2':
|
'@codemirror/lang-sass@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/lang-css': 6.3.1
|
'@codemirror/lang-css': 6.3.1
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/sass': 1.1.0
|
'@lezer/sass': 1.1.0
|
||||||
@@ -2324,7 +2324,7 @@ snapshots:
|
|||||||
'@codemirror/lang-sql@6.10.0':
|
'@codemirror/lang-sql@6.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
@@ -2334,14 +2334,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/lang-html': 6.4.11
|
'@codemirror/lang-html': 6.4.11
|
||||||
'@codemirror/lang-javascript': 6.2.4
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@codemirror/lang-wast@6.0.2':
|
'@codemirror/lang-wast@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
@@ -2349,7 +2349,7 @@ snapshots:
|
|||||||
'@codemirror/lang-xml@6.1.0':
|
'@codemirror/lang-xml@6.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
@@ -2358,14 +2358,14 @@ snapshots:
|
|||||||
'@codemirror/lang-yaml@6.1.2':
|
'@codemirror/lang-yaml@6.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@lezer/common': 1.5.0
|
'@lezer/common': 1.5.0
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
'@lezer/yaml': 1.0.3
|
'@lezer/yaml': 1.0.3
|
||||||
|
|
||||||
'@codemirror/language@6.12.2':
|
'@codemirror/language@6.12.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
@@ -2376,7 +2376,7 @@ snapshots:
|
|||||||
|
|
||||||
'@codemirror/legacy-modes@6.5.2':
|
'@codemirror/legacy-modes@6.5.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
|
|
||||||
'@codemirror/lint@6.9.3':
|
'@codemirror/lint@6.9.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2396,7 +2396,7 @@ snapshots:
|
|||||||
|
|
||||||
'@codemirror/theme-one-dark@6.1.3':
|
'@codemirror/theme-one-dark@6.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
@@ -3232,7 +3232,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
'@codemirror/commands': 6.10.2
|
'@codemirror/commands': 6.10.2
|
||||||
'@codemirror/language': 6.12.2
|
'@codemirror/language': 6.12.3
|
||||||
'@codemirror/lint': 6.9.3
|
'@codemirror/lint': 6.9.3
|
||||||
'@codemirror/search': 6.6.0
|
'@codemirror/search': 6.6.0
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
|
|||||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.11.0"
|
version = "1.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.11.0"
|
version = "1.13.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+138
-3
@@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
|
||||||
|
let data: serde_json::Value =
|
||||||
|
serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
data.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "No version field in response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_cli_latest_version() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch CLI version: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Registry returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
parse_npm_cli_version(&body)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct SavedFileInfo {
|
pub struct SavedFileInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -1433,6 +1464,7 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
pub struct MemoryFileInfo {
|
pub struct MemoryFileInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub heading: Option<String>,
|
pub heading: Option<String>,
|
||||||
|
pub last_modified: Option<String>, // Unix timestamp in seconds as a string
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -1504,7 +1536,11 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
|||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let heading = read_wsl_file_first_heading(&path);
|
let heading = read_wsl_file_first_heading(&path);
|
||||||
files.push(MemoryFileInfo { path, heading });
|
files.push(MemoryFileInfo {
|
||||||
|
path,
|
||||||
|
heading,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MemoryFilesResponse { files })
|
Ok(MemoryFilesResponse { files })
|
||||||
@@ -1574,14 +1610,23 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
// Sort files alphabetically
|
// Sort files alphabetically
|
||||||
memory_paths.sort();
|
memory_paths.sort();
|
||||||
|
|
||||||
// Read first heading from each file
|
// Read first heading and modification time from each file
|
||||||
let files = memory_paths
|
let files = memory_paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let heading = fs::read_to_string(&path)
|
let heading = fs::read_to_string(&path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|content| extract_first_heading(&content));
|
.and_then(|content| extract_first_heading(&content));
|
||||||
MemoryFileInfo { path, heading }
|
let last_modified = fs::metadata(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs().to_string());
|
||||||
|
MemoryFileInfo {
|
||||||
|
path,
|
||||||
|
heading,
|
||||||
|
last_modified,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -2547,6 +2592,32 @@ pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_binary_file(app: AppHandle, path: String) -> Result<(), String> {
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Convert the WSL Linux path (e.g. /tmp/file.pdf) to a Windows UNC path
|
||||||
|
// (e.g. \\wsl.localhost\Ubuntu\tmp\file.pdf) so the Windows shell can open it.
|
||||||
|
let output = std::process::Command::new("wsl")
|
||||||
|
.args(["wslpath", "-w", &path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let windows_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
app.opener()
|
||||||
|
.open_path(windows_path, None::<&str>)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
app.opener()
|
||||||
|
.open_path(path, None::<&str>)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2838,6 +2909,35 @@ mod tests {
|
|||||||
assert!(json.contains("null") || json.contains("release_notes"));
|
assert!(json.contains("null") || json.contains("release_notes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== parse_npm_cli_version tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_valid() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#;
|
||||||
|
let result = parse_npm_cli_version(json).unwrap();
|
||||||
|
assert_eq!(result, "2.1.72");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_missing_field() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_invalid_json() {
|
||||||
|
let result = parse_npm_cli_version("not json at all");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_non_string_version() {
|
||||||
|
let json = r#"{"version":123}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== SavedFileInfo struct tests ====================
|
// ==================== SavedFileInfo struct tests ====================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3232,4 +3332,39 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
|
|||||||
Some("Indented Heading".to_string())
|
Some("Indented Heading".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== open_binary_file E2E path conversion tests ====================
|
||||||
|
|
||||||
|
/// Build the wslpath command structure without executing it, for cross-platform CI testing.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_wslpath_command(path: &str) -> (String, Vec<String>) {
|
||||||
|
(
|
||||||
|
"wsl".to_string(),
|
||||||
|
vec!["wslpath".to_string(), "-w".to_string(), path.to_string()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_pdf() {
|
||||||
|
let (command, args) = build_wslpath_command("/tmp/mcp_output_abc123.pdf");
|
||||||
|
assert_eq!(command, "wsl");
|
||||||
|
assert_eq!(args.len(), 3);
|
||||||
|
assert_eq!(args[0], "wslpath");
|
||||||
|
assert_eq!(args[1], "-w");
|
||||||
|
assert_eq!(args[2], "/tmp/mcp_output_abc123.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_audio() {
|
||||||
|
let (command, args) = build_wslpath_command("/tmp/mcp_output_xyz789.mp3");
|
||||||
|
assert_eq!(command, "wsl");
|
||||||
|
assert_eq!(args[2], "/tmp/mcp_output_xyz789.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_preserves_path() {
|
||||||
|
let path = "/home/naomi/documents/report with spaces.pdf";
|
||||||
|
let (_, args) = build_wslpath_command(path);
|
||||||
|
assert_eq!(args[2], path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -34,6 +36,24 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_output_tokens: Option<u64>,
|
pub max_output_tokens: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_cron: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_include_git_instructions")]
|
||||||
|
pub include_git_instructions: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_enable_claudeai_mcp_servers")]
|
||||||
|
pub enable_claudeai_mcp_servers: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_memory_directory: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub model_overrides: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -168,6 +188,21 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task_loop_include_summary: bool,
|
pub task_loop_include_summary: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_cron: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_include_git_instructions")]
|
||||||
|
pub include_git_instructions: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_enable_claudeai_mcp_servers")]
|
||||||
|
pub enable_claudeai_mcp_servers: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_memory_directory: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub model_overrides: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -214,6 +249,11 @@ impl Default for HikariConfig {
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat".to_string(),
|
task_loop_commit_prefix: "feat".to_string(),
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: None,
|
||||||
|
model_overrides: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +298,14 @@ fn default_task_loop_commit_prefix() -> String {
|
|||||||
"feat".to_string()
|
"feat".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_include_git_instructions() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enable_claudeai_mcp_servers() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BudgetAction {
|
pub enum BudgetAction {
|
||||||
@@ -352,6 +400,11 @@ mod tests {
|
|||||||
assert!(!config.task_loop_auto_commit);
|
assert!(!config.task_loop_auto_commit);
|
||||||
assert_eq!(config.task_loop_commit_prefix, "feat");
|
assert_eq!(config.task_loop_commit_prefix, "feat");
|
||||||
assert!(!config.task_loop_include_summary);
|
assert!(!config.task_loop_include_summary);
|
||||||
|
assert!(!config.disable_cron);
|
||||||
|
assert!(config.include_git_instructions);
|
||||||
|
assert!(config.enable_claudeai_mcp_servers);
|
||||||
|
assert!(config.auto_memory_directory.is_none());
|
||||||
|
assert!(config.model_overrides.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -398,6 +451,14 @@ mod tests {
|
|||||||
task_loop_auto_commit: true,
|
task_loop_auto_commit: true,
|
||||||
task_loop_commit_prefix: "fix".to_string(),
|
task_loop_commit_prefix: "fix".to_string(),
|
||||||
task_loop_include_summary: true,
|
task_loop_include_summary: true,
|
||||||
|
disable_cron: true,
|
||||||
|
include_git_instructions: false,
|
||||||
|
enable_claudeai_mcp_servers: false,
|
||||||
|
auto_memory_directory: Some("/custom/memory".to_string()),
|
||||||
|
model_overrides: Some(HashMap::from([(
|
||||||
|
"claude-opus-4-6".to_string(),
|
||||||
|
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
|
||||||
|
)])),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
@@ -415,6 +476,19 @@ mod tests {
|
|||||||
assert!(deserialized.task_loop_auto_commit);
|
assert!(deserialized.task_loop_auto_commit);
|
||||||
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
||||||
assert!(deserialized.task_loop_include_summary);
|
assert!(deserialized.task_loop_include_summary);
|
||||||
|
assert!(deserialized.disable_cron);
|
||||||
|
assert!(!deserialized.include_git_instructions);
|
||||||
|
assert!(!deserialized.enable_claudeai_mcp_servers);
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.auto_memory_directory,
|
||||||
|
Some("/custom/memory".to_string())
|
||||||
|
);
|
||||||
|
assert!(deserialized.model_overrides.is_some());
|
||||||
|
let overrides = deserialized.model_overrides.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
overrides.get("claude-opus-4-6").map(String::as_str),
|
||||||
|
Some("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ pub fn run() {
|
|||||||
list_skills,
|
list_skills,
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
fetch_changelog,
|
fetch_changelog,
|
||||||
|
check_cli_latest_version,
|
||||||
save_temp_file,
|
save_temp_file,
|
||||||
register_temp_file,
|
register_temp_file,
|
||||||
get_temp_files,
|
get_temp_files,
|
||||||
@@ -222,6 +223,7 @@ pub fn run() {
|
|||||||
delete_draft,
|
delete_draft,
|
||||||
delete_all_drafts,
|
delete_all_drafts,
|
||||||
scan_project,
|
scan_project,
|
||||||
|
open_binary_file,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -280,6 +280,44 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ElicitationEvent {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub server_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ElicitationResultEvent {
|
||||||
|
pub action: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StopFailureEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop_reason: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PostCompactEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentStartEvent {
|
pub struct AgentStartEvent {
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
@@ -292,6 +330,26 @@ pub struct AgentStartEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parent_tool_use_id: Option<String>,
|
pub parent_tool_use_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorktreeInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub branch: String,
|
||||||
|
pub original_repo_directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorktreeEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
/// "create" or "remove"
|
||||||
|
pub event_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worktree: Option<WorktreeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -546,4 +604,141 @@ mod tests {
|
|||||||
panic!("Expected RateLimitEvent variant");
|
panic!("Expected RateLimitEvent variant");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_elicitation_event_serialization() {
|
||||||
|
let event = ElicitationEvent {
|
||||||
|
message: "Please provide the API endpoint".to_string(),
|
||||||
|
server_name: Some("my-server".to_string()),
|
||||||
|
request_id: Some("req-123".to_string()),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"message\":\"Please provide the API endpoint\""));
|
||||||
|
assert!(serialized.contains("\"server_name\":\"my-server\""));
|
||||||
|
assert!(serialized.contains("\"request_id\":\"req-123\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_elicitation_event_omits_none_fields() {
|
||||||
|
let event = ElicitationEvent {
|
||||||
|
message: "Enter your token".to_string(),
|
||||||
|
server_name: None,
|
||||||
|
request_id: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"message\":\"Enter your token\""));
|
||||||
|
assert!(!serialized.contains("server_name"));
|
||||||
|
assert!(!serialized.contains("request_id"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_elicitation_result_event_serialization() {
|
||||||
|
let event = ElicitationResultEvent {
|
||||||
|
action: "accept".to_string(),
|
||||||
|
request_id: Some("req-123".to_string()),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"action\":\"accept\""));
|
||||||
|
assert!(serialized.contains("\"request_id\":\"req-123\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_elicitation_result_event_cancel_omits_none_fields() {
|
||||||
|
let event = ElicitationResultEvent {
|
||||||
|
action: "cancel".to_string(),
|
||||||
|
request_id: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"action\":\"cancel\""));
|
||||||
|
assert!(!serialized.contains("request_id"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_failure_event_serialization() {
|
||||||
|
let event = StopFailureEvent {
|
||||||
|
stop_reason: Some("api_error".to_string()),
|
||||||
|
error_type: Some("rate_limit".to_string()),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||||
|
assert!(serialized.contains("\"error_type\":\"rate_limit\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_failure_event_omits_none_fields() {
|
||||||
|
let event = StopFailureEvent {
|
||||||
|
stop_reason: None,
|
||||||
|
error_type: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(!serialized.contains("stop_reason"));
|
||||||
|
assert!(!serialized.contains("error_type"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_failure_event_partial_fields() {
|
||||||
|
let event = StopFailureEvent {
|
||||||
|
stop_reason: Some("api_error".to_string()),
|
||||||
|
error_type: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||||
|
assert!(!serialized.contains("error_type"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_compact_event_serialization() {
|
||||||
|
let event = PostCompactEvent {
|
||||||
|
session_id: Some("sess-abc".to_string()),
|
||||||
|
conversation_id: Some("conv-123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"session_id\":\"sess-abc\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-123\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_compact_event_omits_none_fields() {
|
||||||
|
let event = PostCompactEvent {
|
||||||
|
session_id: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(!serialized.contains("session_id"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_compact_event_partial_fields() {
|
||||||
|
let event = PostCompactEvent {
|
||||||
|
session_id: Some("sess-xyz".to_string()),
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1174
-27
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.11.0",
|
"version": "1.13.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ vi.mock("$lib/stores/config", () => ({
|
|||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
max_output_tokens: null,
|
max_output_tokens: null,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -87,10 +91,15 @@ describe("slashCommands", () => {
|
|||||||
expect(commandNames).toContain("search");
|
expect(commandNames).toContain("search");
|
||||||
expect(commandNames).toContain("summarise");
|
expect(commandNames).toContain("summarise");
|
||||||
expect(commandNames).toContain("skill");
|
expect(commandNames).toContain("skill");
|
||||||
|
expect(commandNames).toContain("simplify");
|
||||||
|
expect(commandNames).toContain("loop");
|
||||||
|
expect(commandNames).toContain("batch");
|
||||||
|
expect(commandNames).toContain("memory");
|
||||||
|
expect(commandNames).toContain("context");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has 7 commands total", () => {
|
it("has 12 commands total", () => {
|
||||||
expect(slashCommands.length).toBe(7);
|
expect(slashCommands.length).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each command has required properties", () => {
|
it("each command has required properties", () => {
|
||||||
@@ -160,6 +169,52 @@ describe("slashCommands", () => {
|
|||||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("simplify command has correct metadata and source", () => {
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify");
|
||||||
|
expect(simplifyCmd).toBeDefined();
|
||||||
|
expect(simplifyCmd!.source).toBe("cli");
|
||||||
|
expect(simplifyCmd!.usage).toBe("/simplify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loop command has correct metadata and source", () => {
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop");
|
||||||
|
expect(loopCmd).toBeDefined();
|
||||||
|
expect(loopCmd!.source).toBe("cli");
|
||||||
|
expect(loopCmd!.usage).toBe("/loop [interval] [command]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batch command has correct metadata and source", () => {
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch");
|
||||||
|
expect(batchCmd).toBeDefined();
|
||||||
|
expect(batchCmd!.source).toBe("cli");
|
||||||
|
expect(batchCmd!.usage).toBe("/batch [tasks]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context command has correct metadata and source", () => {
|
||||||
|
const contextCmd = slashCommands.find((cmd) => cmd.name === "context");
|
||||||
|
expect(contextCmd).toBeDefined();
|
||||||
|
expect(contextCmd!.source).toBe("cli");
|
||||||
|
expect(contextCmd!.usage).toBe("/context");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app commands do not have source set", () => {
|
||||||
|
const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"];
|
||||||
|
appCommandNames.forEach((name) => {
|
||||||
|
const cmd = slashCommands.find((c) => c.name === name);
|
||||||
|
expect(cmd).toBeDefined();
|
||||||
|
expect(cmd!.source).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cli commands have source set to 'cli'", () => {
|
||||||
|
const cliCommandNames = ["simplify", "loop", "batch", "memory", "context"];
|
||||||
|
cliCommandNames.forEach((name) => {
|
||||||
|
const cmd = slashCommands.find((c) => c.name === name);
|
||||||
|
expect(cmd).toBeDefined();
|
||||||
|
expect(cmd!.source).toBe("cli");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseSlashCommand", () => {
|
describe("parseSlashCommand", () => {
|
||||||
@@ -342,6 +397,19 @@ describe("slashCommands", () => {
|
|||||||
expect(names).toContain("search");
|
expect(names).toContain("search");
|
||||||
expect(names).toContain("summarise");
|
expect(names).toContain("summarise");
|
||||||
expect(names).toContain("skill");
|
expect(names).toContain("skill");
|
||||||
|
expect(names).toContain("simplify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns /loop for /l prefix", () => {
|
||||||
|
const result = getMatchingCommands("/l");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("loop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns /batch for /b prefix", () => {
|
||||||
|
const result = getMatchingCommands("/b");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("batch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case insensitive", () => {
|
it("is case insensitive", () => {
|
||||||
@@ -412,6 +480,19 @@ describe("slashCommands", () => {
|
|||||||
expect(testCommand.description).toBe("A test command");
|
expect(testCommand.description).toBe("A test command");
|
||||||
expect(testCommand.usage).toBe("/test [arg]");
|
expect(testCommand.usage).toBe("/test [arg]");
|
||||||
expect(typeof testCommand.execute).toBe("function");
|
expect(typeof testCommand.execute).toBe("function");
|
||||||
|
expect(testCommand.source).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can create a cli-sourced slash command object", () => {
|
||||||
|
const cliCommand: SlashCommand = {
|
||||||
|
name: "cli-test",
|
||||||
|
description: "A CLI command",
|
||||||
|
usage: "/cli-test",
|
||||||
|
source: "cli",
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cliCommand.source).toBe("cli");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("execute can be async function", () => {
|
it("execute can be async function", () => {
|
||||||
@@ -715,6 +796,125 @@ describe("slashCommands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/simplify execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||||
|
await simplifyCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /simplify prompt to Claude when there is an active conversation", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||||
|
await simplifyCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/simplify",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/loop execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("5m /help");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /loop with args when args are provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("5m /help");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/loop 5m /help",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /loop without args when no args provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/loop",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/batch execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("task1, task2");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /batch with args when args are provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("task1, task2");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/batch task1, task2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /batch without args when no args provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/batch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/memory execute", () => {
|
||||||
|
it("opens the memory browser panel without requiring an active conversation", () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!;
|
||||||
|
memoryCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).not.toHaveBeenCalled();
|
||||||
|
expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send a prompt to Claude when executed", () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!;
|
||||||
|
memoryCmd.execute("");
|
||||||
|
expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/context execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!;
|
||||||
|
await contextCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /context prompt to Claude when there is an active conversation", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!;
|
||||||
|
await contextCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/context",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("/cd success path", () => {
|
describe("/cd success path", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
|||||||
import { searchState } from "$lib/stores/search";
|
import { searchState } from "$lib/stores/search";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
|
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
|
/** "cli" = built into Claude Code CLI; omitted = Hikari app command */
|
||||||
|
source?: "cli";
|
||||||
execute: (args: string) => Promise<void> | void;
|
execute: (args: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +67,11 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
max_output_tokens: config.max_output_tokens ?? null,
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +149,11 @@ async function startNewConversation(): Promise<void> {
|
|||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
max_output_tokens: config.max_output_tokens ?? null,
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,6 +244,74 @@ export const slashCommands: SlashCommand[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "simplify",
|
||||||
|
description: "Review changed code for reuse, quality, and efficiency (Claude Code built-in)",
|
||||||
|
usage: "/simplify",
|
||||||
|
source: "cli",
|
||||||
|
execute: async () => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invoke("send_prompt", { conversationId, message: "/simplify" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loop",
|
||||||
|
description: "Run a prompt or slash command on a recurring interval (Claude Code built-in)",
|
||||||
|
usage: "/loop [interval] [command]",
|
||||||
|
source: "cli",
|
||||||
|
execute: async (args: string) => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = args.trim() ? `/loop ${args.trim()}` : "/loop";
|
||||||
|
await invoke("send_prompt", { conversationId, message });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "batch",
|
||||||
|
description: "Process multiple tasks in a single Claude Code session (Claude Code built-in)",
|
||||||
|
usage: "/batch [tasks]",
|
||||||
|
source: "cli",
|
||||||
|
execute: async (args: string) => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = args.trim() ? `/batch ${args.trim()}` : "/batch";
|
||||||
|
await invoke("send_prompt", { conversationId, message });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory",
|
||||||
|
description: "Open the memory browser panel to view and manage memory files",
|
||||||
|
usage: "/memory",
|
||||||
|
source: "cli",
|
||||||
|
execute: () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context",
|
||||||
|
description:
|
||||||
|
"Show current context window usage with optimisation suggestions (Claude Code built-in)",
|
||||||
|
usage: "/context",
|
||||||
|
source: "cli",
|
||||||
|
execute: async () => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invoke("send_prompt", { conversationId, message: "/context" });
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "skill",
|
name: "skill",
|
||||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { fade, fly } from "svelte/transition";
|
|
||||||
import { cubicOut } from "svelte/easing";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
|
||||||
|
|
||||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
|
||||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
|
||||||
let showNotification = $state(false);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let unlisten: (() => void) | undefined;
|
|
||||||
|
|
||||||
const setupListener = async () => {
|
|
||||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
|
||||||
achievements.push(event.payload);
|
|
||||||
if (!showNotification) {
|
|
||||||
showNext();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setupListener();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (unlisten) {
|
|
||||||
unlisten();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function showNext() {
|
|
||||||
if (achievements.length > 0) {
|
|
||||||
currentAchievement = achievements.shift() || null;
|
|
||||||
showNotification = true;
|
|
||||||
|
|
||||||
// Auto-hide after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
showNotification = false;
|
|
||||||
// Show next achievement after animation completes
|
|
||||||
setTimeout(() => showNext(), 300);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss() {
|
|
||||||
showNotification = false;
|
|
||||||
// Show next achievement after animation completes
|
|
||||||
setTimeout(() => showNext(), 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRarityColor(rarity: string): string {
|
|
||||||
switch (rarity) {
|
|
||||||
case "legendary":
|
|
||||||
return "from-yellow-400 to-orange-500";
|
|
||||||
case "epic":
|
|
||||||
return "from-purple-400 to-pink-500";
|
|
||||||
case "rare":
|
|
||||||
return "from-blue-400 to-indigo-500";
|
|
||||||
default:
|
|
||||||
return "from-green-400 to-emerald-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAchievementRarity(id: string): string {
|
|
||||||
// Determine rarity based on achievement ID
|
|
||||||
if (id === "TokenMaster") return "legendary";
|
|
||||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"BlossomingCoder",
|
|
||||||
"CodeWizard",
|
|
||||||
"MasterBuilder",
|
|
||||||
"EnduranceChamp",
|
|
||||||
"DeepDive",
|
|
||||||
"CreativeCoder",
|
|
||||||
].includes(id)
|
|
||||||
)
|
|
||||||
return "rare";
|
|
||||||
return "common";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if showNotification && currentAchievement}
|
|
||||||
<div
|
|
||||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
|
||||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
|
||||||
out:fade={{ duration: 300 }}
|
|
||||||
>
|
|
||||||
<!-- Backdrop with animated gradient border -->
|
|
||||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
|
||||||
<!-- Animated gradient border -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
|
||||||
)} animate-pulse"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Main notification content -->
|
|
||||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
|
||||||
<button
|
|
||||||
onclick={dismiss}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
|
||||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
|
||||||
aria-label="Dismiss notification"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<!-- Icon with animated sparkles -->
|
|
||||||
<div class="relative flex-shrink-0">
|
|
||||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
|
||||||
|
|
||||||
<!-- Sparkle animations -->
|
|
||||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
|
||||||
<div
|
|
||||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
|
||||||
>
|
|
||||||
✨
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
|
||||||
✨
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Text content -->
|
|
||||||
<div class="flex-1 min-w-0 pt-1">
|
|
||||||
<h3
|
|
||||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Achievement Unlocked!
|
|
||||||
</h3>
|
|
||||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
|
||||||
{currentAchievement.achievement.name}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{currentAchievement.achievement.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Rarity badge -->
|
|
||||||
<div class="mt-2 inline-flex items-center">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
|
||||||
)} text-white capitalize"
|
|
||||||
>
|
|
||||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Celebration confetti effect (CSS only) -->
|
|
||||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
|
||||||
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
|
||||||
<div
|
|
||||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
|
||||||
getAchievementRarity(currentAchievement.achievement.id)
|
|
||||||
)} rounded-full animate-fall"
|
|
||||||
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
|
||||||
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes fall {
|
|
||||||
0% {
|
|
||||||
transform: translateY(-20px) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(400px) rotate(720deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fall {
|
|
||||||
animation: fall linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-delay-200 {
|
|
||||||
animation-delay: 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-delay-400 {
|
|
||||||
animation-delay: 400ms;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/**
|
|
||||||
* AchievementNotification Component Tests
|
|
||||||
*
|
|
||||||
* Tests the rarity classification and colour mapping logic used by the
|
|
||||||
* AchievementNotification component.
|
|
||||||
*
|
|
||||||
* What this component does:
|
|
||||||
* - Listens for "achievement:unlocked" Tauri events
|
|
||||||
* - Queues and displays achievement notifications one at a time
|
|
||||||
* - Each notification shows the achievement's name, icon, description, and rarity
|
|
||||||
* - A gradient border and badge colour correspond to the achievement's rarity
|
|
||||||
*
|
|
||||||
* Manual testing checklist:
|
|
||||||
* - [ ] Achievement notification slides in from the right
|
|
||||||
* - [ ] Notification auto-dismisses after 5 seconds
|
|
||||||
* - [ ] Dismiss button works immediately
|
|
||||||
* - [ ] Multiple achievements queue and display sequentially
|
|
||||||
* - [ ] Legendary achievements have a yellow-orange gradient
|
|
||||||
* - [ ] Epic achievements have a purple-pink gradient
|
|
||||||
* - [ ] Rare achievements have a blue-indigo gradient
|
|
||||||
* - [ ] Common achievements have a green-emerald gradient
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
function getAchievementRarity(id: string): string {
|
|
||||||
if (id === "TokenMaster") return "legendary";
|
|
||||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"BlossomingCoder",
|
|
||||||
"CodeWizard",
|
|
||||||
"MasterBuilder",
|
|
||||||
"EnduranceChamp",
|
|
||||||
"DeepDive",
|
|
||||||
"CreativeCoder",
|
|
||||||
].includes(id)
|
|
||||||
)
|
|
||||||
return "rare";
|
|
||||||
return "common";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRarityColor(rarity: string): string {
|
|
||||||
switch (rarity) {
|
|
||||||
case "legendary":
|
|
||||||
return "from-yellow-400 to-orange-500";
|
|
||||||
case "epic":
|
|
||||||
return "from-purple-400 to-pink-500";
|
|
||||||
case "rare":
|
|
||||||
return "from-blue-400 to-indigo-500";
|
|
||||||
default:
|
|
||||||
return "from-green-400 to-emerald-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
describe("getAchievementRarity", () => {
|
|
||||||
describe("legendary tier", () => {
|
|
||||||
it("classifies TokenMaster as legendary", () => {
|
|
||||||
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("epic tier", () => {
|
|
||||||
it("classifies CodeMachine as epic", () => {
|
|
||||||
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies Unstoppable as epic", () => {
|
|
||||||
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rare tier", () => {
|
|
||||||
it("classifies BlossomingCoder as rare", () => {
|
|
||||||
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies CodeWizard as rare", () => {
|
|
||||||
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies MasterBuilder as rare", () => {
|
|
||||||
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies EnduranceChamp as rare", () => {
|
|
||||||
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies DeepDive as rare", () => {
|
|
||||||
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies CreativeCoder as rare", () => {
|
|
||||||
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("common tier", () => {
|
|
||||||
it("classifies unknown IDs as common", () => {
|
|
||||||
expect(getAchievementRarity("FirstChat")).toBe("common");
|
|
||||||
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
|
||||||
expect(getAchievementRarity("")).toBe("common");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getRarityColor", () => {
|
|
||||||
it("returns yellow-to-orange gradient for legendary", () => {
|
|
||||||
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns purple-to-pink gradient for epic", () => {
|
|
||||||
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns blue-to-indigo gradient for rare", () => {
|
|
||||||
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns green-to-emerald gradient for common", () => {
|
|
||||||
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
|
||||||
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
|
|
||||||
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("end-to-end rarity pipeline", () => {
|
|
||||||
it("produces the correct colour for a legendary achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("TokenMaster"));
|
|
||||||
expect(color).toBe("from-yellow-400 to-orange-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for an epic achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("CodeMachine"));
|
|
||||||
expect(color).toBe("from-purple-400 to-pink-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for a rare achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("CodeWizard"));
|
|
||||||
expect(color).toBe("from-blue-400 to-indigo-500");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces the correct colour for a common achievement", () => {
|
|
||||||
const color = getRarityColor(getAchievementRarity("FirstChat"));
|
|
||||||
expect(color).toBe("from-green-400 to-emerald-500");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -282,8 +282,9 @@
|
|||||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
agent.status
|
agent.status
|
||||||
)}"
|
)}"
|
||||||
|
title={agent.agentId ? `ID: ${agent.agentId}` : undefined}
|
||||||
>
|
>
|
||||||
{getSubagentTypeLabel(agent.subagentType)}
|
{getSubagentTypeLabel(agent.agentType ?? agent.subagentType)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -308,6 +309,13 @@
|
|||||||
{agent.description}
|
{agent.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Model override badge -->
|
||||||
|
{#if agent.model}
|
||||||
|
<p class="mt-0.5 text-[10px] text-purple-400 truncate" title="Model: {agent.model}">
|
||||||
|
✦ {agent.model}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Status indicator -->
|
<!-- Status indicator -->
|
||||||
<div class="mt-1 flex items-center gap-1">
|
<div class="mt-1 flex items-center gap-1">
|
||||||
{#if agent.status === "running"}
|
{#if agent.status === "running"}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
const SUPPORTED_CLI_VERSION = "2.1.80";
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
let installedVersion = $state("Loading...");
|
||||||
|
let latestNpmVersion = $state<string | null>(null);
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
function compareVersions(a: string, b: string): number {
|
||||||
const aParts = a.split(".").map(Number);
|
const aParts = a.split(".").map(Number);
|
||||||
@@ -32,6 +33,15 @@
|
|||||||
return "current";
|
return "current";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let updateAvailable = $derived.by(() => {
|
||||||
|
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return false;
|
||||||
|
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchVersion() {
|
async function fetchVersion() {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("get_claude_version");
|
const result = await invoke<string>("get_claude_version");
|
||||||
@@ -42,13 +52,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchLatestNpmVersion() {
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>("check_cli_latest_version");
|
||||||
|
latestNpmVersion = result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check latest CLI version:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchVersion();
|
fetchVersion();
|
||||||
|
fetchLatestNpmVersion();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="cli-versions">
|
<div class="cli-versions">
|
||||||
<div class="cli-version">
|
<div
|
||||||
|
class="cli-version {updateAvailable ? 'update-available' : ''}"
|
||||||
|
title={updateAvailable
|
||||||
|
? `Update available: ${latestNpmVersion} — run: npm install -g @anthropic-ai/claude-code`
|
||||||
|
: "Installed CLI version"}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="terminal-icon"
|
class="terminal-icon"
|
||||||
width="14"
|
width="14"
|
||||||
@@ -64,6 +89,22 @@
|
|||||||
<line x1="12" y1="19" x2="20" y2="19" />
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="version-text">CLI {displayVersion}</span>
|
<span class="version-text">CLI {displayVersion}</span>
|
||||||
|
{#if updateAvailable}
|
||||||
|
<svg
|
||||||
|
class="update-icon"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="17 11 12 6 7 11" />
|
||||||
|
<line x1="12" y1="6" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||||
@@ -135,6 +176,27 @@
|
|||||||
color: var(--error-color, #f44336);
|
color: var(--error-color, #f44336);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cli-version.update-available {
|
||||||
|
border-color: var(--warning-color, #ff9800);
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-icon {
|
.terminal-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
const SUPPORTED_CLI_VERSION = "2.1.74";
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
function compareVersions(a: string, b: string): number {
|
||||||
const aParts = a.split(".").map(Number);
|
const aParts = a.split(".").map(Number);
|
||||||
@@ -41,7 +41,7 @@ describe("SUPPORTED_CLI_VERSION", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("matches the expected audited version", () => {
|
it("matches the expected audited version", () => {
|
||||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
expect(SUPPORTED_CLI_VERSION).toBe("2.1.74");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +128,55 @@ describe("compareVersions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 0 for exactly the supported version", () => {
|
it("returns 0 for exactly the supported version", () => {
|
||||||
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
expect(compareVersions("2.1.74", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirrors the updateAvailable derived logic in CliVersion.svelte
|
||||||
|
function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean {
|
||||||
|
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return false;
|
||||||
|
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("updateAvailable", () => {
|
||||||
|
it("returns false when latestNpmVersion is null", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is Loading...", () => {
|
||||||
|
expect(isUpdateAvailable("Loading...", "2.1.74")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is Unknown", () => {
|
||||||
|
expect(isUpdateAvailable("Unknown", "2.1.74")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed equals latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.74", "2.1.74")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is ahead of latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.75", "2.1.74")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when installed is behind latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70", "2.1.74")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when installed has a lower minor version", () => {
|
||||||
|
expect(isUpdateAvailable("2.0.99", "2.1.74")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles version strings with extra info like '2.1.70 (build 123)'", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.74")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unparseable installed version", () => {
|
||||||
|
expect(isUpdateAvailable("not-a-version", "2.1.74")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
max_output_tokens: null,
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
@@ -78,6 +83,8 @@
|
|||||||
let customUiFontPathInput = $state("");
|
let customUiFontPathInput = $state("");
|
||||||
let customUiFontFamilyInput = $state("");
|
let customUiFontFamilyInput = $state("");
|
||||||
let customUiFontStatus: string | null = $state(null);
|
let customUiFontStatus: string | null = $state(null);
|
||||||
|
let modelOverridesJson = $state("");
|
||||||
|
let modelOverridesError: string | null = $state(null);
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
is_logged_in: boolean;
|
is_logged_in: boolean;
|
||||||
@@ -107,6 +114,7 @@
|
|||||||
customFontFamilyInput = c.custom_font_family ?? "";
|
customFontFamilyInput = c.custom_font_family ?? "";
|
||||||
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||||
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||||
|
modelOverridesJson = c.model_overrides ? JSON.stringify(c.model_overrides, null, 2) : "";
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
@@ -137,11 +145,6 @@
|
|||||||
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||||
// Legacy (Claude 3.x)
|
|
||||||
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
|
||||||
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
|
||||||
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
|
||||||
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
@@ -197,6 +200,18 @@
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
|
modelOverridesError = null;
|
||||||
|
try {
|
||||||
|
if (modelOverridesJson.trim()) {
|
||||||
|
config.model_overrides = JSON.parse(modelOverridesJson) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
config.model_overrides = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
modelOverridesError = "Invalid JSON — please check your model overrides.";
|
||||||
|
isSaving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await configStore.saveConfig(config);
|
await configStore.saveConfig(config);
|
||||||
configStore.closeSidebar();
|
configStore.closeSidebar();
|
||||||
@@ -554,6 +569,38 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable Cron Scheduling -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_cron}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable cron scheduling</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_CRON=1</code> to prevent Claude from scheduling
|
||||||
|
recurring tasks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include Git Instructions -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.include_git_instructions}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Include git instructions</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
When disabled, sets <code class="font-mono">CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1</code> to
|
||||||
|
remove Claude's built-in commit and PR workflow guidance from its system prompt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Max Output Tokens -->
|
<!-- Max Output Tokens -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||||
@@ -563,13 +610,56 @@
|
|||||||
id="max-output-tokens"
|
id="max-output-tokens"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="Default (32000)"
|
max="128000"
|
||||||
|
placeholder="Default (model-dependent)"
|
||||||
bind:value={config.max_output_tokens}
|
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)]"
|
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">
|
<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
|
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code>. Maximum: 128k tokens
|
||||||
being cut off mid-reply
|
for Opus 4.6 and Sonnet 4.6 (64k default for Opus 4.6, 32k for other models). Increase if
|
||||||
|
responses are being cut off.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-memory Directory -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="auto-memory-dir" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||||
|
Auto-memory directory <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auto-memory-dir"
|
||||||
|
type="text"
|
||||||
|
placeholder="Leave blank to use default"
|
||||||
|
bind:value={config.auto_memory_directory}
|
||||||
|
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">
|
||||||
|
Custom directory for auto-memory storage. Passed via
|
||||||
|
<code class="font-mono">--settings autoMemoryDirectory</code>. Leave blank to use the
|
||||||
|
default (working directory).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Overrides -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="model-overrides" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||||
|
Model overrides <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="model-overrides"
|
||||||
|
rows={4}
|
||||||
|
placeholder={'{\n "claude-opus-4-6": "arn:aws:bedrock:..."\n}'}
|
||||||
|
bind:value={modelOverridesJson}
|
||||||
|
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)] font-mono resize-y"
|
||||||
|
></textarea>
|
||||||
|
{#if modelOverridesError}
|
||||||
|
<p class="text-xs text-red-500 mt-1">{modelOverridesError}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.).
|
||||||
|
Passed via <code class="font-mono">--settings modelOverrides</code>. Leave blank to use
|
||||||
|
defaults.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -629,6 +719,22 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable Claude.ai MCP Servers -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.enable_claudeai_mcp_servers}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Enable Claude.ai MCP servers</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
When disabled, sets <code class="font-mono">ENABLE_CLAUDEAI_MCP_SERVERS=false</code> to prevent
|
||||||
|
Claude Code from connecting to MCP servers configured in Claude.ai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Auto-Granted Tools Section -->
|
<!-- Auto-Granted Tools Section -->
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore, hasElicitationPending } from "$lib/stores/claude";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { ElicitationEvent } from "$lib/types/messages";
|
||||||
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
|
let isVisible = $state(false);
|
||||||
|
let elicitation: ElicitationEvent | null = $state(null);
|
||||||
|
let response = $state("");
|
||||||
|
let grantedToolsList: string[] = $state([]);
|
||||||
|
let workingDirectory = $state("");
|
||||||
|
|
||||||
|
hasElicitationPending.subscribe((pending) => {
|
||||||
|
isVisible = pending;
|
||||||
|
if (!pending) {
|
||||||
|
response = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.pendingElicitation.subscribe((e) => {
|
||||||
|
elicitation = e;
|
||||||
|
if (e) {
|
||||||
|
characterState.setState("permission");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
|
grantedToolsList = Array.from(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
||||||
|
workingDirectory = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmitAndReconnect() {
|
||||||
|
if (!elicitation || !response.trim()) return;
|
||||||
|
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
const responseText = response.trim();
|
||||||
|
const elicitationMessage = elicitation.message;
|
||||||
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
claudeStore.addLine("system", `MCP response submitted. Reconnecting with context...`);
|
||||||
|
claudeStore.clearElicitation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: grantedToolsList,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (conversationHistory) {
|
||||||
|
const contextMessage = `[CONTEXT RESTORATION]
|
||||||
|
I just responded to an MCP server elicitation request. Here's our conversation so far:
|
||||||
|
|
||||||
|
${conversationHistory}
|
||||||
|
|
||||||
|
The MCP server asked: "${elicitationMessage}"
|
||||||
|
My response: "${responseText}"
|
||||||
|
|
||||||
|
Please continue where we left off, taking my response into account.`;
|
||||||
|
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: contextMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reconnect:", error);
|
||||||
|
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
claudeStore.clearElicitation();
|
||||||
|
claudeStore.addLine("system", "MCP elicitation dismissed");
|
||||||
|
characterState.setTemporaryState("idle", 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!isVisible || !elicitation) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmit(): boolean {
|
||||||
|
return response.trim().length > 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isVisible && elicitation}
|
||||||
|
<div
|
||||||
|
class="elicitation-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="elicitation-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-xl">💬</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Request</h2>
|
||||||
|
{#if elicitation.server_name}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">from: {elicitation.server_name}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">Input required from MCP server</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-[var(--text-primary)]">{elicitation.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<textarea
|
||||||
|
bind:value={response}
|
||||||
|
placeholder="Type your response here..."
|
||||||
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={handleDismiss}
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleSubmitAndReconnect}
|
||||||
|
disabled={!canSubmit()}
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Submit & Reconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
const hasAttachments = attachments.length > 0;
|
const hasAttachments = attachments.length > 0;
|
||||||
|
|
||||||
// Need either a message or attachments to submit
|
// Need either a message or attachments to submit
|
||||||
if ((!message && !hasAttachments) || isSubmitting) return;
|
if ((!message && !hasAttachments) || isSubmitting || isProcessing) return;
|
||||||
|
|
||||||
// Check for slash commands first (these work even when disconnected)
|
// Check for slash commands first (these work even when disconnected)
|
||||||
if (message && isSlashCommand(message)) {
|
if (message && isSlashCommand(message)) {
|
||||||
@@ -339,6 +339,7 @@ User: ${formattedMessage}`;
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: messageToSend,
|
message: messageToSend,
|
||||||
});
|
});
|
||||||
|
claudeStore.setProcessing(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send prompt:", error);
|
console.error("Failed to send prompt:", error);
|
||||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
@@ -401,6 +402,11 @@ User: ${formattedMessage}`;
|
|||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -768,7 +774,7 @@ User: ${formattedMessage}`;
|
|||||||
|
|
||||||
async function handleQuickAction(prompt: string): Promise<void> {
|
async function handleQuickAction(prompt: string): Promise<void> {
|
||||||
// Quick actions send the prompt directly
|
// Quick actions send the prompt directly
|
||||||
if (!isConnected || isSubmitting) return;
|
if (!isConnected || isSubmitting || isProcessing) return;
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
addToHistory(prompt);
|
addToHistory(prompt);
|
||||||
@@ -793,6 +799,7 @@ User: ${formattedMessage}`;
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: prompt,
|
message: prompt,
|
||||||
});
|
});
|
||||||
|
claudeStore.setProcessing(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send quick action:", error);
|
console.error("Failed to send quick action:", error);
|
||||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
@@ -1018,7 +1025,7 @@ User: ${formattedMessage}`;
|
|||||||
placeholder={isConnected
|
placeholder={isConnected
|
||||||
? "Ask Hikari anything... (type / for commands)"
|
? "Ask Hikari anything... (type / for commands)"
|
||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isProcessing}
|
||||||
rows={1}
|
rows={1}
|
||||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { linkifyFilePaths } from "$lib/utils/filePaths";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -113,7 +115,8 @@
|
|||||||
let parsedHtml = $derived.by(() => {
|
let parsedHtml = $derived.by(() => {
|
||||||
try {
|
try {
|
||||||
const html = marked.parse(content) as string;
|
const html = marked.parse(content) as string;
|
||||||
return processSpoilers(html);
|
const withSpoilers = processSpoilers(html);
|
||||||
|
return linkifyFilePaths(withSpoilers);
|
||||||
} catch {
|
} catch {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
@@ -140,9 +143,18 @@
|
|||||||
function handleLinkClick(event: MouseEvent) {
|
function handleLinkClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const anchor = target.closest("a");
|
const anchor = target.closest("a");
|
||||||
if (anchor?.href) {
|
if (!anchor) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openUrl(anchor.href);
|
|
||||||
|
const filePath = anchor.dataset.filepath;
|
||||||
|
if (filePath) {
|
||||||
|
void invoke("open_binary_file", { path: filePath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.href) {
|
||||||
|
void openUrl(anchor.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,4 +465,27 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.file-link) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
color: var(--accent-primary, #f472b6);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.file-link:hover) {
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent);
|
||||||
|
color: var(--accent-secondary, #e879f9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* - [ ] Code blocks render with syntax highlighting and a copy button
|
* - [ ] Code blocks render with syntax highlighting and a copy button
|
||||||
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
||||||
* - [ ] Search query highlights matching text in non-code content
|
* - [ ] Search query highlights matching text in non-code content
|
||||||
* - [ ] Links open in the system browser via the Tauri opener
|
* - [ ] Regular links open in the system browser via the Tauri opener
|
||||||
|
* - [ ] Binary file links invoke open_binary_file (WSL-path-aware) instead of openPath
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
interface MemoryFileInfo {
|
||||||
path: string;
|
path: string;
|
||||||
heading: string | null;
|
heading: string | null;
|
||||||
|
last_modified?: string; // Unix timestamp in seconds as a string, optional for backwards compat
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoryFilesResponse {
|
interface MemoryFilesResponse {
|
||||||
@@ -17,7 +26,6 @@
|
|||||||
let fileContent: string = $state("");
|
let fileContent: string = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let isPanelOpen = $state(false);
|
|
||||||
|
|
||||||
async function loadMemoryFiles() {
|
async function loadMemoryFiles() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -58,37 +66,30 @@
|
|||||||
return file.heading ?? getFileName(file.path);
|
return file.heading ?? getFileName(file.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel() {
|
function formatLastModified(ts: string | undefined): string {
|
||||||
isPanelOpen = !isPanelOpen;
|
if (!ts) return "";
|
||||||
if (isPanelOpen && memoryFiles.length === 0) {
|
const date = new Date(Number(ts) * 1000);
|
||||||
loadMemoryFiles();
|
return date.toLocaleDateString(undefined, {
|
||||||
}
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
async function sendMemoryCommand() {
|
||||||
// Don't load on mount - only when panel is opened
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
await invoke("send_prompt", { conversationId, message: "/memory" });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen && memoryFiles.length === 0) {
|
||||||
|
loadMemoryFiles();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
{#if isOpen}
|
||||||
<svg
|
|
||||||
class="icon"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="label">Memory</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if isPanelOpen}
|
|
||||||
<div class="memory-panel">
|
<div class="memory-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
@@ -108,7 +109,40 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h3>Memory Files</h3>
|
<h3>Memory Files</h3>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
<div class="header-actions">
|
||||||
|
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
|
||||||
|
<svg
|
||||||
|
class="action-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick={loadMemoryFiles} class="action-btn" title="Refresh">
|
||||||
|
<svg
|
||||||
|
class="action-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close-btn" onclick={onClose} title="Close">
|
||||||
<svg
|
<svg
|
||||||
class="close-icon"
|
class="close-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -125,6 +159,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
{#if isLoading && memoryFiles.length === 0}
|
{#if isLoading && memoryFiles.length === 0}
|
||||||
@@ -181,7 +216,12 @@
|
|||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div class="file-info">
|
||||||
<span class="file-name">{getDisplayName(file)}</span>
|
<span class="file-name">{getDisplayName(file)}</span>
|
||||||
|
{#if file.last_modified}
|
||||||
|
<span class="file-date">{formatLastModified(file.last_modified)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,34 +270,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.memory-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel {
|
.memory-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -300,6 +312,32 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -394,7 +432,7 @@
|
|||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -423,6 +461,13 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -431,6 +476,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active .file-date {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
.file-viewer {
|
.file-viewer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { editorStore } from "$lib/stores/editor";
|
import { editorStore } from "$lib/stores/editor";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||||
|
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
import StatsDisplay from "./StatsDisplay.svelte";
|
import StatsDisplay from "./StatsDisplay.svelte";
|
||||||
import AboutPanel from "./AboutPanel.svelte";
|
import AboutPanel from "./AboutPanel.svelte";
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||||
import WorkflowPanel from "./WorkflowPanel.svelte";
|
import WorkflowPanel from "./WorkflowPanel.svelte";
|
||||||
|
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
|
||||||
import { injectTextStore } from "$lib/stores/projectContext";
|
import { injectTextStore } from "$lib/stores/projectContext";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
@@ -69,6 +71,10 @@
|
|||||||
let showChangelog = $state(false);
|
let showChangelog = $state(false);
|
||||||
let showTaskLoop = $state(false);
|
let showTaskLoop = $state(false);
|
||||||
let showWorkflowPanel = $state(false);
|
let showWorkflowPanel = $state(false);
|
||||||
|
let showMemoryPanel = $state(false);
|
||||||
|
memoryBrowserStore.subscribe((s) => {
|
||||||
|
showMemoryPanel = s.isOpen;
|
||||||
|
});
|
||||||
|
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
@@ -176,6 +182,19 @@
|
|||||||
<span>Session History</span>
|
<span>Session History</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Memory Manager -->
|
||||||
|
<button onclick={menuAction(() => memoryBrowserStore.open())} class="nav-item">
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Memory Manager</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- To-Do List -->
|
<!-- To-Do List -->
|
||||||
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
|
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
|
||||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -547,6 +566,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showMemoryPanel}
|
||||||
|
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => memoryBrowserStore.close()} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showWorkflowPanel}
|
{#if showWorkflowPanel}
|
||||||
<WorkflowPanel
|
<WorkflowPanel
|
||||||
onClose={() => (showWorkflowPanel = false)}
|
onClose={() => (showWorkflowPanel = false)}
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
api_key: config.api_key || null,
|
api_key: config.api_key || null,
|
||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: newGrantedTools,
|
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* PermissionModal Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions used by the PermissionModal component.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays pending permission requests from Claude Code
|
||||||
|
* - Allows the user to approve or dismiss permission requests
|
||||||
|
* - On approval, reconnects Claude with the newly granted tools merged with
|
||||||
|
* `auto_granted_tools` from config (bug fix: issue #198)
|
||||||
|
* - Restores conversation context after reconnecting
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Permission modal appears when Claude requests a tool not in allowed_tools
|
||||||
|
* - [ ] All permissions are pre-selected by default when modal opens
|
||||||
|
* - [ ] "Select All" and "Select None" buttons work correctly
|
||||||
|
* - [ ] "Already Granted" badge appears for tools already in the session grant list
|
||||||
|
* - [ ] Approving permissions reconnects Claude and restores conversation context
|
||||||
|
* - [ ] After reconnecting, auto_granted_tools are still respected (no re-prompting)
|
||||||
|
* - [ ] Dismissing the modal clears pending permissions without reconnecting
|
||||||
|
* - [ ] Enter key approves selected permissions
|
||||||
|
* - [ ] Escape key dismisses the modal
|
||||||
|
* - [ ] Character enters "permission" state when modal appears
|
||||||
|
* - [ ] Input details are shown in a collapsible "View details" section
|
||||||
|
*
|
||||||
|
* Note: The `handleApproveAndReconnect` function cannot be unit tested here
|
||||||
|
* because it depends on Tauri IPC calls (`invoke("stop_claude")`,
|
||||||
|
* `invoke("start_claude")`, `invoke("send_prompt")`). The critical bug fix
|
||||||
|
* (including `auto_granted_tools` in the reconnect's `allowed_tools`) is
|
||||||
|
* covered by the `buildAllowedToolsList` tests below, which replicate the
|
||||||
|
* exact merging logic from the component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the allowed-tools merging logic from PermissionModal's
|
||||||
|
* handleApproveAndReconnect. This is the fix for issue #198: previously,
|
||||||
|
* `auto_granted_tools` were not included when reconnecting, causing them to
|
||||||
|
* be silently dropped and prompting the user again on subsequent requests.
|
||||||
|
*/
|
||||||
|
function buildAllowedToolsList(
|
||||||
|
sessionGrantedTools: string[],
|
||||||
|
newlyGrantedTools: string[],
|
||||||
|
autoGrantedTools: string[]
|
||||||
|
): string[] {
|
||||||
|
return [...new Set([...sessionGrantedTools, ...newlyGrantedTools, ...autoGrantedTools])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the formatInput helper from PermissionModal, used to display
|
||||||
|
* the tool input JSON in the permission details section.
|
||||||
|
*/
|
||||||
|
function formatInput(input: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the isToolAlreadyGranted helper from PermissionModal.
|
||||||
|
*/
|
||||||
|
function isToolAlreadyGranted(toolName: string, grantedToolsList: string[]): boolean {
|
||||||
|
return grantedToolsList.includes(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("buildAllowedToolsList", () => {
|
||||||
|
it("merges session-granted, newly-granted, and auto-granted tools", () => {
|
||||||
|
const result = buildAllowedToolsList(["Bash"], ["Glob"], ["Read"]);
|
||||||
|
expect(result).toContain("Bash");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
expect(result).toContain("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates tools that appear in multiple lists", () => {
|
||||||
|
const result = buildAllowedToolsList(["Read", "Bash"], ["Read"], ["Read", "Write"]);
|
||||||
|
const readCount = result.filter((t) => t === "Read").length;
|
||||||
|
expect(readCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves auto_granted_tools even when session list is empty", () => {
|
||||||
|
const result = buildAllowedToolsList([], ["Bash"], ["Read", "Glob"]);
|
||||||
|
expect(result).toContain("Read");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
expect(result).toContain("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only auto_granted_tools when no other grants exist", () => {
|
||||||
|
const result = buildAllowedToolsList([], [], ["Read"]);
|
||||||
|
expect(result).toEqual(["Read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when all lists are empty", () => {
|
||||||
|
const result = buildAllowedToolsList([], [], []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the bug scenario from issue #198", () => {
|
||||||
|
// Scenario: user has Read in auto_granted_tools.
|
||||||
|
// Session starts correctly with Read allowed.
|
||||||
|
// User approves Bash via permission modal.
|
||||||
|
// Before fix: reconnect only passed [Bash], dropping Read.
|
||||||
|
// After fix: reconnect passes [Bash, Read].
|
||||||
|
const sessionGrantedTools: string[] = []; // no prior session grants
|
||||||
|
const newlyGrantedTools = ["Bash"]; // just approved via modal
|
||||||
|
const autoGrantedTools = ["Read"]; // configured default
|
||||||
|
|
||||||
|
const result = buildAllowedToolsList(sessionGrantedTools, newlyGrantedTools, autoGrantedTools);
|
||||||
|
|
||||||
|
expect(result).toContain("Bash");
|
||||||
|
expect(result).toContain("Read"); // Must be present — this was the bug!
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatInput", () => {
|
||||||
|
it("formats a simple object as pretty-printed JSON", () => {
|
||||||
|
const result = formatInput({ file_path: "/home/naomi/test.ts" });
|
||||||
|
expect(result).toBe(JSON.stringify({ file_path: "/home/naomi/test.ts" }, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a nested object correctly", () => {
|
||||||
|
const input = { command: "ls", args: ["-la", "/home"] };
|
||||||
|
const result = formatInput(input);
|
||||||
|
expect(result).toContain('"command": "ls"');
|
||||||
|
expect(result).toContain('"args"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats an empty object as '{}'", () => {
|
||||||
|
const result = formatInput({});
|
||||||
|
expect(result).toBe("{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isToolAlreadyGranted", () => {
|
||||||
|
it("returns true when the tool is in the granted list", () => {
|
||||||
|
expect(isToolAlreadyGranted("Read", ["Read", "Bash"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the tool is not in the granted list", () => {
|
||||||
|
expect(isToolAlreadyGranted("Write", ["Read", "Bash"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an empty granted list", () => {
|
||||||
|
expect(isToolAlreadyGranted("Read", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-sensitive", () => {
|
||||||
|
expect(isToolAlreadyGranted("read", ["Read"])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
>
|
>
|
||||||
<span class="command-name">/{command.name}</span>
|
<span class="command-name">/{command.name}</span>
|
||||||
<span class="command-description">{command.description}</span>
|
<span class="command-description">{command.description}</span>
|
||||||
|
{#if command.source === "cli"}
|
||||||
|
<span class="cli-badge">CLI</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,5 +85,19 @@
|
|||||||
.command-description {
|
.command-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||||
let workingDirectory = $state("");
|
let workingDirectory = $state("");
|
||||||
|
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
|
||||||
let selectedDirectory = $state("/home/naomi");
|
let selectedDirectory = $state("/home/naomi");
|
||||||
let isConnecting = $state(false);
|
let isConnecting = $state(false);
|
||||||
let grantedToolsList: string[] = $state([]);
|
let grantedToolsList: string[] = $state([]);
|
||||||
@@ -87,6 +88,11 @@
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat",
|
task_loop_commit_prefix: "feat",
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -115,6 +121,10 @@
|
|||||||
workingDirectory = dir;
|
workingDirectory = dir;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
claudeStore.worktreeInfo.subscribe((info) => {
|
||||||
|
worktreeInfo = info;
|
||||||
|
});
|
||||||
|
|
||||||
claudeStore.grantedTools.subscribe((tools) => {
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
grantedToolsList = Array.from(tools);
|
grantedToolsList = Array.from(tools);
|
||||||
});
|
});
|
||||||
@@ -150,6 +160,7 @@
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
const activeConversationForName = get(conversationsStore.activeConversation);
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
@@ -163,6 +174,11 @@
|
|||||||
use_worktree: currentConfig.use_worktree ?? false,
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
|
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||||
|
model_overrides: currentConfig.model_overrides || null,
|
||||||
|
session_name: activeConversationForName?.name || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,6 +336,11 @@
|
|||||||
use_worktree: currentConfig.use_worktree ?? false,
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
|
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||||
|
model_overrides: currentConfig.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,6 +413,22 @@
|
|||||||
{workingDirectory}
|
{workingDirectory}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if worktreeInfo}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs"
|
||||||
|
title="Worktree: {worktreeInfo.name} | Base: {worktreeInfo.original_repo_directory}"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{worktreeInfo.branch}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-600">cwd:</span>
|
<span class="text-sm text-gray-600">cwd:</span>
|
||||||
|
|||||||
@@ -218,6 +218,11 @@
|
|||||||
use_worktree: cfg.use_worktree ?? false,
|
use_worktree: cfg.use_worktree ?? false,
|
||||||
disable_1m_context: cfg.disable_1m_context ?? false,
|
disable_1m_context: cfg.disable_1m_context ?? false,
|
||||||
max_output_tokens: cfg.max_output_tokens ?? null,
|
max_output_tokens: cfg.max_output_tokens ?? null,
|
||||||
|
include_git_instructions: cfg.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: cfg.auto_memory_directory || null,
|
||||||
|
model_overrides: cfg.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { toastStore, getAchievementRarity, getRarityColour } from "$lib/stores/toasts";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
|
||||||
|
const toasts = toastStore;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||||
|
toastStore.addAchievement(event.payload.achievement);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed top-20 right-4 z-50 flex flex-col gap-3 items-end">
|
||||||
|
{#each $toasts as toast (toast.id)}
|
||||||
|
<div in:fly={{ x: 300, duration: 500, easing: cubicOut }} out:fade={{ duration: 300 }}>
|
||||||
|
{#if toast.kind === "info"}
|
||||||
|
<!-- Info toast -->
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 shadow-lg flex items-center gap-2 max-w-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xl shrink-0">{toast.icon}</span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)] flex-1">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onclick={() => toastStore.remove(toast.id)}
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors shrink-0"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if toast.kind === "achievement"}
|
||||||
|
{@const rarity = getAchievementRarity(toast.achievement.id)}
|
||||||
|
{@const colour = getRarityColour(rarity)}
|
||||||
|
<!-- Achievement toast -->
|
||||||
|
<div class="relative p-[2px] rounded-lg overflow-hidden max-w-sm">
|
||||||
|
<!-- Animated gradient border -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r {colour} animate-pulse"></div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
onclick={() => toastStore.remove(toast.id)}
|
||||||
|
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Icon with animated sparkles -->
|
||||||
|
<div class="relative flex-shrink-0">
|
||||||
|
<div class="text-5xl animate-bounce">{toast.achievement.icon}</div>
|
||||||
|
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||||
|
>
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"
|
||||||
|
>
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text content -->
|
||||||
|
<div class="flex-1 min-w-0 pt-1">
|
||||||
|
<h3
|
||||||
|
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
Achievement Unlocked!
|
||||||
|
</h3>
|
||||||
|
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||||
|
{toast.achievement.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{toast.achievement.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Rarity badge -->
|
||||||
|
<div class="mt-2 inline-flex items-center">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {colour} text-white capitalize"
|
||||||
|
>
|
||||||
|
{rarity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confetti particles -->
|
||||||
|
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||||
|
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||||
|
<div
|
||||||
|
class="absolute w-2 h-2 bg-gradient-to-br {colour} rounded-full animate-fall"
|
||||||
|
style="left: {(confettiIndex * 11) % 100}%; animation-delay: {(confettiIndex *
|
||||||
|
0.3) %
|
||||||
|
2}s; animation-duration: {2 + ((confettiIndex * 0.25) % 2)}s;"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if toast.kind === "update"}
|
||||||
|
<!-- Update toast -->
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg p-4 shadow-lg max-w-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="text-2xl">🎉</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(toast.releaseUrl)}
|
||||||
|
class="text-[var(--accent-primary)] font-mono hover:underline text-sm"
|
||||||
|
>
|
||||||
|
{toast.latestVersion}
|
||||||
|
</button>
|
||||||
|
<p class="text-[var(--text-muted)] text-xs mt-1">
|
||||||
|
Current version: {toast.currentVersion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => toastStore.remove(toast.id)}
|
||||||
|
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-20px) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(400px) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fall {
|
||||||
|
animation: fall linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-200 {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-400 {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { UpdateInfo } from "$lib/types/messages";
|
|
||||||
import { configStore } from "$lib/stores/config";
|
|
||||||
|
|
||||||
let updateInfo = $state<UpdateInfo | null>(null);
|
|
||||||
let dismissed = $state(false);
|
|
||||||
|
|
||||||
export async function checkForUpdates() {
|
|
||||||
// Check if update checks are enabled
|
|
||||||
const config = configStore.getConfig();
|
|
||||||
if (!config.update_checks_enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const info = await invoke<UpdateInfo>("check_for_updates");
|
|
||||||
if (info.has_update) {
|
|
||||||
updateInfo = info;
|
|
||||||
dismissed = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error("Failed to check for updates:", errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss() {
|
|
||||||
dismissed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRelease() {
|
|
||||||
if (updateInfo?.release_url) {
|
|
||||||
await openUrl(updateInfo.release_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if updateInfo && !dismissed}
|
|
||||||
<div
|
|
||||||
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="text-2xl">🎉</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
|
||||||
<p class="text-[var(--text-secondary)] text-sm mb-2">
|
|
||||||
A new version of Hikari Desktop is available:
|
|
||||||
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-[var(--text-muted)] text-xs mb-3">
|
|
||||||
Current version: {updateInfo.current_version}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
|
|
||||||
View Release
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={dismiss}
|
|
||||||
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onclick={dismiss}
|
|
||||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
allowed_tools: grantedToolsList,
|
allowed_tools: grantedToolsList,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
session_name: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,22 @@ describe("agents store", () => {
|
|||||||
expect(agents[0]).toMatchObject(agent);
|
expect(agents[0]).toMatchObject(agent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves model field when provided", () => {
|
||||||
|
const agent = createMockAgent({ model: "claude-opus-4-6" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].model).toBe("claude-opus-4-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves model undefined when not provided", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns a character name and avatar to added agents", () => {
|
it("assigns a character name and avatar to added agents", () => {
|
||||||
const agent = createMockAgent();
|
const agent = createMockAgent();
|
||||||
agentStore.addAgent(conversationId, agent);
|
agentStore.addAgent(conversationId, agent);
|
||||||
@@ -121,6 +137,28 @@ describe("agents store", () => {
|
|||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents[0].agentId).toBeUndefined();
|
expect(agents[0].agentId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates agentType when provided alongside agentId", () => {
|
||||||
|
const agent = createMockAgent({ agentId: undefined });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBe("agent-abc123");
|
||||||
|
expect(agents[0].agentType).toBe("general-purpose");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set agentType when not provided", () => {
|
||||||
|
const agent = createMockAgent({ agentId: undefined });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBe("agent-abc123");
|
||||||
|
expect(agents[0].agentType).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("endAgent", () => {
|
describe("endAgent", () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function createAgentStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
updateAgentId(conversationId: string, toolUseId: string, agentId: string, agentType?: string) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const agents = state[conversationId];
|
const agents = state[conversationId];
|
||||||
if (!agents) return state;
|
if (!agents) return state;
|
||||||
@@ -36,6 +36,7 @@ function createAgentStore() {
|
|||||||
updated[agentIndex] = {
|
updated[agentIndex] = {
|
||||||
...updated[agentIndex],
|
...updated[agentIndex],
|
||||||
agentId,
|
agentId,
|
||||||
|
...(agentType !== undefined ? { agentType } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ export const claudeStore = {
|
|||||||
terminalLines: conversationsStore.terminalLines,
|
terminalLines: conversationsStore.terminalLines,
|
||||||
pendingPermission: conversationsStore.pendingPermission,
|
pendingPermission: conversationsStore.pendingPermission,
|
||||||
pendingQuestion: conversationsStore.pendingQuestion,
|
pendingQuestion: conversationsStore.pendingQuestion,
|
||||||
|
pendingElicitation: conversationsStore.pendingElicitation,
|
||||||
isProcessing: conversationsStore.isProcessing,
|
isProcessing: conversationsStore.isProcessing,
|
||||||
grantedTools: conversationsStore.grantedTools,
|
grantedTools: conversationsStore.grantedTools,
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||||
attachments: conversationsStore.attachments,
|
attachments: conversationsStore.attachments,
|
||||||
|
worktreeInfo: conversationsStore.worktreeInfo,
|
||||||
|
|
||||||
// New conversation-aware subscriptions
|
// New conversation-aware subscriptions
|
||||||
conversations: conversationsStore.conversations,
|
conversations: conversationsStore.conversations,
|
||||||
@@ -41,6 +43,7 @@ export const claudeStore = {
|
|||||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
||||||
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
||||||
setProcessing: conversationsStore.setProcessing,
|
setProcessing: conversationsStore.setProcessing,
|
||||||
|
setProcessingForConversation: conversationsStore.setProcessingForConversation,
|
||||||
addLine: conversationsStore.addLine,
|
addLine: conversationsStore.addLine,
|
||||||
addLineToConversation: conversationsStore.addLineToConversation,
|
addLineToConversation: conversationsStore.addLineToConversation,
|
||||||
updateLine: conversationsStore.updateLine,
|
updateLine: conversationsStore.updateLine,
|
||||||
@@ -55,6 +58,10 @@ export const claudeStore = {
|
|||||||
clearQuestion: conversationsStore.clearQuestion,
|
clearQuestion: conversationsStore.clearQuestion,
|
||||||
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
||||||
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
||||||
|
requestElicitation: conversationsStore.requestElicitation,
|
||||||
|
clearElicitation: conversationsStore.clearElicitation,
|
||||||
|
requestElicitationForConversation: conversationsStore.requestElicitationForConversation,
|
||||||
|
clearElicitationForConversation: conversationsStore.clearElicitationForConversation,
|
||||||
grantTool: conversationsStore.grantTool,
|
grantTool: conversationsStore.grantTool,
|
||||||
revokeAllTools: conversationsStore.revokeAllTools,
|
revokeAllTools: conversationsStore.revokeAllTools,
|
||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
@@ -69,6 +76,9 @@ export const claudeStore = {
|
|||||||
// Draft text (per-tab input persistence)
|
// Draft text (per-tab input persistence)
|
||||||
setDraftText: conversationsStore.setDraftText,
|
setDraftText: conversationsStore.setDraftText,
|
||||||
|
|
||||||
|
// Worktree info (per-conversation)
|
||||||
|
setWorktreeInfo: conversationsStore.setWorktreeInfo,
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
createConversation: conversationsStore.createConversation,
|
createConversation: conversationsStore.createConversation,
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
deleteConversation: conversationsStore.deleteConversation,
|
||||||
@@ -121,6 +131,12 @@ export const hasQuestionPending = derived(
|
|||||||
($conversation) => $conversation?.pendingQuestion !== null
|
($conversation) => $conversation?.pendingQuestion !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hasElicitationPending = derived(
|
||||||
|
claudeStore.activeConversation,
|
||||||
|
($conversation) =>
|
||||||
|
$conversation?.pendingElicitation !== null && $conversation?.pendingElicitation !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Derived store to check if Claude is currently processing (can be interrupted)
|
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||||
export const isClaudeProcessing = derived(
|
export const isClaudeProcessing = derived(
|
||||||
[claudeStore.connectionStatus, characterState],
|
[claudeStore.connectionStatus, characterState],
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ describe("config store", () => {
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat",
|
task_loop_commit_prefix: "feat",
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -279,6 +284,11 @@ describe("config store", () => {
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat",
|
task_loop_commit_prefix: "feat",
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -893,6 +903,11 @@ describe("config store", () => {
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat",
|
task_loop_commit_prefix: "feat",
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ export interface HikariConfig {
|
|||||||
task_loop_auto_commit: boolean;
|
task_loop_auto_commit: boolean;
|
||||||
task_loop_commit_prefix: string;
|
task_loop_commit_prefix: string;
|
||||||
task_loop_include_summary: boolean;
|
task_loop_include_summary: boolean;
|
||||||
|
// Disable cron scheduling
|
||||||
|
disable_cron: boolean;
|
||||||
|
// Git instructions setting
|
||||||
|
include_git_instructions: boolean;
|
||||||
|
// Claude.ai MCP servers setting
|
||||||
|
enable_claudeai_mcp_servers: boolean;
|
||||||
|
// Auto-memory directory
|
||||||
|
auto_memory_directory: string | null;
|
||||||
|
// Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.)
|
||||||
|
model_overrides: Record<string, string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -134,6 +144,11 @@ const defaultConfig: HikariConfig = {
|
|||||||
task_loop_auto_commit: false,
|
task_loop_auto_commit: false,
|
||||||
task_loop_commit_prefix: "feat",
|
task_loop_commit_prefix: "feat",
|
||||||
task_loop_include_summary: false,
|
task_loop_include_summary: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -561,3 +561,147 @@ describe("draft text persistence", () => {
|
|||||||
expect(conversation.draftText).toBe("");
|
expect(conversation.draftText).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("worktreeInfo state management", () => {
|
||||||
|
it("initialises worktreeInfo as null", () => {
|
||||||
|
const conversation = { worktreeInfo: null };
|
||||||
|
expect(conversation.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores worktreeInfo when a worktree is created", () => {
|
||||||
|
const info = {
|
||||||
|
name: "worktree-abc",
|
||||||
|
path: "/tmp/worktrees/worktree-abc",
|
||||||
|
branch: "feat/my-feature",
|
||||||
|
original_repo_directory: "/home/naomi/code/project",
|
||||||
|
};
|
||||||
|
const conversation = { worktreeInfo: null as typeof info | null };
|
||||||
|
conversation.worktreeInfo = info;
|
||||||
|
|
||||||
|
expect(conversation.worktreeInfo?.branch).toBe("feat/my-feature");
|
||||||
|
expect(conversation.worktreeInfo?.name).toBe("worktree-abc");
|
||||||
|
expect(conversation.worktreeInfo?.original_repo_directory).toBe("/home/naomi/code/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears worktreeInfo when a worktree is removed", () => {
|
||||||
|
const info = {
|
||||||
|
name: "worktree-abc",
|
||||||
|
path: "/tmp/worktrees/worktree-abc",
|
||||||
|
branch: "feat/my-feature",
|
||||||
|
original_repo_directory: "/home/naomi/code/project",
|
||||||
|
};
|
||||||
|
const conversation = { worktreeInfo: info as typeof info | null };
|
||||||
|
conversation.worktreeInfo = null;
|
||||||
|
|
||||||
|
expect(conversation.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores worktreeInfo independently per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { worktreeInfo: null as { branch: string } | null }],
|
||||||
|
["conv-2", { worktreeInfo: null as { branch: string } | null }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const conv1 = conversations.get("conv-1");
|
||||||
|
if (conv1) conv1.worktreeInfo = { branch: "feat/one" };
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.worktreeInfo?.branch).toBe("feat/one");
|
||||||
|
expect(conversations.get("conv-2")?.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isProcessing state management", () => {
|
||||||
|
it("starts as false by default", () => {
|
||||||
|
const conversation = { id: "conv-1", isProcessing: false };
|
||||||
|
expect(conversation.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation sets processing true for the target conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
["conv-2", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("conv-1", true);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(true);
|
||||||
|
expect(conversations.get("conv-2")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation resets processing to false", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: true, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("conv-1", false);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation does nothing for unknown conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("unknown", true);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isProcessing is cleared when idle state arrives", () => {
|
||||||
|
const conversation = { isProcessing: true, characterState: "thinking" };
|
||||||
|
|
||||||
|
const terminalStates = ["idle", "success", "error"];
|
||||||
|
const handleStateChange = (state: string) => {
|
||||||
|
conversation.characterState = state;
|
||||||
|
if (terminalStates.includes(state)) {
|
||||||
|
conversation.isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleStateChange("idle");
|
||||||
|
|
||||||
|
expect(conversation.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isProcessing stays true during non-terminal states", () => {
|
||||||
|
const conversation = { isProcessing: true, characterState: "thinking" };
|
||||||
|
|
||||||
|
const terminalStates = ["idle", "success", "error"];
|
||||||
|
const handleStateChange = (state: string) => {
|
||||||
|
conversation.characterState = state;
|
||||||
|
if (terminalStates.includes(state)) {
|
||||||
|
conversation.isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const state of ["thinking", "typing", "coding", "searching", "mcp"]) {
|
||||||
|
handleStateChange(state);
|
||||||
|
expect(conversation.isProcessing).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { writable, derived, get } from "svelte/store";
|
|||||||
import type {
|
import type {
|
||||||
TerminalLine,
|
TerminalLine,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
ElicitationEvent,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
Attachment,
|
Attachment,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
|
import type { WorktreeInfo } from "$lib/types/worktree";
|
||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { sessionsStore } from "$lib/stores/sessions";
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
@@ -31,6 +33,7 @@ export interface Conversation {
|
|||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermissions: PermissionRequest[];
|
pendingPermissions: PermissionRequest[];
|
||||||
pendingQuestion: UserQuestionEvent | null;
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
|
pendingElicitation: ElicitationEvent | null;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
@@ -41,6 +44,7 @@ export interface Conversation {
|
|||||||
successSoundFired: boolean;
|
successSoundFired: boolean;
|
||||||
taskStartSoundFired: boolean;
|
taskStartSoundFired: boolean;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
|
worktreeInfo: WorktreeInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_NAMES = [
|
const TAB_NAMES = [
|
||||||
@@ -155,6 +159,7 @@ function createConversationsStore() {
|
|||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
pendingQuestion: null,
|
pendingQuestion: null,
|
||||||
|
pendingElicitation: null,
|
||||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
@@ -165,6 +170,7 @@ function createConversationsStore() {
|
|||||||
successSoundFired: false,
|
successSoundFired: false,
|
||||||
taskStartSoundFired: false,
|
taskStartSoundFired: false,
|
||||||
draftText: "",
|
draftText: "",
|
||||||
|
worktreeInfo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +224,13 @@ function createConversationsStore() {
|
|||||||
($conv) => $conv?.pendingPermissions || []
|
($conv) => $conv?.pendingPermissions || []
|
||||||
);
|
);
|
||||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||||
|
const pendingElicitation = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.pendingElicitation ?? null
|
||||||
|
);
|
||||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||||
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||||
|
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Expose derived stores for compatibility
|
// Expose derived stores for compatibility
|
||||||
@@ -230,11 +241,13 @@ function createConversationsStore() {
|
|||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
||||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||||
|
pendingElicitation: { subscribe: pendingElicitation.subscribe },
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||||
attachments: { subscribe: attachments.subscribe },
|
attachments: { subscribe: attachments.subscribe },
|
||||||
|
worktreeInfo: { subscribe: worktreeInfo.subscribe },
|
||||||
|
|
||||||
// New conversation-specific stores
|
// New conversation-specific stores
|
||||||
conversations: { subscribe: conversations.subscribe },
|
conversations: { subscribe: conversations.subscribe },
|
||||||
@@ -394,6 +407,52 @@ function createConversationsStore() {
|
|||||||
return convs;
|
return convs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
requestElicitation: (elicitation: ElicitationEvent) => {
|
||||||
|
const activeId = get(activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(activeId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = elicitation;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearElicitation: () => {
|
||||||
|
const activeId = get(activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(activeId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = null;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestElicitationForConversation: (conversationId: string, elicitation: ElicitationEvent) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = elicitation;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearElicitationForConversation: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = null;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
@@ -560,6 +619,17 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setProcessingForConversation: (conversationId: string, processing: boolean) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
addLine: (
|
addLine: (
|
||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
@@ -965,6 +1035,16 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWorktreeInfo: (conversationId: string, info: WorktreeInfo | null) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.worktreeInfo = info;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { memoryBrowserStore } from "./memoryBrowser";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memoryBrowserStore", () => {
|
||||||
|
it("initialises with panel closed", () => {
|
||||||
|
const state = get(memoryBrowserStore);
|
||||||
|
expect(state.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open() sets isOpen to true", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() sets isOpen to false", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle() opens the panel when closed", () => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
memoryBrowserStore.toggle();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle() closes the panel when open", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.toggle();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calling open() when already open keeps it open", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calling close() when already closed keeps it closed", () => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
interface MemoryBrowserState {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryBrowserStore() {
|
||||||
|
const { subscribe, update } = writable<MemoryBrowserState>({
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
open: () => update((state) => ({ ...state, isOpen: true })),
|
||||||
|
close: () => update((state) => ({ ...state, isOpen: false })),
|
||||||
|
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryBrowserStore = createMemoryBrowserStore();
|
||||||
@@ -59,12 +59,52 @@ const makeConversation = () => ({
|
|||||||
|
|
||||||
describe("sessionsStore - loadSessions", () => {
|
describe("sessionsStore - loadSessions", () => {
|
||||||
it("loads sessions from backend and updates the store", async () => {
|
it("loads sessions from backend and updates the store", async () => {
|
||||||
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
const sessionList = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
setMockInvokeResult("list_sessions", sessionList);
|
setMockInvokeResult("list_sessions", sessionList);
|
||||||
await sessionsStore.loadSessions();
|
await sessionsStore.loadSessions();
|
||||||
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts sessions by last_activity_at descending", async () => {
|
||||||
|
const sessionList = [
|
||||||
|
{
|
||||||
|
id: "older",
|
||||||
|
name: "Older",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newest",
|
||||||
|
name: "Newest",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "middle",
|
||||||
|
name: "Middle",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-02T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_sessions", sessionList);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
const sorted = get(sessionsStore.sessions);
|
||||||
|
expect(sorted[0].id).toBe("newest");
|
||||||
|
expect(sorted[1].id).toBe("middle");
|
||||||
|
expect(sorted[2].id).toBe("older");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
it("handles errors gracefully", async () => {
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
||||||
@@ -128,12 +168,44 @@ describe("sessionsStore - searchSessions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("searches with the given query", async () => {
|
it("searches with the given query", async () => {
|
||||||
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
const results = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
setMockInvokeResult("search_sessions", results);
|
setMockInvokeResult("search_sessions", results);
|
||||||
await sessionsStore.searchSessions("test");
|
await sessionsStore.searchSessions("test");
|
||||||
expect(get(sessionsStore.sessions)).toEqual(results);
|
expect(get(sessionsStore.sessions)).toEqual(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts search results by last_activity_at descending", async () => {
|
||||||
|
const results = [
|
||||||
|
{
|
||||||
|
id: "older",
|
||||||
|
name: "Older",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newest",
|
||||||
|
name: "Newest",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("search_sessions", results);
|
||||||
|
await sessionsStore.searchSessions("query");
|
||||||
|
const sorted = get(sessionsStore.sessions);
|
||||||
|
expect(sorted[0].id).toBe("newest");
|
||||||
|
expect(sorted[1].id).toBe("older");
|
||||||
|
});
|
||||||
|
|
||||||
it("updates searchQuery store", async () => {
|
it("updates searchQuery store", async () => {
|
||||||
setMockInvokeResult("search_sessions", []);
|
setMockInvokeResult("search_sessions", []);
|
||||||
await sessionsStore.searchSessions("hello");
|
await sessionsStore.searchSessions("hello");
|
||||||
@@ -187,6 +259,94 @@ describe("sessionsStore - saveConversation", () => {
|
|||||||
const conv = { ...makeConversation(), terminalLines: [] };
|
const conv = { ...makeConversation(), terminalLines: [] };
|
||||||
await sessionsStore.saveConversation(conv as never);
|
await sessionsStore.saveConversation(conv as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the most recent user message as the preview", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "user",
|
||||||
|
content: "First message",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Reply one",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "user",
|
||||||
|
content: "Most recent prompt",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Reply two",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("Most recent prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long preview text at 150 characters", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const longContent = "A".repeat(200);
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{ id: "1", type: "user", content: longContent, timestamp: new Date(), toolName: undefined },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("A".repeat(150) + "...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 'Empty conversation' as preview when there are no user messages", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Only assistant message",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("Empty conversation");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
||||||
|
|||||||
+17
-11
@@ -378,7 +378,11 @@ function createSessionsStore() {
|
|||||||
isLoading.set(true);
|
isLoading.set(true);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SessionListItem[]>("list_sessions");
|
const result = await invoke<SessionListItem[]>("list_sessions");
|
||||||
sessions.set(result);
|
sessions.set(
|
||||||
|
result.sort(
|
||||||
|
(a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load sessions:", error);
|
console.error("Failed to load sessions:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -395,15 +399,13 @@ function createSessionsStore() {
|
|||||||
tool_name: line.toolName,
|
tool_name: line.toolName,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const userAndAssistantMessages = conversation.terminalLines.filter(
|
const userMessages = conversation.terminalLines.filter((line) => line.type === "user");
|
||||||
(line) => line.type === "user" || line.type === "assistant"
|
const mostRecentUserMessage = userMessages.at(-1);
|
||||||
);
|
const previewContent = mostRecentUserMessage
|
||||||
const previewContent =
|
? mostRecentUserMessage.content.length > 150
|
||||||
userAndAssistantMessages
|
? mostRecentUserMessage.content.slice(0, 150) + "..."
|
||||||
.slice(0, 3)
|
: mostRecentUserMessage.content
|
||||||
.map((m) => m.content)
|
: "Empty conversation";
|
||||||
.join(" ")
|
|
||||||
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
|
|
||||||
|
|
||||||
const session: SavedSession = {
|
const session: SavedSession = {
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
@@ -458,7 +460,11 @@ function createSessionsStore() {
|
|||||||
const result = await invoke<SessionListItem[]>("search_sessions", {
|
const result = await invoke<SessionListItem[]>("search_sessions", {
|
||||||
query,
|
query,
|
||||||
});
|
});
|
||||||
sessions.set(result);
|
sessions.set(
|
||||||
|
result.sort(
|
||||||
|
(a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to search sessions:", error);
|
console.error("Failed to search sessions:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { getAchievementRarity, getRarityColour, toastStore } from "./toasts";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getAchievementRarity", () => {
|
||||||
|
describe("legendary tier", () => {
|
||||||
|
it("classifies TokenMaster as legendary", () => {
|
||||||
|
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("epic tier", () => {
|
||||||
|
it("classifies CodeMachine as epic", () => {
|
||||||
|
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies Unstoppable as epic", () => {
|
||||||
|
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rare tier", () => {
|
||||||
|
it("classifies BlossomingCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CodeWizard as rare", () => {
|
||||||
|
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies MasterBuilder as rare", () => {
|
||||||
|
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies EnduranceChamp as rare", () => {
|
||||||
|
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies DeepDive as rare", () => {
|
||||||
|
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CreativeCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("common tier", () => {
|
||||||
|
it("classifies unknown IDs as common", () => {
|
||||||
|
expect(getAchievementRarity("FirstChat")).toBe("common");
|
||||||
|
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
||||||
|
expect(getAchievementRarity("")).toBe("common");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRarityColour", () => {
|
||||||
|
it("returns yellow-to-orange gradient for legendary", () => {
|
||||||
|
expect(getRarityColour("legendary")).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns purple-to-pink gradient for epic", () => {
|
||||||
|
expect(getRarityColour("epic")).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blue-to-indigo gradient for rare", () => {
|
||||||
|
expect(getRarityColour("rare")).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns green-to-emerald gradient for common", () => {
|
||||||
|
expect(getRarityColour("common")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
||||||
|
expect(getRarityColour("mythic")).toBe("from-green-400 to-emerald-500");
|
||||||
|
expect(getRarityColour("")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("end-to-end rarity pipeline", () => {
|
||||||
|
it("produces the correct colour for a legendary achievement", () => {
|
||||||
|
const colour = getRarityColour(getAchievementRarity("TokenMaster"));
|
||||||
|
expect(colour).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for an epic achievement", () => {
|
||||||
|
const colour = getRarityColour(getAchievementRarity("CodeMachine"));
|
||||||
|
expect(colour).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a rare achievement", () => {
|
||||||
|
const colour = getRarityColour(getAchievementRarity("CodeWizard"));
|
||||||
|
expect(colour).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a common achievement", () => {
|
||||||
|
const colour = getRarityColour(getAchievementRarity("FirstChat"));
|
||||||
|
expect(colour).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("toastStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// Clear all toasts before each test
|
||||||
|
const current = get(toastStore);
|
||||||
|
for (const toast of current) {
|
||||||
|
toastStore.remove(toast.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addInfo", () => {
|
||||||
|
it("adds an info toast with the correct fields", () => {
|
||||||
|
toastStore.addInfo("Hello world", "🌍");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
const toast = toasts[0];
|
||||||
|
expect(toast.kind).toBe("info");
|
||||||
|
if (toast.kind === "info") {
|
||||||
|
expect(toast.message).toBe("Hello world");
|
||||||
|
expect(toast.icon).toBe("🌍");
|
||||||
|
expect(typeof toast.id).toBe("string");
|
||||||
|
expect(toast.id.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a default icon when none is provided", () => {
|
||||||
|
toastStore.addInfo("Default icon test");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
const toast = toasts[0];
|
||||||
|
if (toast.kind === "info") {
|
||||||
|
expect(toast.icon).toBe("ℹ️");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-dismisses after 4000ms", () => {
|
||||||
|
toastStore.addInfo("Auto-dismiss test");
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3999);
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(get(toastStore)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addAchievement", () => {
|
||||||
|
const mockAchievement: AchievementUnlockedEvent["achievement"] = {
|
||||||
|
id: "FirstMessage",
|
||||||
|
name: "First Message",
|
||||||
|
description: "Sent your first message",
|
||||||
|
icon: "💬",
|
||||||
|
unlocked_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("adds an achievement toast with the correct fields", () => {
|
||||||
|
toastStore.addAchievement(mockAchievement);
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
const toast = toasts[0];
|
||||||
|
expect(toast.kind).toBe("achievement");
|
||||||
|
if (toast.kind === "achievement") {
|
||||||
|
expect(toast.achievement).toEqual(mockAchievement);
|
||||||
|
expect(typeof toast.id).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-dismisses after 5000ms", () => {
|
||||||
|
toastStore.addAchievement(mockAchievement);
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(4999);
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(get(toastStore)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addError", () => {
|
||||||
|
it("adds an error toast with the warning icon", () => {
|
||||||
|
toastStore.addError("Something went wrong");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
const toast = toasts[0];
|
||||||
|
expect(toast.kind).toBe("info");
|
||||||
|
if (toast.kind === "info") {
|
||||||
|
expect(toast.message).toBe("Something went wrong");
|
||||||
|
expect(toast.icon).toBe("⚠️");
|
||||||
|
expect(typeof toast.id).toBe("string");
|
||||||
|
expect(toast.id.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-dismisses after 6000ms", () => {
|
||||||
|
toastStore.addError("Rate limit reached");
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5999);
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(get(toastStore)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addUpdate", () => {
|
||||||
|
it("adds a persistent update toast with the correct fields", () => {
|
||||||
|
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
const toast = toasts[0];
|
||||||
|
expect(toast.kind).toBe("update");
|
||||||
|
if (toast.kind === "update") {
|
||||||
|
expect(toast.latestVersion).toBe("2.0.0");
|
||||||
|
expect(toast.currentVersion).toBe("1.9.0");
|
||||||
|
expect(toast.releaseUrl).toBe("https://example.com/release");
|
||||||
|
expect(typeof toast.id).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-dismiss after a long time", () => {
|
||||||
|
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60000);
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("removes a toast by id", () => {
|
||||||
|
toastStore.addInfo("To be removed");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
const id = toasts[0].id;
|
||||||
|
|
||||||
|
toastStore.remove(id);
|
||||||
|
expect(get(toastStore)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other toasts when removing by id", () => {
|
||||||
|
toastStore.addInfo("First toast");
|
||||||
|
toastStore.addInfo("Second toast");
|
||||||
|
const toasts = get(toastStore);
|
||||||
|
expect(toasts).toHaveLength(2);
|
||||||
|
|
||||||
|
toastStore.remove(toasts[0].id);
|
||||||
|
const remaining = get(toastStore);
|
||||||
|
expect(remaining).toHaveLength(1);
|
||||||
|
if (remaining[0].kind === "info") {
|
||||||
|
expect(remaining[0].message).toBe("Second toast");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the id does not exist", () => {
|
||||||
|
toastStore.addInfo("Existing toast");
|
||||||
|
toastStore.remove("non-existent-id");
|
||||||
|
expect(get(toastStore)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
|
||||||
|
export interface InfoToast {
|
||||||
|
id: string;
|
||||||
|
kind: "info";
|
||||||
|
message: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementToast {
|
||||||
|
id: string;
|
||||||
|
kind: "achievement";
|
||||||
|
achievement: AchievementUnlockedEvent["achievement"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateToast {
|
||||||
|
id: string;
|
||||||
|
kind: "update";
|
||||||
|
latestVersion: string;
|
||||||
|
currentVersion: string;
|
||||||
|
releaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Toast = InfoToast | AchievementToast | UpdateToast;
|
||||||
|
|
||||||
|
export function getAchievementRarity(id: string): string {
|
||||||
|
if (id === "TokenMaster") return "legendary";
|
||||||
|
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"BlossomingCoder",
|
||||||
|
"CodeWizard",
|
||||||
|
"MasterBuilder",
|
||||||
|
"EnduranceChamp",
|
||||||
|
"DeepDive",
|
||||||
|
"CreativeCoder",
|
||||||
|
].includes(id)
|
||||||
|
)
|
||||||
|
return "rare";
|
||||||
|
return "common";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRarityColour(rarity: string): string {
|
||||||
|
switch (rarity) {
|
||||||
|
case "legendary":
|
||||||
|
return "from-yellow-400 to-orange-500";
|
||||||
|
case "epic":
|
||||||
|
return "from-purple-400 to-pink-500";
|
||||||
|
case "rare":
|
||||||
|
return "from-blue-400 to-indigo-500";
|
||||||
|
default:
|
||||||
|
return "from-green-400 to-emerald-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToastStore() {
|
||||||
|
const { subscribe, update } = writable<Toast[]>([]);
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInfo(message: string, icon = "ℹ️") {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const toast: InfoToast = { id, kind: "info", message, icon };
|
||||||
|
update((toasts) => [...toasts, toast]);
|
||||||
|
setTimeout(() => remove(id), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addError(message: string) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const toast: InfoToast = { id, kind: "info", message, icon: "⚠️" };
|
||||||
|
update((toasts) => [...toasts, toast]);
|
||||||
|
setTimeout(() => remove(id), 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const toast: AchievementToast = { id, kind: "achievement", achievement };
|
||||||
|
update((toasts) => [...toasts, toast]);
|
||||||
|
setTimeout(() => remove(id), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUpdate(latestVersion: string, currentVersion: string, releaseUrl: string) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const toast: UpdateToast = { id, kind: "update", latestVersion, currentVersion, releaseUrl };
|
||||||
|
update((toasts) => [...toasts, toast]);
|
||||||
|
// Update toasts are persistent — no auto-dismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, addInfo, addError, addAchievement, addUpdate, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastStore = createToastStore();
|
||||||
+102
-5
@@ -8,11 +8,15 @@ import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
|||||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
import { initAchievementsListener } from "$lib/stores/achievements";
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
ElicitationEvent,
|
||||||
PermissionPromptEvent,
|
PermissionPromptEvent,
|
||||||
|
PostCompactEvent,
|
||||||
|
StopFailureEvent,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
||||||
|
import type { WorktreeEvent } from "$lib/types/worktree";
|
||||||
import { agentStore } from "$lib/stores/agents";
|
import { agentStore } from "$lib/stores/agents";
|
||||||
import { todos } from "$lib/stores/todos";
|
import { todos } from "$lib/stores/todos";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +26,7 @@ import {
|
|||||||
handleNewUserMessage,
|
handleNewUserMessage,
|
||||||
} from "$lib/notifications/rules";
|
} from "$lib/notifications/rules";
|
||||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||||
|
import { toastStore } from "$lib/stores/toasts";
|
||||||
|
|
||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
@@ -236,9 +241,10 @@ export async function initializeTauriListeners() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update character state for this conversation
|
// Update character state and processing state for this conversation
|
||||||
if (targetConversationId) {
|
if (targetConversationId) {
|
||||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||||
|
claudeStore.setProcessingForConversation(targetConversationId, false);
|
||||||
}
|
}
|
||||||
} else if (status === "error") {
|
} else if (status === "error") {
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
@@ -333,13 +339,21 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always update the conversation's state
|
// Always update the conversation's state
|
||||||
|
const isTerminalState =
|
||||||
|
mappedState === "idle" || mappedState === "success" || mappedState === "error";
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||||
|
if (isTerminalState) {
|
||||||
|
claudeStore.setProcessingForConversation(conversation_id, false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id
|
// Fallback to active conversation if no conversation_id
|
||||||
const activeConversationId = get(claudeStore.activeConversationId);
|
const activeConversationId = get(claudeStore.activeConversationId);
|
||||||
if (activeConversationId) {
|
if (activeConversationId) {
|
||||||
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
|
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
|
||||||
|
if (isTerminalState) {
|
||||||
|
claudeStore.setProcessingForConversation(activeConversationId, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +409,8 @@ export async function initializeTauriListeners() {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change",
|
| "config-change"
|
||||||
|
| "elicitation",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -414,13 +429,24 @@ export async function initializeTauriListeners() {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change",
|
| "config-change"
|
||||||
|
| "elicitation",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
parent_tool_use_id
|
parent_tool_use_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect auto-memory updates — tool writes to ~/.claude/ markdown files
|
||||||
|
if (
|
||||||
|
line_type === "tool" &&
|
||||||
|
content &&
|
||||||
|
content.includes("/.claude/") &&
|
||||||
|
content.includes(".md")
|
||||||
|
) {
|
||||||
|
toastStore.addInfo("Auto-memory updated", "🧠");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(outputUnlisten);
|
unlisteners.push(outputUnlisten);
|
||||||
|
|
||||||
@@ -506,6 +532,7 @@ export async function initializeTauriListeners() {
|
|||||||
agent_id,
|
agent_id,
|
||||||
description,
|
description,
|
||||||
subagent_type,
|
subagent_type,
|
||||||
|
model,
|
||||||
started_at,
|
started_at,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
parent_tool_use_id,
|
parent_tool_use_id,
|
||||||
@@ -517,6 +544,7 @@ export async function initializeTauriListeners() {
|
|||||||
agentId: agent_id,
|
agentId: agent_id,
|
||||||
description,
|
description,
|
||||||
subagentType: subagent_type,
|
subagentType: subagent_type,
|
||||||
|
model,
|
||||||
startedAt: started_at,
|
startedAt: started_at,
|
||||||
status: "running",
|
status: "running",
|
||||||
parentToolUseId: parent_tool_use_id,
|
parentToolUseId: parent_tool_use_id,
|
||||||
@@ -529,9 +557,10 @@ export async function initializeTauriListeners() {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
toolUseId: string;
|
toolUseId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
agentType?: string;
|
||||||
}>("claude:agent-update", (event) => {
|
}>("claude:agent-update", (event) => {
|
||||||
const { conversationId, toolUseId, agentId } = event.payload;
|
const { conversationId, toolUseId, agentId, agentType } = event.payload;
|
||||||
agentStore.updateAgentId(conversationId, toolUseId, agentId);
|
agentStore.updateAgentId(conversationId, toolUseId, agentId, agentType);
|
||||||
});
|
});
|
||||||
unlisteners.push(agentUpdateUnlisten);
|
unlisteners.push(agentUpdateUnlisten);
|
||||||
|
|
||||||
@@ -551,6 +580,24 @@ export async function initializeTauriListeners() {
|
|||||||
});
|
});
|
||||||
unlisteners.push(agentEndUnlisten);
|
unlisteners.push(agentEndUnlisten);
|
||||||
|
|
||||||
|
const worktreeUnlisten = await listen<WorktreeEvent>("claude:worktree", (event) => {
|
||||||
|
const { conversation_id, event_type, worktree } = event.payload;
|
||||||
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
|
if (targetConversationId) {
|
||||||
|
claudeStore.setWorktreeInfo(
|
||||||
|
targetConversationId,
|
||||||
|
event_type === "create" && worktree ? worktree : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event_type === "create" && worktree) {
|
||||||
|
toastStore.addInfo(`Worktree created: ${worktree.branch}`, "🌿");
|
||||||
|
} else if (event_type === "remove") {
|
||||||
|
toastStore.addInfo("Worktree removed", "🌿");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unlisteners.push(worktreeUnlisten);
|
||||||
|
|
||||||
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
||||||
const questionEvent = event.payload;
|
const questionEvent = event.payload;
|
||||||
|
|
||||||
@@ -569,6 +616,56 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(questionUnlisten);
|
unlisteners.push(questionUnlisten);
|
||||||
|
|
||||||
|
const elicitationUnlisten = await listen<ElicitationEvent>("claude:elicitation", (event) => {
|
||||||
|
const elicitationEvent = event.payload;
|
||||||
|
if (elicitationEvent.conversation_id) {
|
||||||
|
claudeStore.requestElicitationForConversation(
|
||||||
|
elicitationEvent.conversation_id,
|
||||||
|
elicitationEvent
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
claudeStore.requestElicitation(elicitationEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unlisteners.push(elicitationUnlisten);
|
||||||
|
|
||||||
|
const elicitationResultUnlisten = await listen<{ conversation_id?: string }>(
|
||||||
|
"claude:elicitation-result",
|
||||||
|
(event) => {
|
||||||
|
const { conversation_id } = event.payload;
|
||||||
|
if (conversation_id) {
|
||||||
|
claudeStore.clearElicitationForConversation(conversation_id);
|
||||||
|
} else {
|
||||||
|
claudeStore.clearElicitation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
unlisteners.push(elicitationResultUnlisten);
|
||||||
|
|
||||||
|
const stopFailureUnlisten = await listen<StopFailureEvent>("claude:stop-failure", (event) => {
|
||||||
|
const { stop_reason, error_type } = event.payload;
|
||||||
|
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
if (stop_reason === "rate_limit") {
|
||||||
|
message = "Rate limit reached";
|
||||||
|
} else if (stop_reason === "auth_failure" || stop_reason === "authentication") {
|
||||||
|
message = "Authentication failed";
|
||||||
|
} else {
|
||||||
|
message = `API error: ${stop_reason ?? error_type ?? "unknown"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastStore.addError(message);
|
||||||
|
});
|
||||||
|
unlisteners.push(stopFailureUnlisten);
|
||||||
|
|
||||||
|
const postCompactUnlisten = await listen<PostCompactEvent>("claude:post-compact", () => {
|
||||||
|
toastStore.addInfo("Context compacted", "🗜️");
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
});
|
||||||
|
unlisteners.push(postCompactUnlisten);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupTauriListeners() {
|
export function cleanupTauriListeners() {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ export type AgentStatus = "running" | "completed" | "errored";
|
|||||||
export interface AgentInfo {
|
export interface AgentInfo {
|
||||||
toolUseId: string;
|
toolUseId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
agentType?: string;
|
||||||
description: string;
|
description: string;
|
||||||
subagentType: string;
|
subagentType: string;
|
||||||
|
model?: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
endedAt?: number;
|
endedAt?: number;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
@@ -20,6 +22,7 @@ export interface AgentStartPayload {
|
|||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
description: string;
|
description: string;
|
||||||
subagent_type: string;
|
subagent_type: string;
|
||||||
|
model?: string;
|
||||||
started_at: number;
|
started_at: number;
|
||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
parent_tool_use_id?: string;
|
parent_tool_use_id?: string;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export interface TerminalLine {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change";
|
| "config-change"
|
||||||
|
| "elicitation";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@@ -162,6 +163,30 @@ export interface UserQuestionEvent {
|
|||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElicitationEvent {
|
||||||
|
message: string;
|
||||||
|
server_name?: string;
|
||||||
|
request_id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElicitationResultEvent {
|
||||||
|
action: string;
|
||||||
|
request_id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopFailureEvent {
|
||||||
|
stop_reason?: string;
|
||||||
|
error_type?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostCompactEvent {
|
||||||
|
session_id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface WorktreeInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
original_repo_directory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeEvent {
|
||||||
|
conversation_id?: string;
|
||||||
|
/** "create" or "remove" */
|
||||||
|
event_type: string;
|
||||||
|
worktree?: WorktreeInfo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
BINARY_FILE_EXTENSIONS,
|
||||||
|
getFileExtension,
|
||||||
|
getFileTypeIcon,
|
||||||
|
isBinaryFilePath,
|
||||||
|
linkifyFilePaths,
|
||||||
|
} from "./filePaths";
|
||||||
|
|
||||||
|
describe("getFileExtension", () => {
|
||||||
|
it("returns the lowercase extension of a simple path", () => {
|
||||||
|
expect(getFileExtension("/tmp/report.pdf")).toBe("pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the lowercase extension for uppercase file names", () => {
|
||||||
|
expect(getFileExtension("/tmp/AUDIO.MP3")).toBe("mp3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the extension for a path with multiple dots", () => {
|
||||||
|
expect(getFileExtension("/tmp/my.file.docx")).toBe("docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string when there is no extension", () => {
|
||||||
|
expect(getFileExtension("/tmp/noextension")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string for an empty string input", () => {
|
||||||
|
expect(getFileExtension("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the extension for a home-relative path", () => {
|
||||||
|
expect(getFileExtension("~/downloads/track.wav")).toBe("wav");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFileTypeIcon", () => {
|
||||||
|
it("returns the PDF icon for .pdf files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/doc.pdf")).toBe("📄");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the Word icon for .docx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/report.docx")).toBe("📝");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the Word icon for .doc files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/old.doc")).toBe("📝");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the spreadsheet icon for .xlsx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/data.xlsx")).toBe("📊");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the spreadsheet icon for .xls files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/data.xls")).toBe("📊");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the presentation icon for .pptx files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/slides.pptx")).toBe("📽️");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the presentation icon for .ppt files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/slides.ppt")).toBe("📽️");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .mp3 files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/song.mp3")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .wav files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/sound.wav")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .ogg files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/audio.ogg")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .flac files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/lossless.flac")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .aac files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/compressed.aac")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the audio icon for .m4a files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/itunes.m4a")).toBe("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mp4 files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/video.mp4")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .avi files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/old.avi")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mov files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/clip.mov")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .mkv files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/film.mkv")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the video icon for .webm files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/stream.webm")).toBe("🎬");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .zip files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/bundle.zip")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .tar files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/archive.tar")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the archive icon for .gz files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/compressed.gz")).toBe("📦");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the disk icon for .bin files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/firmware.bin")).toBe("💿");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the disk icon for .iso files", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/image.iso")).toBe("💿");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the generic folder icon for an unknown extension", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/file.unknown")).toBe("📁");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the generic folder icon for a file with no extension", () => {
|
||||||
|
expect(getFileTypeIcon("/tmp/noext")).toBe("📁");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBinaryFilePath", () => {
|
||||||
|
it("returns true for a PDF path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/report.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for an audio path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/song.mp3")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a video path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/clip.mp4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a document path", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/doc.docx")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a TypeScript file", () => {
|
||||||
|
expect(isBinaryFilePath("/src/index.ts")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a text file", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/output.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a path with no extension", () => {
|
||||||
|
expect(isBinaryFilePath("/tmp/file")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BINARY_FILE_EXTENSIONS", () => {
|
||||||
|
it("includes pdf", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common audio extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("mp3");
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("wav");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common video extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes common document extensions", () => {
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("docx");
|
||||||
|
expect(BINARY_FILE_EXTENSIONS).toContain("xlsx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linkifyFilePaths", () => {
|
||||||
|
it("converts a PDF path in plain text to a file link", () => {
|
||||||
|
const html = "<p>Saved to /tmp/report.pdf successfully.</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/report.pdf"');
|
||||||
|
expect(result).toContain("📄");
|
||||||
|
expect(result).toContain('class="file-link"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts an audio path to a file link", () => {
|
||||||
|
const html = "<p>Audio saved to /tmp/output.mp3</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/output.mp3"');
|
||||||
|
expect(result).toContain("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths inside code blocks", () => {
|
||||||
|
const html = "<p>Example:</p><pre><code>/tmp/file.pdf</code></pre>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
|
||||||
|
expect(result).toContain("/tmp/file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths inside inline code", () => {
|
||||||
|
const html = "<p>Use <code>/tmp/file.pdf</code> to open it.</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
|
||||||
|
expect(result).toContain("/tmp/file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify HTML that has no binary file paths", () => {
|
||||||
|
const html = "<p>Hello, this is regular text with /tmp/script.sh</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify text file paths", () => {
|
||||||
|
const html = "<p>Saved to /tmp/output.txt</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain("data-filepath");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a home-relative path", () => {
|
||||||
|
const html = "<p>Saved to ~/downloads/audio.flac</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="~/downloads/audio.flac"');
|
||||||
|
expect(result).toContain("🎵");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple file paths in the same HTML", () => {
|
||||||
|
const html = "<p>Files: /tmp/a.pdf and /tmp/b.mp3</p>";
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('data-filepath="/tmp/a.pdf"');
|
||||||
|
expect(result).toContain('data-filepath="/tmp/b.mp3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not linkify paths that contain double quotes (invalid path character)", () => {
|
||||||
|
// Double quotes are excluded from path chars so the path is not matched
|
||||||
|
const html = `<p>Saved to /tmp/my"file.pdf</p>`;
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).not.toContain("data-filepath");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing HTML tags and attributes", () => {
|
||||||
|
const html = '<p class="foo">Saved to /tmp/report.pdf</p>';
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
expect(result).toContain('class="foo"');
|
||||||
|
expect(result).toContain('data-filepath="/tmp/report.pdf"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-linkify a path already inside an anchor tag", () => {
|
||||||
|
const html = '<a href="/tmp/file.pdf">/tmp/file.pdf</a>';
|
||||||
|
const result = linkifyFilePaths(html);
|
||||||
|
// The href is inside a tag (placeholder), the text content IS linkified
|
||||||
|
// but the href itself should not be modified
|
||||||
|
const hrefMatches = result.match(/href="[^"]*\/tmp\/file\.pdf[^"]*"/g) ?? [];
|
||||||
|
expect(hrefMatches.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the input unchanged when html is empty", () => {
|
||||||
|
expect(linkifyFilePaths("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for detecting and rendering binary file paths
|
||||||
|
* saved to disk by MCP tools via the Claude Code CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BINARY_FILE_EXTENSIONS = [
|
||||||
|
// Documents
|
||||||
|
"pdf",
|
||||||
|
"docx",
|
||||||
|
"doc",
|
||||||
|
"xlsx",
|
||||||
|
"xls",
|
||||||
|
"pptx",
|
||||||
|
"ppt",
|
||||||
|
// Audio
|
||||||
|
"mp3",
|
||||||
|
"wav",
|
||||||
|
"ogg",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"m4a",
|
||||||
|
// Video
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
// Archives
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
// Other binaries
|
||||||
|
"bin",
|
||||||
|
"iso",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BinaryFileExtension = (typeof BINARY_FILE_EXTENSIONS)[number];
|
||||||
|
|
||||||
|
export function getFileExtension(filePath: string): string {
|
||||||
|
const lastDot = filePath.lastIndexOf(".");
|
||||||
|
if (lastDot === -1) return "";
|
||||||
|
return filePath.slice(lastDot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileTypeIcon(filePath: string): string {
|
||||||
|
const ext = getFileExtension(filePath);
|
||||||
|
switch (ext) {
|
||||||
|
case "pdf":
|
||||||
|
return "📄";
|
||||||
|
case "docx":
|
||||||
|
case "doc":
|
||||||
|
return "📝";
|
||||||
|
case "xlsx":
|
||||||
|
case "xls":
|
||||||
|
return "📊";
|
||||||
|
case "pptx":
|
||||||
|
case "ppt":
|
||||||
|
return "📽️";
|
||||||
|
case "mp3":
|
||||||
|
case "wav":
|
||||||
|
case "ogg":
|
||||||
|
case "flac":
|
||||||
|
case "aac":
|
||||||
|
case "m4a":
|
||||||
|
return "🎵";
|
||||||
|
case "mp4":
|
||||||
|
case "avi":
|
||||||
|
case "mov":
|
||||||
|
case "mkv":
|
||||||
|
case "webm":
|
||||||
|
return "🎬";
|
||||||
|
case "zip":
|
||||||
|
case "tar":
|
||||||
|
case "gz":
|
||||||
|
return "📦";
|
||||||
|
case "bin":
|
||||||
|
case "iso":
|
||||||
|
return "💿";
|
||||||
|
default:
|
||||||
|
return "📁";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBinaryFilePath(filePath: string): boolean {
|
||||||
|
const ext = getFileExtension(filePath);
|
||||||
|
return (BINARY_FILE_EXTENSIONS as readonly string[]).includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processes HTML content to convert binary file paths into clickable
|
||||||
|
* anchor elements with file-type icons. Skips content inside code blocks
|
||||||
|
* and existing HTML tags so it doesn't double-linkify or corrupt attributes.
|
||||||
|
*/
|
||||||
|
export function linkifyFilePaths(html: string): string {
|
||||||
|
const codeBlockPlaceholders: string[] = [];
|
||||||
|
|
||||||
|
// Temporarily replace code blocks and inline code with placeholders
|
||||||
|
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||||
|
codeBlockPlaceholders.push(match);
|
||||||
|
return `__FILEPATH_CODE_${codeBlockPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Temporarily replace all HTML tags with placeholders
|
||||||
|
const tagPlaceholders: string[] = [];
|
||||||
|
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||||
|
tagPlaceholders.push(match);
|
||||||
|
return `__FILEPATH_TAG_${tagPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now replace binary file paths in the remaining plain text
|
||||||
|
const extensions = BINARY_FILE_EXTENSIONS.join("|");
|
||||||
|
// No lookahead needed — the greedy character class naturally backtracks to the
|
||||||
|
// shortest match ending with a recognised extension, terminating before any
|
||||||
|
// character excluded by the class (spaces, HTML-unsafe chars, tag placeholders).
|
||||||
|
const filePathRegex = new RegExp(`((?:~/|/)[^\\s<>"'\`]+\\.(?:${extensions}))`, "gi");
|
||||||
|
processed = processed.replace(filePathRegex, (_, filePath: string) => {
|
||||||
|
const icon = getFileTypeIcon(filePath);
|
||||||
|
const escaped = filePath.replace(/"/g, """);
|
||||||
|
return `<a class="file-link" href="#" data-filepath="${escaped}">${icon} ${filePath}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore HTML tags
|
||||||
|
processed = processed.replace(/__FILEPATH_TAG_(\d+)__/g, (_, index) => {
|
||||||
|
return tagPlaceholders[parseInt(index, 10)];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
processed = processed.replace(/__FILEPATH_CODE_(\d+)__/g, (_, index) => {
|
||||||
|
return codeBlockPlaceholders[parseInt(index, 10)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
+20
-10
@@ -36,12 +36,13 @@
|
|||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||||
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||||
|
import ElicitationModal from "$lib/components/ElicitationModal.svelte";
|
||||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
|
||||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
import ToastContainer from "$lib/components/ToastContainer.svelte";
|
||||||
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
|
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
|
||||||
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
|
import type { UpdateInfo } from "$lib/types/messages";
|
||||||
|
import { toastStore } from "$lib/stores/toasts";
|
||||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||||
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
||||||
|
|
||||||
@@ -85,7 +86,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
|
||||||
let achievementPanelOpen = $state(false);
|
let achievementPanelOpen = $state(false);
|
||||||
let currentCharacterState: CharacterState = $state("idle");
|
let currentCharacterState: CharacterState = $state("idle");
|
||||||
let compactModeActive = $state(false);
|
let compactModeActive = $state(false);
|
||||||
@@ -336,6 +336,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
if (!config.update_checks_enabled) return;
|
||||||
|
try {
|
||||||
|
const info = await invoke<UpdateInfo>("check_for_updates");
|
||||||
|
if (info.has_update) {
|
||||||
|
toastStore.addUpdate(info.latest_version, info.current_version, info.release_url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check for updates:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleInterrupt() {
|
async function handleInterrupt() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -483,9 +496,7 @@
|
|||||||
window.addEventListener("keydown", handleGlobalKeydown);
|
window.addEventListener("keydown", handleGlobalKeydown);
|
||||||
|
|
||||||
// Check for updates on startup
|
// Check for updates on startup
|
||||||
if (config.update_checks_enabled) {
|
await checkForUpdates();
|
||||||
updateNotification?.checkForUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply compact mode if saved (resize window)
|
// Apply compact mode if saved (resize window)
|
||||||
if (config.compact_mode) {
|
if (config.compact_mode) {
|
||||||
@@ -583,14 +594,13 @@
|
|||||||
|
|
||||||
<PermissionModal />
|
<PermissionModal />
|
||||||
<UserQuestionModal />
|
<UserQuestionModal />
|
||||||
|
<ElicitationModal />
|
||||||
<ConfigSidebar />
|
<ConfigSidebar />
|
||||||
<MemoryBrowserPanel />
|
|
||||||
<AchievementNotification />
|
|
||||||
<AchievementsPanel
|
<AchievementsPanel
|
||||||
bind:isOpen={achievementPanelOpen}
|
bind:isOpen={achievementPanelOpen}
|
||||||
onClose={() => (achievementPanelOpen = false)}
|
onClose={() => (achievementPanelOpen = false)}
|
||||||
/>
|
/>
|
||||||
<UpdateNotification bind:this={updateNotification} />
|
<ToastContainer />
|
||||||
<CloseAppConfirmModal
|
<CloseAppConfirmModal
|
||||||
isOpen={closeConfirmModalOpen}
|
isOpen={closeConfirmModalOpen}
|
||||||
{hasActiveConversation}
|
{hasActiveConversation}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
profile_avatar_path: null,
|
profile_avatar_path: null,
|
||||||
profile_bio: null,
|
profile_bio: null,
|
||||||
custom_theme_colors: {},
|
custom_theme_colors: {},
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
});
|
});
|
||||||
case "list_quick_actions":
|
case "list_quick_actions":
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user