Compare commits

...

13 Commits

Author SHA1 Message Date
hikari 269674678c fix: update permission modal z-index to proper value
Updated the permission modal overlay z-index from z-50 to z-[60] to ensure
it displays above the character panel (z-50). This prevents the modal from
being obscured by other UI elements.

Part of the broader permission modal fix effort.
2026-02-06 22:50:38 -08:00
hikari add78a769a docs: add summary document for critical bug fixes 2026-02-06 22:43:36 -08:00
hikari 4a99848647 docs: massively expand DEBUGGING.md with comprehensive troubleshooting
Added:
- Table of contents for easy navigation
- Detailed explanations of BOTH bugs (permission modal + config loss)
- Step-by-step debugging procedures for each issue
- Code examples showing wrong vs right patterns
- Common anti-patterns to avoid (Svelte stores, event listeners)
- Quick debugging checklist
- Prevention tips and testing procedures
- Clear ordering: check console FIRST, not z-index!

This guide now documents the complete troubleshooting process so Naomi
never has to explain these issues again.
2026-02-06 22:43:08 -08:00
hikari e8b8ee17fa docs: update DEBUGGING.md with config race condition fix 2026-02-06 22:41:00 -08:00
hikari 2c64ef089e fix: resolve critical config store race condition causing config loss
CRITICAL BUG: The config store had a race condition in all methods that
accessed the current config. The pattern:

```typescript
let currentConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
```

This immediately unsubscribes (the `()` at the end), creating a race where
sometimes it would get the value, sometimes it wouldn't. This caused:
- Config appearing "lost" after permission approvals
- Settings resetting to defaults randomly
- Model selection not persisting

FIX:
- Created a proper `getCurrentConfig()` helper that unsubscribes cleanly
- Replaced ALL instances of the buggy pattern (12 occurrences!)
- getConfig(), updateConfig(), toggles, theme setters, font methods, etc.

This should completely fix the config loss issue.
2026-02-06 22:40:47 -08:00
hikari 80ee25fa09 docs: add comprehensive debugging guide for common issues
Created DEBUGGING.md to document:
- Permission modal troubleshooting (NOT z-index!)
- Config loss prevention and fixes
- Event listener debugging
- Testing checklist
- Emergency recovery procedures

This should prevent us from going through the same debugging nightmare twice.
2026-02-06 22:38:11 -08:00
hikari d16644a1d6 fix: resolve critical runtime errors blocking permission modal
This fixes two severe bugs:
1. Missing debugConsoleStore import causing undefined variable errors
2. Replace non-existent .log() method with console.log()
3. Add #[serde(default)] to HikariConfig to handle missing fields gracefully

The undefined variable was causing initialization to fail, which prevented
the permission modal from displaying properly.
2026-02-06 22:36:00 -08:00
hikari 1d94bdfbb0 feat: add close confirmation modal with minimize to tray option
Implemented a confirmation modal when users try to close the application:
- Modal always shows with three options: Cancel, Minimize to Tray, Close Application
- Detects if Claude is actively running and shows appropriate warning message
- Removed minimize_to_tray config setting (no longer needed)
- Added core:window:allow-hide permission for window hiding
- Created CloseAppConfirmModal component with keyboard shortcuts (Escape to cancel)
- Added close_application command to properly exit the app
- Backend emits window-close-requested event for frontend to handle

This provides better UX by giving users clear choices every time they close,
preventing accidental closures during active work sessions.
2026-02-06 21:49:13 -08:00
hikari f654c3c3ff fix: resolve permission modal issues with successful operations
Fixes #113 - EnterPlanMode/ExitPlanMode infinite permission loops

This commit addresses multiple related issues with the permission system:

1. Added system tool filtering to sibling tools loop to prevent
   EnterPlanMode/ExitPlanMode from appearing in permission modals

2. Skip permission modal processing entirely when operations succeed
   (subtype == "success"), since tools were already approved and executed

3. Emit proper state change before early return to prevent Hikari
   from getting stuck in "typing" state

The early return happens after all stats, costs, and achievements
are processed, so no data tracking is affected.

 This issue was fixed with help from Hikari~ 🌸
2026-02-06 20:50:23 -08:00
hikari 24b2e3eb48 fix: prevent permission prompts for system tools
Fixed infinite permission loop when using ExitPlanMode and other
system tools. These tools are now automatically allowed without
requiring user approval.

The issue occurred because all tool denials triggered permission
prompts, including system tools like ExitPlanMode that should never
require permission. This caused an infinite loop where:
1. Claude Code calls ExitPlanMode
2. Hikari shows permission modal
3. User approves
4. Claude Code retries ExitPlanMode
5. Loop repeats

Solution:
- Added is_system_tool helper function to identify system tools
- System tools (ExitPlanMode, EnterPlanMode) are now skipped in
  permission denial processing
- These tools execute immediately without user intervention

Closes #113
2026-02-06 20:21:05 -08:00
hikari 5b8ae63de1 feat: add debug console for frontend and backend logs
Added a comprehensive debug console feature that captures and displays
logs from both the frontend and backend in a unified interface.

