Compare commits

..

2 Commits

Author SHA1 Message Date
naomi bebf1552a6 release: v1.0.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s
CI / Lint & Test (push) Successful in 15m58s
CI / Build Linux (push) Successful in 19m8s
CI / Build Windows (cross-compile) (push) Successful in 28m41s
2026-01-26 00:33:17 -08:00
naomi b3d79a82ef feat: add tests and assert coverage (#71)
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #71
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-26 00:26:03 -08:00
19 changed files with 42 additions and 128 deletions
-74
View File
@@ -1,74 +0,0 @@
# Hikari Desktop v0.3.0 Release Notes
## New Features
### AskUserQuestion Tool Support (#51)
- Claude can now ask you questions with multiple choice options during conversations
- A dedicated modal appears with answer choices and support for custom responses
- Answers are seamlessly integrated back into the conversation context
### Slash Commands
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
- `/search <query>` - Search and highlight matches within the conversation (#32)
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
### Auto-Update Checker (#17)
- Automatically checks for new releases on startup
- Notification appears when a newer version is available
- Can be disabled in settings
### Font Size & Zoom (#19)
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
- Font size slider in settings (10-24px range)
- Preference persists between sessions
### Resizable Character Panel (#10)
- Drag the divider between the character panel and terminal to resize
- Panel width is saved and restored on app restart
- Sprite now uses full height for better proportions
### Input History Navigation (#13)
- Use up/down arrows to navigate through previous messages and commands
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
### Keyboard Shortcuts (#21)
- `Ctrl+N` - New conversation
- `Ctrl+W` - Close current tab
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
- `Ctrl+L` - Clear conversation
- `Ctrl+,` - Open settings
### Always On Top Toggle (#28)
- Pin the window to stay above other applications
- Toggle in settings
## Improvements
### UI/UX
- Resizable chat input with drag handle (expands upward)
- Send button properly aligned with input field
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
- Light mode text colors improved for better readability
- Scroll position persists per conversation tab when switching
- Confirmation modal when closing connected tabs
- Links in chat now open in default browser (#54)
- Spaces allowed when renaming tabs (#52)
### State Management
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
- Multiple tabs can now request permissions simultaneously without conflicts
## Closed Issues
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
+1 -1
View File
@@ -27,6 +27,6 @@ export default tseslint.config(
}, },
}, },
{ {
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"], ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
} }
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.3.0", "version": "1.0.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.3.0" version = "1.0.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+5 -2
View File
@@ -408,13 +408,15 @@ mod tests {
#[test] #[test]
fn test_max_history_size_is_reasonable() { fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100); assert_eq!(MAX_HISTORY_SIZE, 100);
assert!(MAX_HISTORY_SIZE > 0); // Compile-time assertions for constant bounds
assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check const _: () = assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
} }
// ==================== Pinned entry sorting tests ==================== // ==================== Pinned entry sorting tests ====================
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() { fn test_pinned_entries_sorting() {
let mut entries = vec![ let mut entries = vec![
ClipboardEntry { ClipboardEntry {
@@ -464,6 +466,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() { fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![ let mut entries = vec![
ClipboardEntry { ClipboardEntry {
+4 -4
View File
@@ -611,7 +611,7 @@ mod tests {
#[test] #[test]
fn test_update_info_serialization() { fn test_update_info_serialization() {
let info = UpdateInfo { let info = UpdateInfo {
current_version: "0.3.0".to_string(), current_version: "1.0.0".to_string(),
latest_version: "0.4.0".to_string(), latest_version: "0.4.0".to_string(),
has_update: true, has_update: true,
release_url: "https://example.com/release".to_string(), release_url: "https://example.com/release".to_string(),
@@ -619,7 +619,7 @@ mod tests {
}; };
let json = serde_json::to_string(&info).unwrap(); let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("0.3.0")); assert!(json.contains("1.0.0"));
assert!(json.contains("0.4.0")); assert!(json.contains("0.4.0"));
assert!(json.contains("true")); assert!(json.contains("true"));
assert!(json.contains("New features!")); assert!(json.contains("New features!"));
@@ -628,8 +628,8 @@ mod tests {
#[test] #[test]
fn test_update_info_without_notes() { fn test_update_info_without_notes() {
let info = UpdateInfo { let info = UpdateInfo {
current_version: "0.3.0".to_string(), current_version: "1.0.0".to_string(),
latest_version: "0.3.0".to_string(), latest_version: "1.0.0".to_string(),
has_update: false, has_update: false,
release_url: "https://example.com/release".to_string(), release_url: "https://example.com/release".to_string(),
release_notes: None, release_notes: None,
+2 -2
View File
@@ -733,8 +733,8 @@ mod tests {
let result = git_log(working_dir, Some(10)); let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty // May fail on empty repo or return empty
if result.is_ok() { if let Ok(commits) = result {
assert!(result.unwrap().is_empty()); assert!(commits.is_empty());
} }
} }
+2
View File
@@ -247,6 +247,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() { fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![ let mut actions = vec![
create_test_action("custom-z", "Zebra", false), create_test_action("custom-z", "Zebra", false),
@@ -354,6 +355,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() { fn test_action_find_by_id() {
let actions = vec![ let actions = vec![
create_test_action("action-1", "First", false), create_test_action("action-1", "First", false),
+1
View File
@@ -321,6 +321,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() { fn test_session_sorting_by_activity() {
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
+3
View File
@@ -281,6 +281,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() { fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![ let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false), create_test_snippet("s1", "Zebra", "B-Category", false),
@@ -388,6 +389,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() { fn test_snippet_find_by_id() {
let snippets = vec![ let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false), create_test_snippet("snippet-1", "First", "Cat", false),
@@ -404,6 +406,7 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() { fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![ let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false), create_test_snippet("s1", "S1", "Zebra", false),
+2 -3
View File
@@ -523,9 +523,8 @@ mod tests {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now()); stats.session_start = Some(Instant::now());
// The duration should be at least 0 seconds // Verify duration is returned (u64 is always non-negative)
let duration = stats.get_session_duration(); let _duration = stats.get_session_duration();
assert!(duration >= 0);
} }
#[test] #[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "0.3.0", "version": "1.0.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+6 -10
View File
@@ -1,9 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
NotificationType,
NOTIFICATION_SOUNDS,
type NotificationSound,
} from "./types";
// Mock HTMLAudioElement for soundPlayer tests // Mock HTMLAudioElement for soundPlayer tests
class MockAudioElement { class MockAudioElement {
@@ -27,7 +23,7 @@ class MockAudioElement {
} }
// Store original Audio before mocking // Store original Audio before mocking
const OriginalAudio = global.Audio; const OriginalAudio = globalThis.Audio;
describe("notifications", () => { describe("notifications", () => {
describe("NotificationType enum", () => { describe("NotificationType enum", () => {
@@ -166,12 +162,12 @@ describe("notifications", () => {
describe("SoundPlayer class", () => { describe("SoundPlayer class", () => {
beforeEach(() => { beforeEach(() => {
// Mock Audio constructor // Mock Audio constructor
global.Audio = MockAudioElement as unknown as typeof Audio; globalThis.Audio = MockAudioElement as unknown as typeof Audio;
}); });
afterEach(() => { afterEach(() => {
// Restore original Audio // Restore original Audio
global.Audio = OriginalAudio; globalThis.Audio = OriginalAudio;
vi.resetModules(); vi.resetModules();
}); });
@@ -236,12 +232,12 @@ describe("notifications", () => {
describe("NotificationManager class", () => { describe("NotificationManager class", () => {
beforeEach(() => { beforeEach(() => {
global.Audio = MockAudioElement as unknown as typeof Audio; globalThis.Audio = MockAudioElement as unknown as typeof Audio;
vi.resetModules(); vi.resetModules();
}); });
afterEach(() => { afterEach(() => {
global.Audio = OriginalAudio; globalThis.Audio = OriginalAudio;
}); });
it("can import notificationManager singleton", async () => { it("can import notificationManager singleton", async () => {
-5
View File
@@ -1,11 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { import {
configStore, configStore,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
maskPaths, maskPaths,
clampFontSize, clampFontSize,
applyFontSize, applyFontSize,
+8 -15
View File
@@ -1,5 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect } from "vitest";
import { get } from "svelte/store";
// Test the Conversation interface and store behavior // Test the Conversation interface and store behavior
describe("Conversation interface", () => { describe("Conversation interface", () => {
@@ -252,7 +251,7 @@ describe("conversation management operations", () => {
}); });
it("uses default name when not provided", () => { it("uses default name when not provided", () => {
let counter = 5; const counter = 5;
const createNewConversation = (name?: string) => ({ const createNewConversation = (name?: string) => ({
name: name || `Conversation ${counter}`, name: name || `Conversation ${counter}`,
}); });
@@ -271,9 +270,7 @@ describe("conversation history formatting", () => {
{ type: "user" as const, content: "How are you?" }, { type: "user" as const, content: "How are you?" },
]; ];
const relevantLines = lines.filter( const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
(line) => line.type === "user" || line.type === "assistant"
);
const history = relevantLines const history = relevantLines
.map((line) => { .map((line) => {
@@ -290,9 +287,7 @@ describe("conversation history formatting", () => {
it("returns empty string for no messages", () => { it("returns empty string for no messages", () => {
const lines: Array<{ type: string; content: string }> = []; const lines: Array<{ type: string; content: string }> = [];
const relevantLines = lines.filter( const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
(line) => line.type === "user" || line.type === "assistant"
);
expect(relevantLines.length).toBe(0); expect(relevantLines.length).toBe(0);
const history = relevantLines.length === 0 ? "" : "has content"; const history = relevantLines.length === 0 ? "" : "has content";
@@ -333,7 +328,7 @@ describe("scroll position handling", () => {
}); });
it("uses positive values for manual scroll position", () => { it("uses positive values for manual scroll position", () => {
const scrollPosition = 500; const scrollPosition: number = 500;
const isAutoScroll = scrollPosition === -1; const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(false); expect(isAutoScroll).toBe(false);
@@ -452,9 +447,7 @@ describe("attachment management", () => {
}); });
it("clears all attachments", () => { it("clears all attachments", () => {
const attachments = [{ id: "att-1" }, { id: "att-2" }]; const cleared: Array<{ id: string }> = [];
const cleared: typeof attachments = [];
expect(cleared).toHaveLength(0); expect(cleared).toHaveLength(0);
}); });
}); });
@@ -468,7 +461,7 @@ describe("derived store behavior", () => {
}); });
it("defaults to disconnected when no active conversation", () => { it("defaults to disconnected when no active conversation", () => {
const activeConv = null; const activeConv = null as { connectionStatus?: string } | null;
const derivedStatus = activeConv?.connectionStatus || "disconnected"; const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("disconnected"); expect(derivedStatus).toBe("disconnected");
@@ -487,7 +480,7 @@ describe("derived store behavior", () => {
}); });
it("defaults to empty array when no active conversation", () => { it("defaults to empty array when no active conversation", () => {
const activeConv = null; const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
const derivedLines = activeConv?.terminalLines || []; const derivedLines = activeConv?.terminalLines || [];
expect(derivedLines).toEqual([]); expect(derivedLines).toEqual([]);
+1 -2
View File
@@ -264,8 +264,7 @@ describe("quickActionsStore", () => {
describe("quick action ID generation", () => { describe("quick action ID generation", () => {
it("generates unique custom action IDs", () => { it("generates unique custom action IDs", () => {
const generateId = () => const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId(); const id1 = generateId();
const id2 = generateId(); const id2 = generateId();
+2 -3
View File
@@ -289,8 +289,7 @@ describe("snippetsStore", () => {
describe("snippet ID generation", () => { describe("snippet ID generation", () => {
it("generates unique custom snippet IDs", () => { it("generates unique custom snippet IDs", () => {
const generateId = () => const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId(); const id1 = generateId();
const id2 = generateId(); const id2 = generateId();
@@ -335,7 +334,7 @@ git push`,
it("supports content with special characters", () => { it("supports content with special characters", () => {
const snippet = { const snippet = {
content: 'echo "Hello, World!" && echo \'Single quotes\'', content: "echo \"Hello, World!\" && echo 'Single quotes'",
}; };
expect(snippet.content).toContain('"'); expect(snippet.content).toContain('"');
-2
View File
@@ -319,8 +319,6 @@ describe("tauri event handling", () => {
describe("mock event system", () => { describe("mock event system", () => {
it("can emit events through mock system", () => { it("can emit events through mock system", () => {
const handler = vi.fn();
// The emitMockEvent function should work // The emitMockEvent function should work
expect(typeof emitMockEvent).toBe("function"); expect(typeof emitMockEvent).toBe("function");
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { vi } from "vitest"; import { vi, beforeEach } from "vitest";
// Mock Tauri invoke API // Mock Tauri invoke API
const mockInvokeResults: Record<string, unknown> = {}; const mockInvokeResults: Record<string, unknown> = {};
@@ -13,7 +13,7 @@ export function clearMockInvokeResults() {
} }
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn((command: string, _args?: Record<string, unknown>) => { invoke: vi.fn((command: string) => {
if (command in mockInvokeResults) { if (command in mockInvokeResults) {
const result = mockInvokeResults[command]; const result = mockInvokeResults[command];
if (result instanceof Error) { if (result instanceof Error) {