feat: add multiple productivity features and UI enhancements (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m42s
CI / Build Linux (push) Successful in 19m4s
CI / Build Windows (cross-compile) (push) Successful in 28m37s

## 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:
2026-01-25 22:19:00 -08:00
committed by Naomi Carrigan
parent 852a4d6661
commit 4c46d4c8fd
47 changed files with 11695 additions and 319 deletions
@@ -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>