Files
hikari-desktop/src/lib/stores/sessions.ts
T
hikari 4c46d4c8fd
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
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>
2026-01-25 22:19:00 -08:00

709 lines
21 KiB
TypeScript

import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { save, open } from "@tauri-apps/plugin-dialog";
import type { Conversation } from "./conversations";
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openPath } from "@tauri-apps/plugin-opener";
// Debounce delay for auto-save (in milliseconds)
const AUTO_SAVE_DEBOUNCE_MS = 2000;
export interface SavedMessage {
id: string;
type: string;
content: string;
timestamp: string;
tool_name?: string;
}
export interface SavedSession {
id: string;
name: string;
created_at: string;
last_activity_at: string;
working_directory: string;
message_count: number;
preview: string;
messages: SavedMessage[];
}
export interface SessionListItem {
id: string;
name: string;
created_at: string;
last_activity_at: string;
working_directory: string;
message_count: number;
preview: string;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string {
const styles = `
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-code: #1e1e2e;
--accent-primary: #e94560;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #2a2a4a;
--terminal-user: #22d3ee;
--terminal-tool: #c084fc;
--terminal-error: #f87171;
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
h1 { color: var(--accent-primary); margin-bottom: 1rem; }
.metadata {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.metadata p { color: var(--text-secondary); margin: 0.25rem 0; }
.metadata strong { color: var(--text-primary); }
hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; }
.message {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border-left: 4px solid var(--border-color);
}
.message.user { border-left-color: var(--terminal-user); }
.message.assistant { border-left-color: var(--accent-primary); }
.message.tool_use { border-left-color: var(--terminal-tool); }
.message.tool_result { border-left-color: var(--hljs-string); }
.message.error { border-left-color: var(--terminal-error); }
.message.system { border-left-color: var(--text-secondary); }
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.message-type {
font-weight: bold;
text-transform: capitalize;
}
.message-type.user { color: var(--terminal-user); }
.message-type.assistant { color: var(--accent-primary); }
.message-type.tool_use { color: var(--terminal-tool); }
.message-type.tool_result { color: var(--hljs-string); }
.message-type.error { color: var(--terminal-error); }
.message-type.system { color: var(--text-secondary); }
.timestamp { color: var(--text-secondary); font-size: 0.85rem; }
.tool-name { color: var(--terminal-tool); font-size: 0.9rem; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
}
pre {
background: var(--bg-code);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
}
code {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
}
.footer {
text-align: center;
color: var(--text-secondary);
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-code: #f5f5f5;
--text-primary: #1a1a2e;
--text-secondary: #5a5a7a;
--border-color: #d0d0e0;
--terminal-user: #0891b2;
--terminal-tool: #7c3aed;
--terminal-error: #dc2626;
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
}
}
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool Use"
: message.type === "tool_result"
? "Tool Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">🔧 ${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
<style>${styles}</style>
</head>
<body>
<h1>💬 ${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>✨ Exported from Hikari Desktop</p>
<p>Export date: ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string {
const printStyles = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 11pt;
line-height: 1.5;
padding: 0.5in;
max-width: 8.5in;
margin: 0 auto;
color: #000;
background: #fff;
}
h1 {
font-size: 18pt;
margin-bottom: 0.25in;
border-bottom: 2px solid #333;
padding-bottom: 0.1in;
}
.metadata {
background: #f5f5f5;
padding: 0.15in;
margin-bottom: 0.25in;
border: 1px solid #ddd;
font-size: 9pt;
}
.metadata p { margin: 2pt 0; }
hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; }
.message {
margin-bottom: 0.15in;
padding: 0.1in;
border-left: 3px solid #ccc;
page-break-inside: avoid;
}
.message.user { border-left-color: #0088cc; }
.message.assistant { border-left-color: #cc0044; }
.message.tool_use { border-left-color: #8844cc; }
.message.tool_result { border-left-color: #44cc44; }
.message.error { border-left-color: #cc4444; }
.message.system { border-left-color: #888; }
.message-header {
display: flex;
justify-content: space-between;
font-size: 9pt;
color: #666;
margin-bottom: 0.05in;
border-bottom: 1px dotted #ddd;
padding-bottom: 0.03in;
}
.message-type { font-weight: bold; text-transform: uppercase; }
.message-type.user { color: #0066aa; }
.message-type.assistant { color: #aa0033; }
.message-type.tool_use { color: #6633aa; }
.message-type.tool_result { color: #33aa33; }
.message-type.error { color: #aa3333; }
.tool-name { color: #666; font-size: 9pt; font-style: italic; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 9pt;
background: #fafafa;
padding: 0.05in;
}
.footer {
text-align: center;
font-size: 8pt;
color: #999;
margin-top: 0.25in;
padding-top: 0.1in;
border-top: 1px solid #ddd;
}
.print-instructions {
background: #ffffcc;
border: 1px solid #cccc00;
padding: 0.15in;
margin-bottom: 0.25in;
font-size: 10pt;
}
.print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; }
.print-instructions ol { margin-left: 0.2in; }
.print-instructions li { margin: 0.05in 0; }
@media print {
.print-instructions { display: none; }
body { padding: 0; }
}
@page { margin: 0.5in; }
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool"
: message.type === "tool_result"
? "Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Print to PDF</title>
<style>${printStyles}</style>
</head>
<body>
<div class="print-instructions">
<h2>Print to PDF Instructions</h2>
<ol>
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
<li>Click <strong>Print</strong> to save your PDF</li>
</ol>
<p><em>This instruction box will not appear in your PDF.</em></p>
</div>
<h1>${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function createSessionsStore() {
const sessions = writable<SessionListItem[]>([]);
const isLoading = writable(false);
const searchQuery = writable("");
// Track pending auto-save timeouts per conversation
const pendingAutoSaves = new Map<string, ReturnType<typeof setTimeout>>();
async function loadSessions(): Promise<void> {
isLoading.set(true);
try {
const result = await invoke<SessionListItem[]>("list_sessions");
sessions.set(result);
} catch (error) {
console.error("Failed to load sessions:", error);
} finally {
isLoading.set(false);
}
}
async function saveConversation(conversation: Conversation): Promise<void> {
const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({
id: line.id,
type: line.type,
content: line.content,
timestamp: line.timestamp.toISOString(),
tool_name: line.toolName,
}));
const userAndAssistantMessages = conversation.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const previewContent =
userAndAssistantMessages
.slice(0, 3)
.map((m) => m.content)
.join(" ")
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
const session: SavedSession = {
id: conversation.id,
name: conversation.name,
created_at: conversation.createdAt.toISOString(),
last_activity_at: conversation.lastActivityAt.toISOString(),
working_directory: conversation.workingDirectory,
message_count: conversation.terminalLines.length,
preview: previewContent || "Empty conversation",
messages,
};
try {
await invoke("save_session", { session });
await loadSessions();
} catch (error) {
console.error("Failed to save session:", error);
}
}
async function loadSession(sessionId: string): Promise<SavedSession | null> {
try {
const session = await invoke<SavedSession | null>("load_session", {
sessionId,
});
return session;
} catch (error) {
console.error("Failed to load session:", error);
return null;
}
}
async function deleteSession(sessionId: string): Promise<void> {
try {
await invoke("delete_session", { sessionId });
await loadSessions();
} catch (error) {
console.error("Failed to delete session:", error);
}
}
async function searchSessions(query: string): Promise<void> {
searchQuery.set(query);
if (!query.trim()) {
await loadSessions();
return;
}
isLoading.set(true);
try {
const result = await invoke<SessionListItem[]>("search_sessions", {
query,
});
sessions.set(result);
} catch (error) {
console.error("Failed to search sessions:", error);
} finally {
isLoading.set(false);
}
}
async function clearAllSessions(): Promise<void> {
try {
await invoke("clear_all_sessions");
sessions.set([]);
} catch (error) {
console.error("Failed to clear sessions:", error);
}
}
function scheduleAutoSave(conversation: Conversation): void {
// Don't auto-save empty conversations
if (conversation.terminalLines.length === 0) {
return;
}
// Clear any pending auto-save for this conversation
const existingTimeout = pendingAutoSaves.get(conversation.id);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Schedule a new auto-save
const timeout = setTimeout(async () => {
pendingAutoSaves.delete(conversation.id);
try {
await saveConversation(conversation);
} catch (error) {
console.error("Auto-save failed:", error);
}
}, AUTO_SAVE_DEBOUNCE_MS);
pendingAutoSaves.set(conversation.id, timeout);
}
function cancelAutoSave(conversationId: string): void {
const existingTimeout = pendingAutoSaves.get(conversationId);
if (existingTimeout) {
clearTimeout(existingTimeout);
pendingAutoSaves.delete(conversationId);
}
}
async function exportSessionAsJson(sessionId: string): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!filePath) {
return false;
}
const exportData = {
version: 1,
exported_at: new Date().toISOString(),
session,
};
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
return true;
} catch (error) {
console.error("Failed to export session as JSON:", error);
return false;
}
}
async function exportSessionAsMarkdown(sessionId: string): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!filePath) {
return false;
}
let markdown = `# ${session.name}\n\n`;
markdown += `**Created:** ${new Date(session.created_at).toLocaleString()}\n`;
markdown += `**Last Activity:** ${new Date(session.last_activity_at).toLocaleString()}\n`;
markdown += `**Working Directory:** ${session.working_directory || "Not set"}\n`;
markdown += `**Messages:** ${session.message_count}\n\n`;
markdown += `---\n\n`;
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleTimeString();
if (message.type === "user") {
markdown += `## 👤 User (${timestamp})\n\n${message.content}\n\n`;
} else if (message.type === "assistant") {
markdown += `## 🤖 Assistant (${timestamp})\n\n${message.content}\n\n`;
} else if (message.type === "tool_use") {
markdown += `### 🔧 Tool: ${message.tool_name || "Unknown"} (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
} else if (message.type === "tool_result") {
markdown += `### 📋 Result (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
} else if (message.type === "system") {
markdown += `> **System:** ${message.content}\n\n`;
} else if (message.type === "error") {
markdown += `> ⚠️ **Error:** ${message.content}\n\n`;
}
}
markdown += `---\n\n*Exported from Hikari Desktop*\n`;
await writeTextFile(filePath, markdown);
return true;
} catch (error) {
console.error("Failed to export session as Markdown:", error);
return false;
}
}
async function exportSessionAsHtml(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`,
filters: [{ name: "HTML", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generateHtmlExport(session, includeMetadata);
await writeTextFile(filePath, html);
return true;
} catch (error) {
console.error("Failed to export session as HTML:", error);
return false;
}
}
async function exportSessionAsPdf(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`,
filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generatePrintableHtml(session, includeMetadata);
await writeTextFile(filePath, html);
// Open the file in the default browser for print-to-PDF
await openPath(filePath);
return true;
} catch (error) {
console.error("Failed to export session for PDF:", error);
return false;
}
}
async function importSession(): Promise<boolean> {
try {
const filePath = await open({
filters: [{ name: "JSON", extensions: ["json"] }],
multiple: false,
});
if (!filePath) {
return false;
}
const content = await readTextFile(filePath as string);
const importData = JSON.parse(content);
if (!importData.session || !importData.session.id) {
console.error("Invalid session file format");
return false;
}
const session: SavedSession = importData.session;
// Generate a new ID to avoid conflicts with existing sessions
session.id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
session.name = `${session.name} (Imported)`;
await invoke("save_session", { session });
await loadSessions();
return true;
} catch (error) {
console.error("Failed to import session:", error);
return false;
}
}
return {
sessions: { subscribe: sessions.subscribe },
isLoading: { subscribe: isLoading.subscribe },
searchQuery: { subscribe: searchQuery.subscribe },
loadSessions,
saveConversation,
loadSession,
deleteSession,
searchSessions,
clearAllSessions,
scheduleAutoSave,
cancelAutoSave,
exportSessionAsJson,
exportSessionAsMarkdown,
exportSessionAsHtml,
exportSessionAsPdf,
importSession,
};
}
export const sessionsStore = createSessionsStore();