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:
+319
-37
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user