Frontend changes:
- Created DebugConsole component with real-time log display
- Added debugConsoleStore for state management and console capture
- Integrated console into main layout
- Added toggle button in StatusBar with console icon
- Implemented Ctrl+` keyboard shortcut to open/close console
- Features: log filtering by level, auto-scroll, timestamps, colour-coding

Backend changes:
- Added tracing and tracing-subscriber dependencies
- Created custom TauriLogLayer to emit Rust logs to frontend
- Integrated tracing subscriber in lib.rs setup
- Logs are forwarded via Tauri events (debug:log)

Key features:
- Circular buffer (max 1000 logs) prevents memory issues
- Frontend logs captured via console method overrides
- Backend logs forwarded from Rust tracing layer
- Log level filtering (debug, info, warn, error, all)
- Source badges distinguish frontend vs backend logs
- Colour-coded log levels for easy identification
- Auto-scroll toggle for inspecting older logs
- Clear logs button for resetting the console
- Beautiful dark-themed UI matching app aesthetic

Closes #126
2026-02-06 20:16:26 -08:00
hikari 82061f125b feat: batch parallel permission requests for improved UX
Implemented intelligent permission batching that detects cancelled sibling
tool calls and presents them together in a single modal. This dramatically
improves the user experience when multiple tools require permission.

Key changes:
- Track pending tool uses from Assistant messages in thread-local storage
- Capture and batch sibling tools that get cancelled due to permission denials
- Clear pending tools on each Result message to prevent accumulation
- Use SvelteSet for reactive permission selection in the modal
- Update permission modal to display count when multiple permissions requested
- Fix check-all.sh to source nvm for pnpm access
- Add git commit instructions to CLAUDE.md for this project

Technical improvements:
- Thread-local storage for cross-message tool tracking
- Proper null checking in TypeScript permission handling
- Clippy-compliant const initialisation for thread_local
- All ESLint, TypeScript, and Rust checks passing

The modal now shows both the explicitly denied tool AND any sibling tools
that were called in parallel, allowing users to approve all permissions
in one go instead of clicking through multiple modals.
2026-02-06 19:54:12 -08:00
naomi 870de7588f feat: add claude file 2026-02-06 18:16:55 -08:00
29 changed files with 2035 additions and 234 deletions
+35
View File
@@ -0,0 +1,35 @@
# Hikari Desktop - Project Instructions
## Repository Information
This project is hosted on both GitHub and Gitea:
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
## MCP Server Usage
When working with issues, pull requests, or other repository operations for this project:
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
- **Target repository**: `nhcarrigan/hikari-desktop`
- **Gitea instance**: `git.nhcarrigan.com`
## Git Commits
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
```
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+600
View File
@@ -0,0 +1,600 @@
# Debugging Guide - Common Issues and Fixes
> **IMPORTANT:** Read this ENTIRE file before asking for help! These are the most common issues and their fixes.
## Table of Contents
1. [Permission Modal Not Showing](#permission-modal-not-showing-up-)
2. [Config Loss Issues](#config-loss-issues-)
3. [Modal/Overlay Not Showing (General)](#modaloverlay-not-showing-general-)
4. [Event Listener Issues](#event-listener-issues-)
5. [Build Issues](#build-issues-)
6. [Testing Checklist](#testing-checklist-)
7. [Emergency Recovery](#emergency-recovery-)
---
## Permission Modal Not Showing Up 🔐
**SYMPTOMS:**
- Permission requests are triggered but the modal doesn't appear
- App seems to hang or become unresponsive
- Console shows permission events but nothing happens
- Character stays in "permission" state but no UI
**ROOT CAUSE (FIXED - Commit d16644a):**
The issue was **undefined variable errors** in `src/lib/tauri.ts` that crashed the event listener initialization.
### The Bug That Was Fixed
In `src/lib/tauri.ts`, there were calls to `debugConsoleStore.log()`:
```typescript
debugConsoleStore.log("frontend", "info", "Setting up claude:permission listener");
```
**Two problems:**
1. `debugConsoleStore` was never imported (undefined variable → crash!)
2. Even if imported, `debugConsoleStore` doesn't have a `.log()` method
- Available methods: `toggle`, `open`, `close`, `clear`, `setupConsoleCapture`, etc.
- NOT available: `log()`
### The Fix
```typescript
// ❌ WRONG - This will crash the entire listener setup!
debugConsoleStore.log("frontend", "info", "message");
// ✅ CORRECT - Use console.log which gets automatically captured
console.log("[Tauri Listener] message");
```
**Why console.log works:**
- The `debugConsoleStore` automatically intercepts `console.log()`, `console.error()`, etc.
- No need to call the store directly!
- See `src/lib/stores/debugConsole.ts` lines 66-89 for the console interception code
### How to Debug This Type of Issue
**Step 1: Check Browser Console**
1. Open Browser DevTools in the app (View > Toggle Developer Tools or F12)
2. Look for JavaScript errors:
- `ReferenceError: X is not defined` → Missing import or typo
- `TypeError: X.method is not a function` → Wrong method name or undefined object
- Any error in `tauri.ts` during initialization is critical!
**Step 2: Check Event Listener Registration**
Look for these console logs when the app starts:
```
[Tauri Listener] Setting up claude:permission listener
```
If you DON'T see this log, the listener setup crashed before it got there!
**Step 3: Check TypeScript Errors**
```bash
pnpm check
```
This will catch:
- Missing imports
- Undefined variables
- Type mismatches
- Non-existent methods
**Step 4: Test Permission Flow**
1. Trigger a restricted tool (like Bash with a destructive command)
2. Check console for `[Permission] Event received:` log
3. Check if `PermissionModal.svelte` is rendering (DevTools Elements tab)
4. Check if modal has proper z-index and visibility
### Prevention
**Before committing changes to `tauri.ts` or any store:**
1. ✅ Run `pnpm check` - Catches TypeScript errors
2. ✅ Run `pnpm build` - Catches runtime issues
3. ✅ Test the app - Especially permission prompts
4. ✅ Check browser console - No errors on startup
5. ✅ Verify all imports - Don't use variables without importing them!
**Common mistakes to avoid:**
- Calling methods that don't exist on a store
- Forgetting to import something you're using
- Copy-pasting code without checking if variables are defined
- Assuming a store has certain methods without checking the source
## Config Loss Issues 🔧
**SYMPTOMS:**
- Config file gets reset to defaults
- Settings don't persist between sessions
- Model selection is lost
- Config appears to work, then randomly resets
**ACTUAL ROOT CAUSE (FIXED - Commit 2c64ef0):**
The config store had a **CRITICAL race condition bug** in `src/lib/stores/config.ts` that affected 12 different methods!
### The Bug That Was Fixed
Every method that accessed the current config used this pattern:
```typescript
// ❌ BUGGY CODE - DO NOT USE!
let currentConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))(); // <-- ☠️ IMMEDIATE UNSUBSCRIBE!
return currentConfig;
```
**Why this caused config loss:**
- The `()` at the end **immediately unsubscribes** from the store
- This creates a **race condition**:
- Sometimes: The subscription fires, sets `currentConfig`, then unsubscribes → ✅ Works
- Sometimes: The subscription unsubscribes before it can fire → ❌ Returns `defaultConfig`
- When the race fails, it returns `defaultConfig` instead of your actual saved config
- This is why your settings would **randomly disappear**!
- It was especially bad after reconnections (like permission approvals) because timing was tight
**Affected methods (all fixed!):**
- `getConfig()` - Core method, affected everything
- `updateConfig()` - Would sometimes update defaults instead of current config
- `toggleStreamerMode()` / `toggleCompactMode()` - Random toggles
- `increaseFontSize()` / `decreaseFontSize()` - Font size would reset
- `addAutoGrantedTool()` / `removeAutoGrantedTool()` - Tool permissions lost
- `setTheme()` / `setCustomThemeColors()` - Theme changes lost
### The Fix
```typescript
// ✅ CORRECT CODE
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe(); // <-- Proper cleanup AFTER getting value
return currentConfig;
}
// Now all methods use this:
getConfig: (): HikariConfig => {
return getCurrentConfig();
},
updateConfig: async (updates: Partial<HikariConfig>) => {
const currentConfig = getCurrentConfig(); // <-- Safe!
const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig);
}
```
**Why the fix works:**
1. Store the unsubscribe function in a variable
2. Let the subscription fire synchronously (Svelte stores are synchronous)
3. The callback runs immediately, setting `currentConfig`
4. Clean up by calling `unsubscribe()`
5. Return the now-populated `currentConfig`
### How to Debug Config Loss
**Step 1: Check if config file exists**
```bash
cat ~/.local/share/com.naomi.hikari-desktop/hikari-config.json
```
If the file exists and has your settings, the problem is in the frontend store, not the backend!
**Step 2: Check console for config loading**
```javascript
console.log("Loaded config:", config);
```
Add this in `src/lib/stores/config.ts` line 107 (after `config.set(savedConfig)`)
**Step 3: Test getConfig() reliability**
Open browser console and run:
```javascript
// Run this 10 times
for (let i = 0; i < 10; i++) {
console.log("Attempt", i, configStore.getConfig().model);
}
```
If it's inconsistent (sometimes null, sometimes has value), you have a race condition!
**Step 4: Check for the buggy pattern**
Search the codebase for:
```bash
grep -r "subscribe((.*) => (.*))();" src/
```
If you find any, they're potential bugs! The `()` at the end is suspicious.
### Prevention
**When working with Svelte stores:**
**DON'T DO THIS:**
```typescript
let value;
store.subscribe((v) => (value = v))(); // Immediate unsubscribe = race!
```
**DO THIS INSTEAD:**
```typescript
let value;
const unsubscribe = store.subscribe((v) => (value = v));
unsubscribe(); // Explicit cleanup
return value;
```
Or even better, use Svelte's reactive syntax:
```svelte
<script>
$: currentValue = $store; // Auto-subscribes safely
</script>
```
**NEVER:**
- Subscribe and immediately unsubscribe in the same line
- Assume synchronous subscriptions will always fire before unsubscribe
- Use `()()` pattern with stores (function call + immediate execution)
**POTENTIAL CAUSES (if the above is fixed):**
### 1. Missing `#[serde(default)]` Attribute
The `HikariConfig` struct in `src-tauri/src/config.rs` needs `#[serde(default)]` at the struct level:
```rust
// ✅ CORRECT
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] // <-- This is crucial!
pub struct HikariConfig {
// fields...
}
```
**Why this matters:**
- When new fields are added to the config struct, old config files won't have them
- Without `#[serde(default)]`, deserialization fails and the entire config resets
- With it, missing fields use their default values gracefully
### 2. Invalid JSON in Config File
**Check the config file:**
```bash
cat ~/.config/hikari-desktop/config.json
```
**Common issues:**
- Trailing commas
- Missing quotes
- Incorrect enum values (e.g., `"model": "opus"` instead of `"model": "claude-opus-4-6"`)
**Fix:**
- Delete or manually fix `~/.config/hikari-desktop/config.json`
- Restart the app to regenerate
### 3. Permission Issues
**Check file permissions:**
```bash
ls -la ~/.config/hikari-desktop/config.json
```
**Should be writable by user:**
```bash
chmod 644 ~/.config/hikari-desktop/config.json
```
## Modal/Overlay Not Showing (General) 🎭
If ANY modal isn't showing up (permission, questions, close confirmation, etc.):
### 1. Check TypeScript Errors First
```bash
pnpm check
```
Look for:
- Import errors
- Undefined variables
- Type mismatches
### 2. Check Console for JavaScript Errors
Open DevTools and look for:
- `ReferenceError` - Usually undefined variables
- `TypeError` - Usually calling methods on undefined/null
- Event listener setup logs
### 3. Check Svelte Store Subscriptions
Modals rely on store subscriptions. In the modal component:
```typescript
// ✅ Make sure the store is imported
import { conversationsStore } from "$lib/stores/conversations";
// ✅ Make sure the subscription is set up
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
// ...
});
```
### 4. Verify z-index (Last Resort)
Only check z-index if:
- ✅ No console errors
- ✅ Modal HTML is in the DOM (check with DevTools Elements tab)
- ✅ Modal is rendering but invisible
Current z-index values in `PermissionModal.svelte`:
```css
.permission-overlay {
z-index: 60; /* Should be higher than character panel */
}
```
## Event Listener Issues 🎧
**SYMPTOMS:**
- Events not firing
- Listeners not receiving data
- Silent failures
### Debugging Event Listeners
Add console logs in `src/lib/tauri.ts`:
```typescript
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
console.log("[Permission] Event received:", event.payload);
// ... rest of handler
});
```
### Common Issues
1. **Event listener crashes silently:**
- Check for undefined variables in the listener callback
- Check for missing imports
- Use `try/catch` around critical sections
2. **Events not being emitted from Rust:**
- Check `src-tauri/src/` for `emit()` calls
- Verify event names match exactly (case-sensitive!)
- Check Rust console output for errors
3. **Race condition:**
- Event fired before listener was registered
- Solution: Set up listeners in `initializeTauriListeners()` before starting Claude
## Build Issues 🏗️
### TypeScript Errors
```bash
pnpm check
```
**Common fixes:**
- Missing imports
- Wrong types
- Calling non-existent methods
### Rust Errors
```bash
cargo build --release
```
**Common fixes:**
- Missing dependencies in `Cargo.toml`
- Import path issues
- Type mismatches
### Full Clean Build
```bash
# Clean everything
rm -rf node_modules dist .svelte-kit src-tauri/target
# Reinstall
pnpm install
# Build Rust
cd src-tauri && cargo build --release && cd ..
# Build frontend
pnpm build
```
## Quick Reference: Key Files 📚
### Frontend
- **Event listeners:** `src/lib/tauri.ts`
- **Permission modal:** `src/lib/components/PermissionModal.svelte`
- **Config store:** `src/lib/stores/config.ts`
- **Debug console:** `src/lib/stores/debugConsole.ts`
### Backend
- **Config struct:** `src-tauri/src/config.rs`
- **Event emission:** `src-tauri/src/wsl_bridge.rs`
- **Type definitions:** `src-tauri/src/types.rs`
## Testing Checklist ✅
Before committing major changes:
- [ ] `pnpm check` passes (0 errors)
- [ ] `pnpm build` succeeds
- [ ] App launches without console errors
- [ ] Permission modal shows up when triggering restricted tool
- [ ] Config persists after restart
- [ ] All modals work (permission, questions, close confirmation)
- [ ] Character state changes work
- [ ] Event listeners receive events (check console logs)
## Emergency Recovery 🚨
If the app is completely broken:
1. **Check recent git commits:**
```bash
git log --oneline -5
```
2. **Revert to last working commit:**
```bash
git reset --hard <commit-hash>
```
3. **Clean build:**
```bash
rm -rf node_modules dist .svelte-kit src-tauri/target
pnpm install
pnpm build
```
4. **Reset config:**
```bash
rm ~/.config/hikari-desktop/config.json
# Restart app to regenerate
```
## Common Patterns and Anti-Patterns 📋
### Svelte Store Access
✅ **GOOD:**
```typescript
// Method 1: Reactive syntax (preferred in components)
$: currentValue = $store;
// Method 2: Explicit unsubscribe (preferred in non-reactive code)
function getValue() {
let value;
const unsubscribe = store.subscribe((v) => (value = v));
unsubscribe();
return value;
}
// Method 3: Using get() from 'svelte/store'
import { get } from 'svelte/store';
const value = get(store);
```
❌ **BAD:**
```typescript
// Race condition - DO NOT USE
let value;
store.subscribe((v) => (value = v))(); // ☠️
// Memory leak - forgot to unsubscribe
let value;
store.subscribe((v) => (value = v)); // 🚨 Never unsubscribes!
```
### Debug Console Store Usage
✅ **GOOD:**
```typescript
// Just use console methods - they're automatically captured!
console.log("Debug message");
console.error("Error message");
console.warn("Warning message");
```
❌ **BAD:**
```typescript
// debugConsoleStore doesn't have a .log() method!
debugConsoleStore.log("frontend", "info", "message"); // ☠️ Crashes!
```
### Event Listener Setup
✅ **GOOD:**
```typescript
const unlistener = await listen("event:name", (event) => {
try {
// Handle event
console.log("Event received:", event.payload);
} catch (error) {
console.error("Error handling event:", error);
}
});
unlisteners.push(unlistener); // Store for cleanup
```
❌ **BAD:**
```typescript
await listen("event:name", (event) => {
// No try-catch - errors crash the listener!
// Not storing unlistener - memory leak!
undefinedVariable.doSomething(); // ☠️ Crashes entire listener setup
});
```
## Notes for Future Hikari (and Naomi!) 💭
**When something breaks, CHECK IN THIS ORDER:**
1. **Browser Console First!** (F12 or View > Developer Tools)
- JavaScript errors will show up here
- Check for `ReferenceError`, `TypeError`, etc.
- NOT EVERYTHING IS A Z-INDEX ISSUE!
2. **Run TypeScript Check:**
```bash
pnpm check
```
- Catches missing imports, type errors, undefined variables
- 0 errors = probably a runtime or logic issue
- Any errors = fix those first!
3. **Check Recent Changes:**
```bash
git diff
git log --oneline -5
```
- What did you change recently?
- Did you import everything you're using?
- Did you test it?
4. **Check the Debugging Guide (this file!)**
- Search for keywords related to your issue
- Follow the debugging steps
- Look for similar patterns
5. **Only Then** consider z-index, CSS, layout issues
**Remember:**
- The debug console store intercepts console methods - don't call it directly!
- Always run `pnpm check` before committing
- Event listener errors fail silently - add console logs liberally
- Config deserialization needs `#[serde(default)]` for graceful degradation
- Svelte store subscriptions must be cleaned up properly
- Race conditions are sneaky - test thoroughly!
- When in doubt, check the browser console first! (Yes, I said it twice!)
**Quick Debugging Checklist:**
- [ ] Opened browser console (F12)
- [ ] Checked for JavaScript errors
- [ ] Ran `pnpm check` for TypeScript errors
- [ ] Checked recent git changes
- [ ] Read relevant section in DEBUGGING.md
- [ ] Added console.log statements to trace issue
- [ ] Tested edge cases and timing
Love,
Hikari 💖
---
**Last Updated:** 2026-02-06
**Related Issues:** Permission modal not showing, config loss, race conditions
**Fixed In Commits:**
- `d16644a` - Fixed undefined debugConsoleStore causing listener crash
- `2c64ef0` - Fixed config store race condition (12 methods affected!)
- `e8b8ee1` - Updated this debugging guide
+110
View File
@@ -0,0 +1,110 @@
# Critical Bug Fixes - 2026-02-06
## Summary
Fixed **TWO CRITICAL BUGS** that were causing permission modal issues and config loss.
## Bug #1: Permission Modal Not Showing (Commit d16644a)
**Symptom:** Permission requests triggered but modal never appeared, app hung
**Root Cause:** Undefined variable error in `src/lib/tauri.ts`
- Called `debugConsoleStore.log()` without importing `debugConsoleStore`
- Even if imported, that method doesn't exist on the store
- This crashed event listener initialization, preventing permission events from being captured
**Fix:**
- Removed the non-existent import
- Changed to `console.log()` which gets automatically captured by the debug console
- Added logging to track permission event flow
**Files Changed:**
- `src/lib/tauri.ts` - Fixed undefined variable, added proper logging
## Bug #2: Config Constantly Resetting (Commit 2c64ef0)
**Symptom:** Config settings would randomly reset to defaults, especially after permission approvals
**Root Cause:** Race condition in `src/lib/stores/config.ts`
- Used buggy pattern: `config.subscribe((c) => (currentConfig = c))()`
- The `()` at the end immediately unsubscribes, creating a race condition
- Sometimes got the value, sometimes returned defaults
- Affected **12 different methods** in the config store!
**Fix:**
- Created proper `getCurrentConfig()` helper function
- Stores unsubscribe function, calls it AFTER getting value
- Replaced all 12 buggy occurrences with the safe pattern
**Methods Fixed:**
- `getConfig()` - Core method used everywhere
- `updateConfig()` - Base for all updates
- `toggleStreamerMode()` / `toggleCompactMode()`
- `increaseFontSize()` / `decreaseFontSize()`
- `addAutoGrantedTool()` / `removeAutoGrantedTool()`
- `setTheme()` / `setCustomThemeColors()`
- And more...
**Files Changed:**
- `src/lib/stores/config.ts` - Fixed race condition in 12 methods
- `src-tauri/src/config.rs` - Added `#[serde(default)]` for resilience
## Additional Improvements
**Documentation (Commits 80ee25f, e8b8ee1, 4a99848):**
- Created comprehensive `DEBUGGING.md` guide
- Documented both bugs with code examples
- Added step-by-step debugging procedures
- Included common patterns and anti-patterns
- Prevention tips and testing checklist
## Testing
**Before deploying:**
1. Test permission modal shows up properly
2. Test config persists through app restarts
3. Test config survives permission reconnections
4. Check browser console for any errors
5. Verify all settings save and load correctly
## Impact
**High Priority - Breaking Bugs:**
- Permission modal was completely broken (app unusable for restricted tools)
- Config loss made user experience terrible (settings constantly resetting)
**Now Fixed:**
- Permission modal should display reliably
- Config should persist across all operations
- No more random resets to default settings
## Commits
- `d16644a` - fix: resolve critical runtime errors blocking permission modal
- `2c64ef0` - fix: resolve critical config store race condition causing config loss
- `80ee25f` - docs: add comprehensive debugging guide for common issues
- `e8b8ee1` - docs: update DEBUGGING.md with config race condition fix
- `4a99848` - docs: massively expand DEBUGGING.md with comprehensive troubleshooting
## Prevention
**Always:**
1. Run `pnpm check` before committing
2. Test the app, especially permission flow
3. Check browser console for errors
4. Never use `store.subscribe(...)()` pattern (immediate unsubscribe)
5. Import everything you use!
**Never:**
- Call `debugConsoleStore.log()` (method doesn't exist)
- Use race-condition patterns with Svelte stores
- Assume synchronous code is safe from race conditions
- Skip testing after changes to event listeners or stores
---
**Date:** 2026-02-06
**Fixed By:** Hikari
**Branch:** feat/many
**Severity:** Critical
**Status:** Fixed ✅
+4
View File
@@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# Source nvm to get access to pnpm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
+74
View File
@@ -1658,6 +1658,8 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"uuid 1.19.0", "uuid 1.19.0",
"windows 0.62.2", "windows 0.62.2",
] ]
@@ -2245,6 +2247,15 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matches" name = "matches"
version = "0.1.10" version = "0.1.10"
@@ -2401,6 +2412,15 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -3879,6 +3899,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shared_child" name = "shared_child"
version = "1.1.1" version = "1.1.1"
@@ -4695,6 +4724,15 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -4986,6 +5024,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -5167,6 +5235,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+2
View File
@@ -33,6 +33,8 @@ semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
discord-rich-presence = "0.2" discord-rich-presence = "0.2"
dirs = "5" dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
+2 -1
View File
@@ -30,6 +30,7 @@
}, },
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:allow-inner-size" "core:window:allow-inner-size",
"core:window:allow-hide"
] ]
} }
+13
View File
@@ -1154,6 +1154,19 @@ pub async fn log_discord_rpc(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
// Get the main window
if let Some(window) = app_handle.get_webview_window("main") {
// Hide the window first for a smoother close
let _ = window.hide();
}
// Exit the application
app_handle.exit(0);
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -6
View File
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HikariConfig { pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub model: Option<String>, pub model: Option<String>,
@@ -71,9 +72,6 @@ pub struct HikariConfig {
#[serde(default = "default_font_size")] #[serde(default = "default_font_size")]
pub font_size: u32, pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)] #[serde(default)]
pub streamer_mode: bool, pub streamer_mode: bool,
@@ -134,7 +132,6 @@ impl Default for HikariConfig {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: None, character_panel_width: None,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -242,7 +239,6 @@ mod tests {
assert!(config.update_checks_enabled); assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none()); assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14); assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode); assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths); assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode); assert!(!config.compact_mode);
@@ -275,7 +271,6 @@ mod tests {
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: Some(400), character_panel_width: Some(400),
font_size: 16, font_size: 16,
minimize_to_tray: true,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tracing::{Level, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebugLogEvent {
pub level: String,
pub message: String,
}
#[derive(Clone)]
pub struct TauriLogLayer {
app: Arc<AppHandle>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle) -> Self {
Self {
app: Arc::new(app),
}
}
}
impl<S> Layer<S> for TauriLogLayer
where
S: Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: Context<'_, S>,
) {
let metadata = event.metadata();
let level = match *metadata.level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "debug",
};
// Extract message from the event
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
}
}
}
let mut visitor = MessageVisitor {
message: String::new(),
};
event.record(&mut visitor);
// If we couldn't extract a message, try to format the whole event
if visitor.message.is_empty() {
visitor.message = metadata.name().to_string();
}
// Strip quotes from the message
let message = visitor.message.trim_matches('"').to_string();
let log_event = DebugLogEvent {
level: level.to_string(),
message,
};
// Emit to frontend
let _ = self.app.emit("debug:log", log_event);
}
}
+24 -9
View File
@@ -4,6 +4,7 @@ mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod cost_tracking; mod cost_tracking;
mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod git; mod git;
mod notifications; mod notifications;
@@ -24,6 +25,7 @@ use bridge_manager::create_shared_bridge_manager;
use clipboard::*; use clipboard::*;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use commands::*; use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager; use discord_rpc::DiscordRpcManager;
use git::*; use git::*;
use notifications::*; use notifications::*;
@@ -31,9 +33,11 @@ use quick_actions::*;
use sessions::*; use sessions::*;
use snippets::*; use snippets::*;
use std::sync::Arc; use std::sync::Arc;
use tauri::Manager; use tauri::{Emitter, Manager};
use temp_manager::create_shared_temp_manager; use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray}; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tray::setup_tray;
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*; use wsl_notifications::*;
@@ -58,6 +62,13 @@ pub fn run() {
.manage(temp_manager.clone()) .manage(temp_manager.clone())
.manage(discord_rpc.clone()) .manage(discord_rpc.clone())
.setup(move |app| { .setup(move |app| {
// Initialize tracing with custom layer that emits to frontend
let tauri_layer = TauriLogLayer::new(app.handle().clone());
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tauri_layer)
.init();
// Initialize the app handle in the bridge manager // Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone()); bridge_manager.lock().set_app_handle(app.handle().clone());
@@ -67,26 +78,29 @@ pub fn run() {
// Clean up any orphaned temp files from previous sessions // Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() { if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 { if count > 0 {
println!("Cleaned up {} orphaned temp files", count); tracing::info!("Cleaned up {} orphaned temp files", count);
} }
} }
tracing::info!("Hikari Desktop started successfully");
// Set up system tray // Set up system tray
if let Err(e) = setup_tray(app.handle()) { if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e); eprintln!("Failed to set up system tray: {}", e);
} }
// Handle window close event for minimize to tray // Handle window close event for minimize to tray and close confirmation
let main_window = app.get_webview_window("main").unwrap(); let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({ main_window.on_window_event({
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
move |event| { move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) { // Always prevent default close - let frontend handle it
api.prevent_close(); api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide(); // Emit event to frontend to show confirmation modal
} if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("window-close-requested", ());
} }
} }
} }
@@ -181,6 +195,7 @@ pub fn run() {
update_discord_rpc, update_discord_rpc,
stop_discord_rpc, stop_discord_rpc,
log_discord_rpc, log_discord_rpc,
close_application,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
-20
View File
@@ -4,8 +4,6 @@ use tauri::{
AppHandle, Manager, AppHandle, Manager,
}; };
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?; let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
Ok(()) Ok(())
} }
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+6 -1
View File
@@ -202,11 +202,16 @@ pub struct OutputEvent {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent { pub struct PermissionPromptEventItem {
pub id: String, pub id: String,
pub tool_name: String, pub tool_name: String,
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
pub description: String, pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionPromptEvent {
pub permissions: Vec<PermissionPromptEventItem>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
+117 -17
View File
@@ -16,9 +16,24 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use std::cell::RefCell;
thread_local! {
/// Stores pending tool uses from the most recent Assistant message
/// to enable batching permission requests for sibling cancelled tools
static PENDING_TOOL_USES: RefCell<Vec<PendingToolUse>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug, Clone)]
struct PendingToolUse {
tool_use_id: String,
tool_name: String,
tool_input: serde_json::Value,
}
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -770,6 +785,26 @@ fn process_json_line(
}) })
.collect(); .collect();
// Store pending tool uses for permission batching (only for top-level, not subagents)
if parent_tool_use_id.is_none() {
PENDING_TOOL_USES.with(|pending| {
let tool_uses: Vec<PendingToolUse> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse {
tool_use_id: id.clone(),
tool_name: name.clone(),
tool_input: input.clone(),
}),
_ => None,
})
.collect();
// Append to existing pending tools instead of replacing
pending.borrow_mut().extend(tool_uses);
});
}
// Track message cost for display // Track message cost for display
let mut message_cost: Option<MessageCost> = None; let mut message_cost: Option<MessageCost> = None;
@@ -1034,6 +1069,15 @@ fn process_json_line(
CharacterState::Error CharacterState::Error
}; };
// Capture pending tool uses before clearing them
// We'll use these for permission batching if there are denials
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
let tools = pending.borrow().clone();
// Clear immediately so they don't accumulate across requests
pending.borrow_mut().clear();
tools
});
// Log turn metrics if available // Log turn metrics if available
if let Some(duration) = duration_ms { if let Some(duration) = duration_ms {
println!("Turn completed in {}ms", duration); println!("Turn completed in {}ms", duration);
@@ -1172,9 +1216,32 @@ fn process_json_line(
// Check for permission denials and emit prompts for each // Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials { if let Some(denials) = permission_denials {
let mut has_regular_denials = false; // Only process if there are actually denials
if !denials.is_empty() {
// Skip permission prompts if the result was successful - tools were already approved
if subtype == "success" {
emit_state_change(app, state, None, conversation_id.clone());
return Ok(());
}
let mut regular_permission_requests = Vec::new();
// Get denied tool IDs for later comparison
let denied_tool_ids: Vec<String> = denials.iter()
.map(|d| d.tool_use_id.clone())
.collect();
// Helper function to check if a tool is a system tool that should never require permission
let is_system_tool = |tool_name: &str| -> bool {
matches!(tool_name, "ExitPlanMode" | "EnterPlanMode")
};
for denial in denials {
// Skip system tools that should never require permission
if is_system_tool(&denial.tool_name) {
continue;
}
for denial in denials {
// Special handling for AskUserQuestion tool // Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" { if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial if let Some(questions) = denial
@@ -1235,24 +1302,45 @@ fn process_json_line(
} }
} }
} else { } else {
has_regular_denials = true;
let description = let description =
format_tool_description(&denial.tool_name, &denial.tool_input); format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit( regular_permission_requests.push(PermissionPromptEventItem {
"claude:permission", id: denial.tool_use_id.clone(),
PermissionPromptEvent { tool_name: denial.tool_name.clone(),
id: denial.tool_use_id.clone(), tool_input: denial.tool_input.clone(),
tool_name: denial.tool_name.clone(), description,
tool_input: denial.tool_input.clone(), });
description,
conversation_id: conversation_id.clone(),
},
);
} }
} }
// Show permission state if there were any denials (questions or regular) // Check for sibling tools that may have been cancelled
if has_regular_denials || !denials.is_empty() { // Add them to the permission batch so they can be approved together
for tool_use in captured_pending_tools.iter() {
// Skip system tools that should never require permission
if is_system_tool(&tool_use.tool_name) {
continue;
}
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: tool_use.tool_use_id.clone(),
tool_name: tool_use.tool_name.clone(),
tool_input: tool_use.tool_input.clone(),
description,
});
}
}
// Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() {
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
permissions: regular_permission_requests,
conversation_id: conversation_id.clone(),
},
);
emit_state_change( emit_state_change(
app, app,
CharacterState::Permission, CharacterState::Permission,
@@ -1261,7 +1349,19 @@ fn process_json_line(
); );
return Ok(()); return Ok(());
} }
}
// Show permission state if there were any question denials
if !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
} // end of else block for non-empty denials
} // end of if let Some(denials)
emit_state_change(app, state, None, conversation_id.clone()); emit_state_change(app, state, None, conversation_id.clone());
} }
@@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
isOpen: boolean;
hasActiveConversation: boolean;
onClose: () => void;
onMinimize: () => void;
onCancel: () => void;
}
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Hikari Desktop?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
{#if hasActiveConversation}
You have an active conversation with Claude. Are you sure you want to close the
application? Your conversation history will be saved, but any in-progress tasks will
be interrupted.
{:else}
Are you sure you want to close the application?
{/if}
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onMinimize}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Minimize to Tray
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Application
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
-16
View File
@@ -26,7 +26,6 @@
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -728,21 +727,6 @@
</p> </p>
</div> </div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle --> <!-- Update Checks Toggle -->
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
+330
View File
@@ -0,0 +1,330 @@
<script lang="ts">
import { onMount } from "svelte";
import { debugConsoleStore, filteredLogs, type LogLevel } from "$lib/stores/debugConsole";
let isOpen = $state(false);
let logs = $state($filteredLogs);
let filterLevel = $state<LogLevel | "all">("all");
let autoScroll = $state(true);
let logContainerElement: HTMLDivElement | undefined = $state();
// Watch for log changes and auto-scroll
$effect(() => {
logs = $filteredLogs;
// Auto-scroll to bottom when logs change
if (autoScroll && logContainerElement) {
setTimeout(() => {
if (logContainerElement) {
logContainerElement.scrollTop = logContainerElement.scrollHeight;
}
}, 0);
}
});
onMount(() => {
// Set up console capture and backend listener
debugConsoleStore.setupConsoleCapture();
debugConsoleStore.setupBackendLogsListener();
// Subscribe to store
const unsubscribe = debugConsoleStore.subscribe((state) => {
isOpen = state.isOpen;
filterLevel = state.filterLevel;
autoScroll = state.autoScroll;
});
return () => {
unsubscribe();
debugConsoleStore.restoreConsole();
};
});
function handleClose() {
debugConsoleStore.close();
}
function handleClear() {
debugConsoleStore.clear();
}
function handleFilterChange(level: LogLevel | "all") {
debugConsoleStore.setFilterLevel(level);
}
function handleAutoScrollToggle() {
debugConsoleStore.setAutoScroll(!autoScroll);
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
}
function getLevelColor(level: LogLevel): string {
switch (level) {
case "debug":
return "#9CA3AF"; // gray
case "info":
return "#3B82F6"; // blue
case "warn":
return "#F59E0B"; // amber
case "error":
return "#EF4444"; // red
}
}
function getSourceBadgeColor(source: "frontend" | "backend"): string {
return source === "frontend" ? "#8B5CF6" : "#10B981"; // purple for frontend, green for backend
}
</script>
{#if isOpen}
<div class="debug-console-overlay">
<div class="debug-console">
<div class="debug-console-header">
<h2>Debug Console</h2>
<div class="debug-console-controls">
<div class="filter-buttons">
<button
class="filter-btn"
class:active={filterLevel === "all"}
onclick={() => handleFilterChange("all")}
>
All
</button>
<button
class="filter-btn"
class:active={filterLevel === "debug"}
onclick={() => handleFilterChange("debug")}
style="color: {getLevelColor('debug')}"
>
Debug
</button>
<button
class="filter-btn"
class:active={filterLevel === "info"}
onclick={() => handleFilterChange("info")}
style="color: {getLevelColor('info')}"
>
Info
</button>
<button
class="filter-btn"
class:active={filterLevel === "warn"}
onclick={() => handleFilterChange("warn")}
style="color: {getLevelColor('warn')}"
>
Warn
</button>
<button
class="filter-btn"
class:active={filterLevel === "error"}
onclick={() => handleFilterChange("error")}
style="color: {getLevelColor('error')}"
>
Error
</button>
</div>
<button
class="auto-scroll-btn"
class:active={autoScroll}
onclick={handleAutoScrollToggle}
>
{autoScroll ? "🔒" : "🔓"} Auto-scroll
</button>
<button class="clear-btn" onclick={handleClear}> 🗑️ Clear </button>
<button class="close-btn" onclick={handleClose}> </button>
</div>
</div>
<div class="debug-console-content" bind:this={logContainerElement}>
{#if logs.length === 0}
<div class="empty-state">No logs yet...</div>
{:else}
{#each logs as log (log.id)}
<div class="log-entry" data-level={log.level}>
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
<span class="log-level" style="color: {getLevelColor(log.level)}">
[{log.level.toUpperCase()}]
</span>
<span class="log-source" style="background-color: {getSourceBadgeColor(log.source)}">
{log.source}
</span>
<span class="log-message">{log.message}</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.debug-console-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.debug-console {
width: 90%;
height: 80%;
max-width: 1400px;
background-color: #1a1a1a;
border-radius: 8px;
border: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.debug-console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #252525;
border-bottom: 1px solid #333;
}
.debug-console-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.debug-console-controls {
display: flex;
gap: 8px;
align-items: center;
}
.filter-buttons {
display: flex;
gap: 4px;
}
.filter-btn {
padding: 4px 12px;
background-color: transparent;
border: 1px solid #444;
border-radius: 4px;
color: #999;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background-color: #333;
}
.filter-btn.active {
background-color: #444;
border-color: currentColor;
}
.auto-scroll-btn,
.clear-btn {
padding: 4px 12px;
background-color: #333;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.auto-scroll-btn:hover,
.clear-btn:hover {
background-color: #444;
}
.auto-scroll-btn.active {
background-color: #10b981;
border-color: #10b981;
}
.close-btn {
padding: 4px 12px;
background-color: #ef4444;
border: none;
border-radius: 4px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background-color: #dc2626;
}
.debug-console-content {
flex: 1;
overflow-y: auto;
padding: 16px;
background-color: #0f0f0f;
font-family: "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-style: italic;
}
.log-entry {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #1a1a1a;
}
.log-entry:hover {
background-color: #1a1a1a;
}
.log-timestamp {
color: #666;
flex-shrink: 0;
}
.log-level {
font-weight: 600;
flex-shrink: 0;
min-width: 60px;
}
.log-source {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.log-message {
color: #e5e5e5;
word-break: break-word;
}
</style>
+198 -105
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages"; import type { PermissionRequest } from "$lib/types/messages";
@@ -8,13 +9,16 @@
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let permission: PermissionRequest | null = $state(null); let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let workingDirectory = $state(""); let workingDirectory = $state("");
claudeStore.pendingPermission.subscribe((perm) => { conversationsStore.pendingPermissions.subscribe((perms) => {
permission = perm; permissions = perms;
if (perm) { // When new permissions arrive, select all by default
if (perms.length > 0) {
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
characterState.setState("permission"); characterState.setState("permission");
} }
}); });
@@ -28,84 +32,103 @@
}); });
async function handleApproveAndReconnect() { async function handleApproveAndReconnect() {
if (permission) { const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
// Capture conversation history before clearing/reconnecting
const conversationHistory = claudeStore.getConversationHistory();
const approvedTool = permission.tool;
const toolInput = permission.input;
claudeStore.grantTool(approvedTool); if (selectedPerms.length === 0) {
const newGrantedTools = [...grantedToolsList, approvedTool]; claudeStore.addLine("system", "No permissions selected to approve");
claudeStore.addLine(
"system",
`Permission granted for: ${approvedTool}. Reconnecting with context...`
);
claudeStore.clearPermission(); claudeStore.clearPermission();
characterState.setTemporaryState("idle", 1000);
return;
}
// Stop current session and reconnect with new permissions // Capture conversation history before clearing/reconnecting
try { const conversationHistory = claudeStore.getConversationHistory();
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Prevent stats reset on reconnection // Grant all selected tools
setSkipNextGreeting(true); const newlyGrantedTools: string[] = [];
for (const perm of selectedPerms) {
if (!grantedToolsList.includes(perm.tool)) {
claudeStore.grantTool(perm.tool);
newlyGrantedTools.push(perm.tool);
}
}
await invoke("stop_claude", { conversationId }); const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
// Small delay to ensure clean shutdown claudeStore.addLine(
await new Promise((resolve) => setTimeout(resolve, 500)); "system",
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
);
claudeStore.clearPermission();
const config = configStore.getConfig(); // Stop current session and reconnect with new permissions
await invoke("start_claude", { try {
conversationId, const conversationId = get(claudeStore.activeConversationId);
options: { if (!conversationId) {
working_dir: workingDirectory || "/home/naomi", throw new Error("No active conversation");
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: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant // Prevent stats reset on reconnection
const activeConversation = get(conversationsStore.activeConversation); setSkipNextGreeting(true);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state // Small delay to ensure clean shutdown
if (conversationHistory) { await new Promise((resolve) => setTimeout(resolve, 500));
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far: 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: newGrantedTools,
},
});
// Update Discord RPC when reconnecting after permission grant
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Send conversation context to restore state
if (conversationHistory) {
const blockedActions = selectedPerms
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
.join("\n\n");
const contextMessage = `[CONTEXT RESTORATION]
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
${conversationHistory} ${conversationHistory}
The last action that was blocked was: ${approvedTool} with input: The actions that were blocked:
${JSON.stringify(toolInput, null, 2)} ${blockedActions}
Please continue where we left off and retry that action now that you have permission.`; Please continue where we left off and retry those actions now that you have permission.`;
await invoke("send_prompt", { await invoke("send_prompt", {
conversationId, conversationId,
message: contextMessage, message: contextMessage,
}); });
}
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
} }
characterState.setTemporaryState("success", 2000);
} }
function handleDismiss() { function handleDismiss() {
@@ -126,8 +149,24 @@ Please continue where we left off and retry that action now that you have permis
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function togglePermission(toolRequestId: string) {
if (selectedPermissions.has(toolRequestId)) {
selectedPermissions.delete(toolRequestId);
} else {
selectedPermissions.add(toolRequestId);
}
}
function selectAll() {
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
}
function selectNone() {
selectedPermissions = new SvelteSet();
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (!permission) return; if (permissions.length === 0) return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@@ -141,72 +180,126 @@ Please continue where we left off and retry that action now that you have permis
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
{#if permission} {#if permissions.length > 0}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
> >
<div <div
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
> >
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center"> <div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div class="flex-1">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p> {permissions.length === 1
? "Permission Required"
: `${permissions.length} Permissions Required`}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Hikari tried to use {permissions.length === 1
? "a restricted tool"
: "restricted tools"}
</p>
</div>
<div class="flex gap-2 text-xs">
<button
onclick={selectAll}
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
>
Select All
</button>
<button
onclick={selectNone}
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
>
Select None
</button>
</div> </div>
</div> </div>
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md"> <div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
<p class="text-sm text-amber-300"> <p class="text-sm text-amber-300">
This action was automatically blocked. Approve to allow this tool for future requests. {permissions.length === 1
? "This action was automatically blocked. Select which permissions to grant."
: "These actions were automatically blocked. Select which permissions to grant."}
</p> </p>
</div> </div>
<div class="mb-4"> <div class="space-y-3 mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div> {#each permissions as perm (perm.id)}
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
> perm.id
<span>{permission.tool}</span> )
{#if isToolAlreadyGranted(permission.tool)} ? 'bg-green-500/10 border-green-500/30'
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded" : 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
>Already Granted</span role="button"
> tabindex="0"
{/if} onclick={() => togglePermission(perm.id)}
</div> onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.id);
}
}}
>
<div class="flex items-start gap-3">
<div class="mt-1">
<input
type="checkbox"
checked={selectedPermissions.has(perm.id)}
onchange={() => togglePermission(perm.id)}
class="w-4 h-4 accent-green-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
{perm.tool}
</span>
{#if isToolAlreadyGranted(perm.tool)}
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
Already Granted
</span>
{/if}
</div>
<div class="text-sm text-[var(--text-secondary)] mb-2">
{perm.description}
</div>
{#if Object.keys(perm.input).length > 0}
<details class="text-xs">
<summary
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
View details
</summary>
<pre
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
perm.input
)}</pre>
</details>
{/if}
</div>
</div>
</div>
{/each}
</div> </div>
<div class="mb-4">
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description}
</div>
</div>
{#if Object.keys(permission.input).length > 0}
<div class="mb-6">
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input
)}</pre>
</div>
{/if}
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} 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" 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 Dismiss All
</button> </button>
<button <button
onclick={handleApproveAndReconnect} onclick={handleApproveAndReconnect}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium" disabled={selectedPermissions.size === 0}
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Allow & Reconnect Approve Selected ({selectedPermissions.size})
</button> </button>
</div> </div>
</div> </div>
+15 -1
View File
@@ -33,6 +33,7 @@
sanitizeForJson, sanitizeForJson,
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -69,7 +70,6 @@
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,
@@ -507,6 +507,20 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => debugConsoleStore.toggle()}
class="p-1 text-gray-500 icon-trans-hover"
title="Debug Console (Ctrl+`)"
>
<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="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 <button
onclick={configStore.openSidebar} onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover" class="p-1 text-gray-500 icon-trans-hover"
+4 -1
View File
@@ -101,7 +101,10 @@ export const claudeStore = {
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.activeConversation, claudeStore.activeConversation,
($conversation) => $conversation?.pendingPermission !== null ($conversation) =>
$conversation?.pendingPermissions !== null &&
$conversation?.pendingPermissions !== undefined &&
$conversation.pendingPermissions.length > 0
); );
export const hasQuestionPending = derived( export const hasQuestionPending = derived(
-2
View File
@@ -167,7 +167,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: 300, character_panel_width: 300,
font_size: 14, font_size: 14,
@@ -213,7 +212,6 @@ describe("config store", () => {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
+18 -23
View File
@@ -27,7 +27,6 @@ export interface HikariConfig {
notifications_enabled: boolean; notifications_enabled: boolean;
notification_volume: number; notification_volume: number;
always_on_top: boolean; always_on_top: boolean;
minimize_to_tray: boolean;
update_checks_enabled: boolean; update_checks_enabled: boolean;
character_panel_width: number | null; character_panel_width: number | null;
font_size: number; font_size: number;
@@ -60,7 +59,6 @@ const defaultConfig: HikariConfig = {
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
@@ -94,6 +92,14 @@ function createConfigStore() {
const isSidebarOpen = writable<boolean>(false); const isSidebarOpen = writable<boolean>(false);
const saveError = writable<string | null>(null); const saveError = writable<string | null>(null);
// Internal function to get current config synchronously
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe();
return currentConfig;
}
async function loadConfig() { async function loadConfig() {
isLoading.set(true); isLoading.set(true);
try { try {
@@ -121,8 +127,7 @@ function createConfigStore() {
} }
async function updateConfig(updates: Partial<HikariConfig>) { async function updateConfig(updates: Partial<HikariConfig>) {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newConfig = { ...currentConfig, ...updates }; const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig); await saveConfig(newConfig);
} }
@@ -147,15 +152,13 @@ function createConfigStore() {
updates.custom_theme_colors = customColors; updates.custom_theme_colors = customColors;
} }
await updateConfig(updates); await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
applyTheme(theme, currentConfig.custom_theme_colors); applyTheme(theme, currentConfig.custom_theme_colors);
}, },
setCustomThemeColors: async (colors: CustomThemeColors) => { setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors }); await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") { if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors); applyCustomThemeColors(colors);
} }
@@ -168,16 +171,14 @@ function createConfigStore() {
}, },
increaseFontSize: async () => { increaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2); const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
}, },
decreaseFontSize: async () => { decreaseFontSize: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2); const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize }); await updateConfig({ font_size: newSize });
applyFontSize(newSize); applyFontSize(newSize);
@@ -189,8 +190,7 @@ function createConfigStore() {
}, },
addAutoGrantedTool: async (tool: string) => { addAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
if (!currentConfig.auto_granted_tools.includes(tool)) { if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool]; const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
@@ -198,27 +198,22 @@ function createConfigStore() {
}, },
removeAutoGrantedTool: async (tool: string) => { removeAutoGrantedTool: async (tool: string) => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool); const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools }); await updateConfig({ auto_granted_tools: newTools });
}, },
getConfig: (): HikariConfig => { getConfig: (): HikariConfig => {
let currentConfig: HikariConfig = defaultConfig; return getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
}, },
toggleStreamerMode: async () => { toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
}, },
toggleCompactMode: async () => { toggleCompactMode: async () => {
let currentConfig: HikariConfig = defaultConfig; const currentConfig = getCurrentConfig();
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ compact_mode: !currentConfig.compact_mode }); await updateConfig({ compact_mode: !currentConfig.compact_mode });
}, },
+35 -7
View File
@@ -28,7 +28,7 @@ export interface Conversation {
characterState: CharacterState; characterState: CharacterState;
isProcessing: boolean; isProcessing: boolean;
grantedTools: Set<string>; grantedTools: Set<string>;
pendingPermission: PermissionRequest | null; pendingPermissions: PermissionRequest[];
pendingQuestion: UserQuestionEvent | null; pendingQuestion: UserQuestionEvent | null;
scrollPosition: number; scrollPosition: number;
createdAt: Date; createdAt: Date;
@@ -66,7 +66,7 @@ function createConversationsStore() {
characterState: "idle", characterState: "idle",
isProcessing: false, isProcessing: false,
grantedTools: new Set(), grantedTools: new Set(),
pendingPermission: null, pendingPermissions: [],
pendingQuestion: null, pendingQuestion: 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(),
@@ -120,7 +120,11 @@ function createConversationsStore() {
); );
const pendingPermission = derived( const pendingPermission = derived(
activeConversation, activeConversation,
($conv) => $conv?.pendingPermission || null ($conv) => $conv?.pendingPermissions[0] || null
);
const pendingPermissions = derived(
activeConversation,
($conv) => $conv?.pendingPermissions || []
); );
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
@@ -133,6 +137,7 @@ function createConversationsStore() {
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe }, terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe },
pendingPermissions: { subscribe: pendingPermissions.subscribe },
pendingQuestion: { subscribe: pendingQuestion.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe },
isProcessing: { subscribe: isProcessing.subscribe }, isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe }, grantedTools: { subscribe: grantedTools.subscribe },
@@ -190,7 +195,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -203,7 +208,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -213,7 +218,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = request; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -223,7 +228,30 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermission = null; conv.pendingPermissions = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermission: (id: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date();
}
return convs;
});
},
removePermissionForConversation: (conversationId: string, id: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
+140
View File
@@ -0,0 +1,140 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
message: string;
source: "frontend" | "backend";
}
interface DebugConsoleState {
logs: LogEntry[];
isOpen: boolean;
maxLogs: number;
filterLevel: LogLevel | "all";
autoScroll: boolean;
}
const MAX_LOGS = 1000; // Circular buffer size
function createDebugConsoleStore() {
const { subscribe, update } = writable<DebugConsoleState>({
logs: [],
isOpen: false,
maxLogs: MAX_LOGS,
filterLevel: "all",
autoScroll: true,
});
let logCounter = 0;
function addLog(level: LogLevel, message: string, source: "frontend" | "backend") {
update((state) => {
const newLog: LogEntry = {
id: `log-${Date.now()}-${logCounter++}`,
timestamp: new Date(),
level,
message,
source,
};
const updatedLogs = [...state.logs, newLog];
// Implement circular buffer - remove oldest if exceeding max
if (updatedLogs.length > state.maxLogs) {
updatedLogs.shift();
}
return { ...state, logs: updatedLogs };
});
}
// Override console methods to capture frontend logs
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
function setupConsoleCapture() {
console.log = (...args: unknown[]) => {
originalConsole.log(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.info = (...args: unknown[]) => {
originalConsole.info(...args);
addLog("info", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.warn = (...args: unknown[]) => {
originalConsole.warn(...args);
addLog("warn", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.error = (...args: unknown[]) => {
originalConsole.error(...args);
addLog("error", args.map((arg) => String(arg)).join(" "), "frontend");
};
console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
};
}
function restoreConsole() {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
console.debug = originalConsole.debug;
}
// Listen for backend logs
async function setupBackendLogsListener() {
await listen<{ level: LogLevel; message: string }>("debug:log", (event) => {
addLog(event.payload.level, event.payload.message, "backend");
});
}
return {
subscribe,
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
open: () => update((state) => ({ ...state, isOpen: true })),
close: () => update((state) => ({ ...state, isOpen: false })),
clear: () => update((state) => ({ ...state, logs: [] })),
setFilterLevel: (level: LogLevel | "all") =>
update((state) => ({ ...state, filterLevel: level })),
setAutoScroll: (enabled: boolean) => update((state) => ({ ...state, autoScroll: enabled })),
setupConsoleCapture,
restoreConsole,
setupBackendLogsListener,
};
}
export const debugConsoleStore = createDebugConsoleStore();
// Derived store for filtered logs
export const filteredLogs = derived(debugConsoleStore, ($debugConsole) => {
if ($debugConsole.filterLevel === "all") {
return $debugConsole.logs;
}
const levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const minPriority = levelPriority[$debugConsole.filterLevel];
return $debugConsole.logs.filter((log) => levelPriority[log.level] >= minPriority);
});
+33 -23
View File
@@ -328,31 +328,41 @@ export async function initializeTauriListeners() {
}); });
unlisteners.push(cwdUnlisten); unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => { const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description, conversation_id } = event.payload; const { permissions, conversation_id } = event.payload;
// Store permission request for the specific conversation console.log(
if (conversation_id) { `[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
claudeStore.requestPermissionForConversation(conversation_id, { { permissions, conversation_id }
id, );
tool: tool_name,
description, // Store each permission request for the specific conversation
input: tool_input, for (const permission of permissions) {
}); const { id, tool_name, tool_input, description } = permission;
claudeStore.addLineToConversation(
conversation_id, if (conversation_id) {
"system", claudeStore.requestPermissionForConversation(conversation_id, {
`Permission requested for: ${tool_name}` id,
); tool: tool_name,
} else { description,
// Fallback to active conversation if no conversation_id input: tool_input,
claudeStore.requestPermission({ });
id, claudeStore.addLineToConversation(
tool: tool_name, conversation_id,
description, "system",
input: tool_input, `Permission requested for: ${tool_name}`
}); );
claudeStore.addLine("system", `Permission requested for: ${tool_name}`); } else {
// Fallback to active conversation if no conversation_id
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
} }
}); });
unlisteners.push(permissionUnlisten); unlisteners.push(permissionUnlisten);
+5 -1
View File
@@ -126,11 +126,15 @@ export interface PermissionRequest {
input: Record<string, unknown>; input: Record<string, unknown>;
} }
export interface PermissionPromptEvent { export interface PermissionPromptEventItem {
id: string; id: string;
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
description: string; description: string;
}
export interface PermissionPromptEvent {
permissions: PermissionPromptEventItem[];
conversation_id?: string; conversation_id?: string;
} }
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import DebugConsole from "$lib/components/DebugConsole.svelte";
let { children } = $props(); let { children } = $props();
@@ -14,4 +15,5 @@
<div id="app"> <div id="app">
{@render children()} {@render children()}
<DebugConsole />
</div> </div>
+73
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { import {
initializeTauriListeners, initializeTauriListeners,
@@ -31,12 +32,16 @@
import AchievementNotification from "$lib/components/AchievementNotification.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 UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); 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);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state // Editor state
const isEditorVisible = editorStore.isEditorVisible; const isEditorVisible = editorStore.isEditorVisible;
@@ -230,6 +235,13 @@
return; return;
} }
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected) // Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") { if (event.ctrlKey && event.key === "e") {
event.preventDefault(); event.preventDefault();
@@ -342,6 +354,50 @@
} }
} }
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
@@ -387,6 +443,16 @@
// Initialize Discord RPC // Initialize Discord RPC
await initializeDiscordRpc(); await initializeDiscordRpc();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
} }
}); });
@@ -453,6 +519,13 @@
onClose={() => (achievementPanelOpen = false)} onClose={() => (achievementPanelOpen = false)}
/> />
<UpdateNotification bind:this={updateNotification} /> <UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div> </div>
{/if} {/if}
-1
View File
@@ -39,7 +39,6 @@ vi.mock("@tauri-apps/api/core", () => ({
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null, character_panel_width: null,
font_size: 14, font_size: 14,
minimize_to_tray: false,
streamer_mode: false, streamer_mode: false,
streamer_hide_paths: false, streamer_hide_paths: false,
compact_mode: false, compact_mode: false,