Files
hikari-desktop/src/lib/tauri.ts
T
hikari bf411adeb7
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
fix: critical permission modal and config issues (#127)
## Summary

This PR resolves several critical bugs that were blocking the permission modal and causing config loss:

- **Permission modal not appearing** - Fixed z-index issues and runtime errors
- **Config store race condition** - Resolved critical race condition causing settings to be lost
- **Excessive logging** - Removed redundant fmt layer that was writing to hidden stdout
- **System tool prompts** - Prevented unnecessary permission prompts for built-in tools
- **Permission batching** - Added support for parallel permission requests
- **ExitPlanMode tool** - Fixed ExitPlanMode tool not functioning correctly

## Changes Made

### Permission Modal Fixes
- Updated z-index to proper value (9999) to ensure modal appears above all other UI elements
- Fixed runtime errors that were preventing modal from rendering
- Resolved issues with permission grants not being properly applied

### Config Store Race Condition
- Fixed critical race condition where multiple rapid config updates would result in lost settings
- Ensured config writes are properly sequenced to prevent data loss
- Added proper synchronisation for config store operations

### Logging Cleanup
- Removed redundant fmt formatting layer that was outputting to hidden stdout
- Cleaned up excessive debug logging added during troubleshooting
- Removed temporary debugging documentation files

### UX Improvements
- Added close confirmation modal with minimise to tray option
- Implemented batching for parallel permission requests
- Added debug console for viewing frontend and backend logs

### ExitPlanMode Fix
- Fixed ExitPlanMode tool not functioning correctly, ensuring proper transitions out of plan mode

## Issues Resolved

Closes #112 - Permission flow now properly handles multiple tool requests
Closes #113 - ExitPlanMode tool now functions correctly
Closes #126 - Debug console feature added (partial - basic implementation complete)

## Test Plan

- [x] Permission modal appears and functions correctly
- [x] Config settings persist across app restarts
- [x] No excessive logging in production builds
- [x] System tools don't trigger permission prompts
- [x] Parallel permission requests are properly batched
- [x] Debug console displays frontend and backend logs
- [x] ExitPlanMode properly exits plan mode

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #127
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 01:55:49 -08:00

512 lines
16 KiB
TypeScript

import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config";
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
import { initAchievementsListener } from "$lib/stores/achievements";
import type {
ConnectionStatus,
PermissionPromptEvent,
UserQuestionEvent,
} from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
import { agentStore } from "$lib/stores/agents";
import {
initializeNotificationRules,
cleanupNotificationRules,
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
interface StateChangePayload {
state: CharacterState;
tool_name: string | null;
conversation_id?: string;
}
const connectedConversations = new Set<string>();
let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
export function setSkipNextGreeting(skip: boolean) {
skipNextGreeting = skip;
}
function getTimeOfDay(): string {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) {
return "morning";
} else if (hour >= 12 && hour < 17) {
return "afternoon";
} else if (hour >= 17 && hour < 21) {
return "evening";
} else {
return "late night";
}
}
function generateGreetingPrompt(): string {
const timeOfDay = getTimeOfDay();
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
}
async function sendGreeting(conversationId: string) {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
return;
}
const config = configStore.getConfig();
if (!config.greeting_enabled) {
return;
}
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
// Don't show the system prompt in the UI - just trigger Claude to respond
characterState.setState("thinking");
// Reset notification state for greeting
handleNewUserMessage();
try {
await invoke("send_prompt", {
conversationId,
message: greetingPrompt,
});
} catch (error) {
console.error("Failed to send greeting:", error);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
interface OutputPayload {
line_type: string;
content: string;
tool_name: string | null;
conversation_id?: string;
cost?: {
input_tokens: number;
output_tokens: number;
cost_usd: number;
};
parent_tool_use_id?: string;
}
interface ConnectionPayload {
status: ConnectionStatus;
conversation_id?: string;
}
interface SessionPayload {
session_id: string;
conversation_id?: string;
}
interface WorkingDirectoryPayload {
directory: string;
conversation_id?: string;
}
export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
// Clean up any temp files associated with this conversation
try {
await invoke("cleanup_temp_files", { conversationId });
} catch (error) {
console.error("Failed to cleanup temp files for conversation:", error);
}
}
export async function initializeTauriListeners() {
// Cleanup any existing listeners first
cleanupTauriListeners();
// Initialize notification rules
initializeNotificationRules();
// Initialize stats listener
await initStatsListener();
// Initialize achievements listener
await initAchievementsListener();
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
const { status, conversation_id } = event.payload;
// Update connection status for the specific conversation
if (conversation_id) {
claudeStore.setConnectionStatusForConversation(conversation_id, status);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setConnectionStatus(status);
}
// Handle notification for connection status
handleConnectionStatusChange(status);
if (status === "connected") {
// Get the actual conversation ID (fallback to active if not provided)
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
// Add system message to the correct conversation
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Connected to Claude Code"
);
// Update character state for this conversation
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
// Check if this specific conversation has connected before
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
}
}
} else if (status === "disconnected") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Mark all running agents as errored on disconnect, but not during reconnects
// (permission prompts trigger reconnects and agents may complete before reconnect)
if (!skipNextGreeting && targetConversationId) {
agentStore.markAllErrored(targetConversationId);
}
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
if (!skipNextGreeting && targetConversationId) {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
);
}
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
}
} else if (status === "error") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
characterState.setTemporaryState("error", 3000);
}
});
unlisteners.push(connectionUnlisten);
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
const { state, conversation_id } = event.payload;
const stateMap: Record<string, CharacterState> = {
idle: "idle",
thinking: "thinking",
typing: "typing",
searching: "searching",
coding: "coding",
mcp: "mcp",
permission: "permission",
success: "success",
error: "error",
};
const mappedState = stateMap[state.toLowerCase()] || "idle";
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
} else {
// Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
}
}
// Only update global character state for active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (!conversation_id || conversation_id === activeConversationId) {
if (mappedState === "success" || mappedState === "error") {
characterState.setTemporaryState(mappedState, 3000);
} else {
characterState.setState(mappedState);
}
}
});
unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
event.payload;
// Convert snake_case cost to camelCase for TypeScript
const costData = cost
? {
inputTokens: cost.input_tokens,
outputTokens: cost.output_tokens,
costUsd: cost.cost_usd,
}
: undefined;
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined,
costData,
parent_tool_use_id
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined,
costData,
parent_tool_use_id
);
}
});
unlisteners.push(outputUnlisten);
const streamUnlisten = await listen<string>("claude:stream", () => {
// no-op
});
unlisteners.push(streamUnlisten);
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
const { session_id, conversation_id } = event.payload;
// Store session ID for the correct conversation
if (conversation_id) {
claudeStore.setSessionIdForConversation(conversation_id, session_id);
claudeStore.addLineToConversation(
conversation_id,
"system",
`Session: ${session_id.substring(0, 8)}...`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setSessionId(session_id);
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
}
});
unlisteners.push(sessionUnlisten);
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
const { directory, conversation_id } = event.payload;
// Store working directory for the correct conversation
if (conversation_id) {
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setWorkingDirectory(directory);
}
});
unlisteners.push(cwdUnlisten);
console.log("[Tauri Listener] Setting up claude:permission listener");
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { permissions, conversation_id } = event.payload;
console.log(
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
{ permissions, conversation_id }
);
// Store each permission request for the specific conversation
for (const permission of permissions) {
const { id, tool_name, tool_input, description } = permission;
if (conversation_id) {
claudeStore.requestPermissionForConversation(conversation_id, {
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLineToConversation(
conversation_id,
"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);
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
const {
tool_use_id,
agent_id,
description,
subagent_type,
started_at,
conversation_id,
parent_tool_use_id,
} = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.addAgent(targetConversationId, {
toolUseId: tool_use_id,
agentId: agent_id,
description,
subagentType: subagent_type,
startedAt: started_at,
status: "running",
parentToolUseId: parent_tool_use_id,
});
}
});
unlisteners.push(agentStartUnlisten);
const agentUpdateUnlisten = await listen<{
conversationId: string;
toolUseId: string;
agentId: string;
}>("claude:agent-update", (event) => {
const { conversationId, toolUseId, agentId } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId);
});
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
}
});
unlisteners.push(agentEndUnlisten);
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
// Store question request for the specific conversation
if (questionEvent.conversation_id) {
claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent);
claudeStore.addLineToConversation(
questionEvent.conversation_id,
"system",
`Question: ${questionEvent.question}`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.requestQuestion(questionEvent);
claudeStore.addLine("system", `Question: ${questionEvent.question}`);
}
});
unlisteners.push(questionUnlisten);
}
export function cleanupTauriListeners() {
// Cleanup all event listeners
unlisteners.forEach((unlisten) => unlisten());
unlisteners = [];
// Cleanup notification rules
cleanupNotificationRules();
}
export async function initializeDiscordRpc() {
const config = configStore.getConfig();
if (config.discord_rpc_enabled) {
try {
const startedAt = new Date();
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
const model = config.model || "claude";
await invoke("log_discord_rpc", {
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
});
console.log("Initializing Discord RPC with initial activity:", {
session_name: "Idle",
model,
started_at: startedAtUnixSeconds,
});
await invoke("init_discord_rpc", {
sessionName: "Idle",
model,
startedAt: startedAtUnixSeconds,
});
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC initialized successfully!",
});
console.log("Discord RPC initialized successfully with initial presence");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await invoke("log_discord_rpc", {
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
});
console.error("Failed to initialize Discord RPC:", error);
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
}
} else {
await invoke("log_discord_rpc", {
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
});
}
}
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
const config = configStore.getConfig();
if (!config.discord_rpc_enabled) {
return;
}
try {
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
await invoke("update_discord_rpc", {
sessionName: sessionName,
model,
startedAt: startedAtUnixSeconds,
});
} catch (error) {
console.error("Failed to update Discord RPC:", error);
}
}
export async function stopDiscordRpc() {
try {
await invoke("stop_discord_rpc");
} catch (error) {
console.error("Failed to stop Discord RPC:", error);
}
}