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
+319 -37
View File
@@ -3,16 +3,19 @@
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize } from "$lib/stores/config";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import CompactMode from "$lib/components/CompactMode.svelte";
import { characterState } from "$lib/stores/character";
import type { CharacterState } from "$lib/types/states";
import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
@@ -21,8 +24,46 @@
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
let initialized = false;
let updateNotification: UpdateNotification;
let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false);
// Window size constants
const COMPACT_WIDTH = 280;
const COMPACT_HEIGHT = 400;
// Store the previous window size to restore when exiting compact mode
let previousWindowSize: { width: number; height: number } | null = null;
characterState.subscribe((state) => {
currentCharacterState = state;
});
isCompactMode.subscribe((value) => {
compactModeActive = value;
});
function getPanelGlowClass(): string {
switch (currentCharacterState) {
case "thinking":
return "panel-glow-thinking";
case "typing":
return "panel-glow-typing";
case "searching":
return "panel-glow-searching";
case "coding":
return "panel-glow-coding";
case "mcp":
return "panel-glow-mcp";
case "success":
return "panel-glow-success";
case "error":
return "panel-glow-error";
default:
return "";
}
}
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
@@ -121,6 +162,20 @@
configStore.resetFontSize();
return;
}
// Ctrl+Shift+S - Toggle streamer mode
if (event.ctrlKey && event.shiftKey && event.key === "S") {
event.preventDefault();
configStore.toggleStreamerMode();
return;
}
// Ctrl+Shift+M - Toggle compact mode
if (event.ctrlKey && event.shiftKey && event.key === "M") {
event.preventDefault();
toggleCompactMode();
return;
}
}
async function handleInterrupt() {
@@ -135,6 +190,59 @@
}
}
async function enterCompactMode() {
try {
const window = getCurrentWindow();
const currentSize = await window.innerSize();
const scaleFactor = await window.scaleFactor();
// Save current window size (convert from physical to logical pixels)
// innerSize() returns physical pixels, but setSize() with LogicalSize expects logical
previousWindowSize = {
width: Math.round(currentSize.width / scaleFactor),
height: Math.round(currentSize.height / scaleFactor),
};
// Resize to compact dimensions
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
// Enable compact mode in config
await configStore.setCompactMode(true);
} catch (error) {
console.error("Failed to enter compact mode:", error);
}
}
async function exitCompactMode() {
try {
const window = getCurrentWindow();
// Only resize if we have a saved previous size
// (i.e., user entered compact mode during this session)
// Otherwise, just expand to a reasonable default
if (previousWindowSize) {
await window.setSize(new LogicalSize(previousWindowSize.width, previousWindowSize.height));
previousWindowSize = null;
} else {
// No saved size (e.g., app started in compact mode) - use modest default
await window.setSize(new LogicalSize(900, 650));
}
// Disable compact mode in config
await configStore.setCompactMode(false);
} catch (error) {
console.error("Failed to exit compact mode:", error);
}
}
async function toggleCompactMode() {
if (compactModeActive) {
await exitCompactMode();
} else {
await enterCompactMode();
}
}
onMount(async () => {
if (!initialized) {
initialized = true;
@@ -147,7 +255,7 @@
// Apply saved settings on startup
const config = configStore.getConfig();
applyTheme(config.theme);
applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size);
// Apply always-on-top setting
@@ -171,6 +279,12 @@
if (config.update_checks_enabled) {
updateNotification?.checkForUpdates();
}
// Apply compact mode if saved (resize window)
if (config.compact_mode) {
const window = getCurrentWindow();
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
}
}
});
@@ -184,43 +298,56 @@
});
</script>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
{#if compactModeActive}
<!-- Compact mode: minimal widget interface -->
<div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
>
<CompactMode onExpand={exitCompactMode} />
</div>
{:else}
<!-- Full mode: standard interface -->
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode}
/>
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
>
<AnimeGirl />
</div>
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
>
<AnimeGirl />
</div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal />
<InputBar />
</div>
</main>
<!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal />
<InputBar />
</div>
</main>
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
</div>
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
</div>
{/if}
<style>
.app-container {
@@ -235,6 +362,161 @@
.character-panel {
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
transition: all 0.5s ease;
position: relative;
}
.character-panel::before {
content: "";
position: absolute;
inset: 0;
padding: 3px;
background: transparent;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
/* Trans pride gradient glow effects for the character panel */
.panel-glow-thinking {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #9333ea) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(147, 51, 234, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-thinking::before {
background: linear-gradient(180deg, #9333ea, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-typing {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #3b82f6) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(59, 130, 246, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.15),
0 0 40px rgba(91, 206, 250, 0.25),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-typing::before {
background: linear-gradient(180deg, #3b82f6, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-searching {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #eab308) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(234, 179, 8, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-searching::before {
background: linear-gradient(180deg, #eab308, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-coding {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #22c55e) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(34, 197, 94, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-coding::before {
background: linear-gradient(180deg, #22c55e, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-mcp {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 80%, var(--trans-blue)) 0%,
color-mix(in srgb, var(--bg-primary) 85%, var(--trans-pink)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 100%
);
box-shadow:
inset 0 0 80px rgba(91, 206, 250, 0.2),
inset 0 0 120px rgba(245, 169, 184, 0.15),
0 0 60px rgba(91, 206, 250, 0.3),
0 0 100px rgba(245, 169, 184, 0.2);
}
.panel-glow-mcp::before {
background: var(--trans-gradient-vibrant);
opacity: 1;
}
.panel-glow-success {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #10b981) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(16, 185, 129, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-success::before {
background: linear-gradient(180deg, #10b981, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-error {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 80%, #ef4444) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 50%,
color-mix(in srgb, var(--bg-primary) 95%, var(--trans-blue)) 100%
);
box-shadow:
inset 0 0 60px rgba(239, 68, 68, 0.2),
inset 0 0 100px rgba(245, 169, 184, 0.1),
0 0 40px rgba(245, 169, 184, 0.2),
0 0 80px rgba(239, 68, 68, 0.15);
}
.panel-glow-error::before {
background: linear-gradient(180deg, #ef4444, var(--trans-pink), var(--trans-blue));
opacity: 1;
}
.resize-handle:hover,