generated from nhcarrigan/template
feat: add multiple productivity features and UI enhancements (#68)
## 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>
This commit was merged in pull request #68.
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import type { TerminalLine } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let searchInput = $state("");
|
||||
let selectedSession = $state<SavedSession | null>(null);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
});
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
await sessionsStore.searchSessions(searchInput);
|
||||
}
|
||||
|
||||
async function handleViewSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
selectedSession = fullSession;
|
||||
}
|
||||
|
||||
async function handleResumeSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
if (!fullSession) return;
|
||||
|
||||
const newConvId = conversationsStore.createConversation(fullSession.name);
|
||||
|
||||
for (const msg of fullSession.messages) {
|
||||
const line: TerminalLine = {
|
||||
id: msg.id,
|
||||
type: msg.type as TerminalLine["type"],
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
toolName: msg.tool_name,
|
||||
};
|
||||
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
|
||||
}
|
||||
|
||||
if (fullSession.working_directory) {
|
||||
conversationsStore.setWorkingDirectoryForConversation(
|
||||
newConvId,
|
||||
fullSession.working_directory
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDeleteSession(sessionId: string): Promise<void> {
|
||||
await sessionsStore.deleteSession(sessionId);
|
||||
showDeleteConfirm = null;
|
||||
if (selectedSession?.id === sessionId) {
|
||||
selectedSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToList(): void {
|
||||
selectedSession = null;
|
||||
}
|
||||
|
||||
async function handleExportJson(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsJson(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportMarkdown(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsMarkdown(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportHtml(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsHtml(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportPdf(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsPdf(sessionId);
|
||||
}
|
||||
|
||||
async function handleImport(): Promise<void> {
|
||||
isImporting = true;
|
||||
try {
|
||||
await sessionsStore.importSession();
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
} else {
|
||||
showExportMenu = sessionId;
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="session-history-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if selectedSession}
|
||||
<button
|
||||
onclick={handleBackToList}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{selectedSession ? selectedSession.name : "Session History"}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !selectedSession}
|
||||
<button
|
||||
onclick={handleImport}
|
||||
disabled={isImporting}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Import session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedSession}
|
||||
<div class="overflow-y-auto flex-1 p-6">
|
||||
<div class="text-sm text-[var(--text-tertiary)] mb-4">
|
||||
<span>{formatDate(selectedSession.created_at)}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{selectedSession.message_count} messages</span>
|
||||
{#if selectedSession.working_directory}
|
||||
<span class="mx-2">•</span>
|
||||
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each selectedSession.messages as message (message.id)}
|
||||
<div
|
||||
class="p-3 rounded-lg {message.type === 'user'
|
||||
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
|
||||
: message.type === 'assistant'
|
||||
? 'bg-[var(--bg-secondary)]'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-500/10 border border-red-500/20'
|
||||
: 'bg-[var(--bg-tertiary)]'}"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="text-xs font-medium {message.type === 'user'
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: message.type === 'assistant'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: message.type === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-[var(--text-tertiary)]'}"
|
||||
>
|
||||
{message.type === "user"
|
||||
? "You"
|
||||
: message.type === "assistant"
|
||||
? "Hikari"
|
||||
: message.type === "tool"
|
||||
? message.tool_name || "Tool"
|
||||
: message.type}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||
{message.content.length > 500
|
||||
? message.content.slice(0, 500) + "..."
|
||||
: message.content}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
class="w-full px-4 py-2 pl-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if $sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Your conversations will appear here once saved
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{session.message_count} messages
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
|
||||
{session.preview}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{formatDate(session.last_activity_at)}</span>
|
||||
{#if session.working_directory}
|
||||
<span>•</span>
|
||||
<span class="font-mono truncate max-w-[200px]">
|
||||
{session.working_directory}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleResumeSession(session)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Resume this session"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => toggleExportMenu(session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Export session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showExportMenu === session.id}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleExportJson(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportMarkdown(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as Markdown
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportHtml(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as HTML
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportPdf(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as PDF
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDeleteConfirm === session.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDeleteSession(session.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user