generated from nhcarrigan/template
4c46d4c8fd
## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *✨ This PR was created with help from Hikari~ 🌸* Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #68 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
265 lines
8.6 KiB
Svelte
265 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { get } from "svelte/store";
|
|
import { SvelteSet } from "svelte/reactivity";
|
|
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
|
import { characterState } from "$lib/stores/character";
|
|
import type { UserQuestionEvent } from "$lib/types/messages";
|
|
|
|
let isVisible = $state(false);
|
|
let question: UserQuestionEvent | null = $state(null);
|
|
let selectedOptions: SvelteSet<string> = new SvelteSet();
|
|
let customAnswer = $state("");
|
|
let showCustomInput = $state(false);
|
|
let grantedToolsList: string[] = $state([]);
|
|
let workingDirectory = $state("");
|
|
|
|
hasQuestionPending.subscribe((pending) => {
|
|
isVisible = pending;
|
|
if (!pending) {
|
|
selectedOptions.clear();
|
|
customAnswer = "";
|
|
showCustomInput = false;
|
|
}
|
|
});
|
|
|
|
claudeStore.pendingQuestion.subscribe((q) => {
|
|
question = q;
|
|
if (q) {
|
|
characterState.setState("permission");
|
|
}
|
|
});
|
|
|
|
claudeStore.grantedTools.subscribe((tools) => {
|
|
grantedToolsList = Array.from(tools);
|
|
});
|
|
|
|
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
|
workingDirectory = dir;
|
|
});
|
|
|
|
function toggleOption(label: string) {
|
|
if (!question) return;
|
|
|
|
if (question.multi_select) {
|
|
if (selectedOptions.has(label)) {
|
|
selectedOptions.delete(label);
|
|
} else {
|
|
selectedOptions.add(label);
|
|
}
|
|
} else {
|
|
selectedOptions.clear();
|
|
selectedOptions.add(label);
|
|
}
|
|
showCustomInput = false;
|
|
}
|
|
|
|
function selectCustom() {
|
|
showCustomInput = true;
|
|
selectedOptions.clear();
|
|
}
|
|
|
|
async function handleSubmitAndReconnect() {
|
|
if (!question) return;
|
|
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) return;
|
|
|
|
let answerText: string;
|
|
|
|
if (showCustomInput && customAnswer.trim()) {
|
|
answerText = customAnswer.trim();
|
|
} else if (selectedOptions.size > 0) {
|
|
if (question.multi_select) {
|
|
answerText = Array.from(selectedOptions).join(", ");
|
|
} else {
|
|
answerText = Array.from(selectedOptions)[0];
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
const questionText = question.question;
|
|
const conversationHistory = claudeStore.getConversationHistory();
|
|
|
|
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
|
|
claudeStore.clearQuestion();
|
|
|
|
try {
|
|
await invoke("stop_claude", { conversationId });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
await invoke("start_claude", {
|
|
conversationId,
|
|
options: {
|
|
working_dir: workingDirectory || "/home/naomi",
|
|
allowed_tools: grantedToolsList,
|
|
},
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
if (conversationHistory) {
|
|
const contextMessage = `[CONTEXT RESTORATION]
|
|
I just answered your question. Here's our conversation so far:
|
|
|
|
${conversationHistory}
|
|
|
|
You asked me: "${questionText}"
|
|
My answer: "${answerText}"
|
|
|
|
Please continue where we left off, taking my answer into account.`;
|
|
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message: contextMessage,
|
|
});
|
|
}
|
|
|
|
characterState.setTemporaryState("success", 2000);
|
|
} catch (error) {
|
|
console.error("Failed to reconnect:", error);
|
|
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
}
|
|
}
|
|
|
|
function handleDismiss() {
|
|
claudeStore.clearQuestion();
|
|
claudeStore.addLine("system", "Question dismissed");
|
|
characterState.setTemporaryState("idle", 1000);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (!isVisible || !question) return;
|
|
|
|
if (event.key === "Enter" && !showCustomInput) {
|
|
event.preventDefault();
|
|
if (selectedOptions.size > 0) {
|
|
handleSubmitAndReconnect();
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
handleDismiss();
|
|
}
|
|
}
|
|
|
|
function canSubmit(): boolean {
|
|
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
{#if isVisible && question}
|
|
<div
|
|
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
|
>
|
|
<div
|
|
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
|
>
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
|
<span class="text-xl">?</span>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
|
{question.header || "Question"}
|
|
</h2>
|
|
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<p class="text-[var(--text-primary)]">{question.question}</p>
|
|
</div>
|
|
|
|
<div class="mb-4 space-y-2">
|
|
{#each question.options as option (option.label)}
|
|
<button
|
|
onclick={() => toggleOption(option.label)}
|
|
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
|
|
option.label
|
|
)
|
|
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
|
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div
|
|
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
|
|
? 'sm'
|
|
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
|
|
option.label
|
|
)
|
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
|
: 'border-[var(--text-secondary)]'}"
|
|
>
|
|
{#if selectedOptions.has(option.label)}
|
|
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="font-medium">{option.label}</div>
|
|
{#if option.description}
|
|
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
|
|
<button
|
|
onclick={selectCustom}
|
|
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
|
|
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
|
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div
|
|
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
|
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
|
: 'border-[var(--text-secondary)]'}"
|
|
>
|
|
{#if showCustomInput}
|
|
<span class="text-white text-xs">x</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="font-medium">Other</div>
|
|
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{#if showCustomInput}
|
|
<div class="mb-4">
|
|
<textarea
|
|
bind:value={customAnswer}
|
|
placeholder="Type your answer here..."
|
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
|
|
rows="3"
|
|
></textarea>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex gap-3">
|
|
<button
|
|
onclick={handleDismiss}
|
|
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
<button
|
|
onclick={handleSubmitAndReconnect}
|
|
disabled={!canSubmit()}
|
|
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Answer & Reconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|