generated from nhcarrigan/template
82061f125b
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.
308 lines
10 KiB
Svelte
308 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { get } from "svelte/store";
|
|
import { SvelteSet } from "svelte/reactivity";
|
|
import { claudeStore } from "$lib/stores/claude";
|
|
import { characterState } from "$lib/stores/character";
|
|
import type { PermissionRequest } from "$lib/types/messages";
|
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
|
import { conversationsStore } from "$lib/stores/conversations";
|
|
import { configStore } from "$lib/stores/config";
|
|
|
|
let permissions: PermissionRequest[] = $state([]);
|
|
let selectedPermissions = new SvelteSet<string>();
|
|
let grantedToolsList: string[] = $state([]);
|
|
let workingDirectory = $state("");
|
|
|
|
conversationsStore.pendingPermissions.subscribe((perms) => {
|
|
permissions = perms;
|
|
// When new permissions arrive, select all by default
|
|
if (perms.length > 0) {
|
|
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
|
|
characterState.setState("permission");
|
|
}
|
|
});
|
|
|
|
claudeStore.grantedTools.subscribe((tools) => {
|
|
grantedToolsList = Array.from(tools);
|
|
});
|
|
|
|
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
|
workingDirectory = dir;
|
|
});
|
|
|
|
async function handleApproveAndReconnect() {
|
|
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
|
|
|
|
if (selectedPerms.length === 0) {
|
|
claudeStore.addLine("system", "No permissions selected to approve");
|
|
claudeStore.clearPermission();
|
|
characterState.setTemporaryState("idle", 1000);
|
|
return;
|
|
}
|
|
|
|
// Capture conversation history before clearing/reconnecting
|
|
const conversationHistory = claudeStore.getConversationHistory();
|
|
|
|
// Grant all selected tools
|
|
const newlyGrantedTools: string[] = [];
|
|
for (const perm of selectedPerms) {
|
|
if (!grantedToolsList.includes(perm.tool)) {
|
|
claudeStore.grantTool(perm.tool);
|
|
newlyGrantedTools.push(perm.tool);
|
|
}
|
|
}
|
|
|
|
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
|
|
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
|
|
|
|
claudeStore.addLine(
|
|
"system",
|
|
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
|
|
);
|
|
claudeStore.clearPermission();
|
|
|
|
// Stop current session and reconnect with new permissions
|
|
try {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
throw new Error("No active conversation");
|
|
}
|
|
|
|
// Prevent stats reset on reconnection
|
|
setSkipNextGreeting(true);
|
|
|
|
await invoke("stop_claude", { conversationId });
|
|
|
|
// Small delay to ensure clean shutdown
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
const config = configStore.getConfig();
|
|
await invoke("start_claude", {
|
|
conversationId,
|
|
options: {
|
|
working_dir: workingDirectory || "/home/naomi",
|
|
model: config.model || null,
|
|
api_key: config.api_key || null,
|
|
custom_instructions: config.custom_instructions || null,
|
|
mcp_servers_json: config.mcp_servers_json || null,
|
|
allowed_tools: 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}
|
|
|
|
The actions that were blocked:
|
|
${blockedActions}
|
|
|
|
Please continue where we left off and retry those actions now that you have permission.`;
|
|
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message: contextMessage,
|
|
});
|
|
}
|
|
characterState.setTemporaryState("success", 2000);
|
|
} catch (error) {
|
|
console.error("Failed to reconnect:", error);
|
|
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
|
}
|
|
}
|
|
|
|
function handleDismiss() {
|
|
claudeStore.clearPermission();
|
|
claudeStore.addLine("system", "Permission request dismissed");
|
|
characterState.setTemporaryState("idle", 1000);
|
|
}
|
|
|
|
function formatInput(input: Record<string, unknown>): string {
|
|
try {
|
|
return JSON.stringify(input, null, 2);
|
|
} catch {
|
|
return String(input);
|
|
}
|
|
}
|
|
|
|
function isToolAlreadyGranted(toolName: string): boolean {
|
|
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) {
|
|
if (permissions.length === 0) return;
|
|
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
handleApproveAndReconnect();
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
handleDismiss();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
{#if permissions.length > 0}
|
|
<div
|
|
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
|
>
|
|
<div
|
|
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="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
|
<span class="text-xl">🔐</span>
|
|
</div>
|
|
<div class="flex-1">
|
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
|
{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 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">
|
|
{permissions.length === 1
|
|
? "This action was automatically blocked. Select which permissions to grant."
|
|
: "These actions were automatically blocked. Select which permissions to grant."}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-3 mb-6">
|
|
{#each permissions as perm (perm.id)}
|
|
<div
|
|
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
|
|
perm.id
|
|
)
|
|
? 'bg-green-500/10 border-green-500/30'
|
|
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => togglePermission(perm.id)}
|
|
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 class="flex gap-3">
|
|
<button
|
|
onclick={handleDismiss}
|
|
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
|
>
|
|
Dismiss All
|
|
</button>
|
|
<button
|
|
onclick={handleApproveAndReconnect}
|
|
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"
|
|
>
|
|
Approve Selected ({selectedPermissions.size})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|