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>
301 lines
9.6 KiB
Svelte
301 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { claudeStore } from "$lib/stores/claude";
|
|
import { onMount } from "svelte";
|
|
import type { Conversation } from "$lib/stores/conversations";
|
|
import { SvelteMap } from "svelte/reactivity";
|
|
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
|
|
|
|
// Use store subscriptions with $ syntax
|
|
const conversations = $derived(claudeStore.conversations);
|
|
const activeConversationId = $derived(claudeStore.activeConversationId);
|
|
|
|
let editingTabId = $state<string | null>(null);
|
|
let editingName = $state("");
|
|
|
|
// Track last seen message count for each conversation
|
|
let lastSeenMessageCount = new SvelteMap<string, number>();
|
|
|
|
// Confirmation modal state
|
|
let showConfirmModal = $state(false);
|
|
let tabToClose = $state<string | null>(null);
|
|
let tabToCloseName = $state("");
|
|
|
|
// Update last seen count when active conversation changes
|
|
$effect(() => {
|
|
if ($activeConversationId) {
|
|
const activeConv = $conversations.get($activeConversationId);
|
|
if (activeConv) {
|
|
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
|
|
// Trigger reactivity
|
|
lastSeenMessageCount = lastSeenMessageCount;
|
|
}
|
|
}
|
|
});
|
|
|
|
function createNewTab() {
|
|
claudeStore.createConversation();
|
|
}
|
|
|
|
async function switchTab(id: string) {
|
|
if (editingTabId) {
|
|
saveTabName();
|
|
}
|
|
await claudeStore.switchConversation(id);
|
|
|
|
// Mark messages as seen when switching to this tab
|
|
const conv = $conversations.get(id);
|
|
if (conv) {
|
|
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
|
// Trigger reactivity
|
|
lastSeenMessageCount = lastSeenMessageCount;
|
|
}
|
|
}
|
|
|
|
function deleteTab(id: string, event: MouseEvent) {
|
|
event.stopPropagation();
|
|
if ($conversations.size > 1) {
|
|
const conversation = $conversations.get(id);
|
|
if (conversation && conversation.connectionStatus === "connected") {
|
|
// Show confirmation modal for connected tabs
|
|
tabToClose = id;
|
|
tabToCloseName = conversation.name;
|
|
showConfirmModal = true;
|
|
} else {
|
|
// Close disconnected tabs immediately
|
|
claudeStore.deleteConversation(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmCloseTab() {
|
|
if (tabToClose) {
|
|
claudeStore.deleteConversation(tabToClose);
|
|
}
|
|
showConfirmModal = false;
|
|
tabToClose = null;
|
|
tabToCloseName = "";
|
|
}
|
|
|
|
function cancelCloseTab() {
|
|
showConfirmModal = false;
|
|
tabToClose = null;
|
|
tabToCloseName = "";
|
|
}
|
|
|
|
function startEditing(id: string, name: string, event: MouseEvent) {
|
|
event.stopPropagation();
|
|
editingTabId = id;
|
|
editingName = name;
|
|
// Focus input after DOM update
|
|
setTimeout(() => {
|
|
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
|
|
if (input) input.focus();
|
|
}, 0);
|
|
}
|
|
|
|
function saveTabName() {
|
|
if (editingTabId && editingName.trim()) {
|
|
claudeStore.renameConversation(editingTabId, editingName.trim());
|
|
}
|
|
editingTabId = null;
|
|
editingName = "";
|
|
}
|
|
|
|
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
|
|
switch (status) {
|
|
case "connected":
|
|
return "bg-green-500";
|
|
case "connecting":
|
|
return "bg-yellow-500";
|
|
case "disconnected":
|
|
return "bg-red-500";
|
|
default:
|
|
return "bg-gray-500";
|
|
}
|
|
}
|
|
|
|
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
|
if (id === $activeConversationId) return false; // Active tab never has unread
|
|
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
|
return conversation.terminalLines.length > lastSeen;
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter") {
|
|
saveTabName();
|
|
} else if (event.key === "Escape") {
|
|
editingTabId = null;
|
|
editingName = "";
|
|
} else if (event.key === " ") {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
switchTab(id);
|
|
}
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
onMount(() => {
|
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
|
// Ctrl/Cmd + T: New tab
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
|
event.preventDefault();
|
|
createNewTab();
|
|
}
|
|
// Ctrl/Cmd + W: Close current tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
|
event.preventDefault();
|
|
if ($activeConversationId && $conversations.size > 1) {
|
|
const conversation = $conversations.get($activeConversationId);
|
|
if (conversation && conversation.connectionStatus === "connected") {
|
|
// Show confirmation modal for connected tabs
|
|
tabToClose = $activeConversationId;
|
|
tabToCloseName = conversation.name;
|
|
showConfirmModal = true;
|
|
} else {
|
|
// Close disconnected tabs immediately
|
|
claudeStore.deleteConversation($activeConversationId);
|
|
}
|
|
}
|
|
}
|
|
// Ctrl/Cmd + Tab: Next tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
const tabs = Array.from($conversations.keys());
|
|
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
if (currentIndex !== -1) {
|
|
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
claudeStore.switchConversation(tabs[nextIndex]);
|
|
}
|
|
}
|
|
// Ctrl/Cmd + Shift + Tab: Previous tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
|
event.preventDefault();
|
|
const tabs = Array.from($conversations.keys());
|
|
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
if (currentIndex !== -1) {
|
|
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
claudeStore.switchConversation(tabs[prevIndex]);
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleGlobalKeydown);
|
|
return () => window.removeEventListener("keydown", handleGlobalKeydown);
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
|
>
|
|
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
|
<div
|
|
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
|
{id === $activeConversationId
|
|
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
|
onclick={() => switchTab(id)}
|
|
onkeydown={(e) => handleTabKeydown(id, e)}
|
|
role="tab"
|
|
tabindex={0}
|
|
aria-selected={id === $activeConversationId}
|
|
>
|
|
{#if editingTabId === id}
|
|
<input
|
|
type="text"
|
|
bind:value={editingName}
|
|
onblur={saveTabName}
|
|
onkeydown={handleKeydown}
|
|
onclick={(e) => e.stopPropagation()}
|
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
|
/>
|
|
{:else}
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
|
title="Connection: {conversation.connectionStatus}"
|
|
></div>
|
|
<span
|
|
class="text-sm pr-2 max-w-[150px] truncate"
|
|
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
|
role="button"
|
|
tabindex={-1}
|
|
>
|
|
{conversation.name}
|
|
</span>
|
|
{#if hasUnreadMessages(id, conversation)}
|
|
<div
|
|
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
|
|
title="New messages"
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
{#if $conversations.size > 1}
|
|
<button
|
|
onclick={(e) => deleteTab(id, e)}
|
|
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
|
|
title="Close tab"
|
|
>
|
|
<svg
|
|
class="w-3 h-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
<button
|
|
onclick={createNewTab}
|
|
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
|
title="New conversation (Ctrl+T)"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<CloseTabConfirmModal
|
|
isOpen={showConfirmModal}
|
|
tabName={tabToCloseName}
|
|
onConfirm={confirmCloseTab}
|
|
onCancel={cancelCloseTab}
|
|
/>
|
|
|
|
<style>
|
|
.terminal-tabs {
|
|
min-height: 36px;
|
|
}
|
|
|
|
.tab-item {
|
|
min-width: 100px;
|
|
}
|
|
</style>
|