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",
"version": "0.3.0",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "0.3.0"
version = "1.0.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+5 -2
View File
@@ -408,13 +408,15 @@ mod tests {
#[test]
fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100);
assert!(MAX_HISTORY_SIZE > 0);
assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
// Compile-time assertions for constant bounds
const _: () = assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
}
// ==================== Pinned entry sorting tests ====================
#[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
@@ -464,6 +466,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
+4 -4
View File
@@ -611,7 +611,7 @@ mod tests {
#[test]
fn test_update_info_serialization() {
let info = UpdateInfo {
current_version: "0.3.0".to_string(),
current_version: "1.0.0".to_string(),
latest_version: "0.4.0".to_string(),
has_update: true,
release_url: "https://example.com/release".to_string(),
@@ -619,7 +619,7 @@ mod tests {
};
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("true"));
assert!(json.contains("New features!"));
@@ -628,8 +628,8 @@ mod tests {
#[test]
fn test_update_info_without_notes() {
let info = UpdateInfo {
current_version: "0.3.0".to_string(),
latest_version: "0.3.0".to_string(),
current_version: "1.0.0".to_string(),
latest_version: "1.0.0".to_string(),
has_update: false,
release_url: "https://example.com/release".to_string(),
release_notes: None,
+2 -2
View File
@@ -733,8 +733,8 @@ mod tests {
let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty
if result.is_ok() {
assert!(result.unwrap().is_empty());
if let Ok(commits) = result {
assert!(commits.is_empty());
}
}
+2
View File
@@ -247,6 +247,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![
create_test_action("custom-z", "Zebra", false),
@@ -354,6 +355,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() {
let actions = vec![
create_test_action("action-1", "First", false),
+1
View File
@@ -321,6 +321,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() {
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();
+3
View File
@@ -281,6 +281,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false),
@@ -388,6 +389,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() {
let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false),
@@ -404,6 +406,7 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false),
+2 -3
View File
@@ -523,9 +523,8 @@ mod tests {
let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now());
// The duration should be at least 0 seconds
let duration = stats.get_session_duration();
assert!(duration >= 0);
// Verify duration is returned (u64 is always non-negative)
let _duration = stats.get_session_duration();
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "0.3.0",
"version": "1.0.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+6 -10
View File
@@ -1,9 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
NotificationType,
NOTIFICATION_SOUNDS,
type NotificationSound,
} from "./types";
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
// Mock HTMLAudioElement for soundPlayer tests
class MockAudioElement {
@@ -27,7 +23,7 @@ class MockAudioElement {
}
// Store original Audio before mocking
const OriginalAudio = global.Audio;
const OriginalAudio = globalThis.Audio;
describe("notifications", () => {
describe("NotificationType enum", () => {
@@ -166,12 +162,12 @@ describe("notifications", () => {
describe("SoundPlayer class", () => {
beforeEach(() => {
// Mock Audio constructor
global.Audio = MockAudioElement as unknown as typeof Audio;
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
});
afterEach(() => {
// Restore original Audio
global.Audio = OriginalAudio;
globalThis.Audio = OriginalAudio;
vi.resetModules();
});
@@ -236,12 +232,12 @@ describe("notifications", () => {
describe("NotificationManager class", () => {
beforeEach(() => {
global.Audio = MockAudioElement as unknown as typeof Audio;
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
vi.resetModules();
});
afterEach(() => {
global.Audio = OriginalAudio;
globalThis.Audio = OriginalAudio;
});
it("can import notificationManager singleton", async () => {
-5
View File
@@ -1,11 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import {
configStore,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
maskPaths,
clampFontSize,
applyFontSize,
+8 -15
View File
@@ -1,5 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { describe, it, expect } from "vitest";
// Test the Conversation interface and store behavior
describe("Conversation interface", () => {
@@ -252,7 +251,7 @@ describe("conversation management operations", () => {
});
it("uses default name when not provided", () => {
let counter = 5;
const counter = 5;
const createNewConversation = (name?: string) => ({
name: name || `Conversation ${counter}`,
});
@@ -271,9 +270,7 @@ describe("conversation history formatting", () => {
{ type: "user" as const, content: "How are you?" },
];
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
const history = relevantLines
.map((line) => {
@@ -290,9 +287,7 @@ describe("conversation history formatting", () => {
it("returns empty string for no messages", () => {
const lines: Array<{ type: string; content: string }> = [];
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
expect(relevantLines.length).toBe(0);
const history = relevantLines.length === 0 ? "" : "has content";
@@ -333,7 +328,7 @@ describe("scroll position handling", () => {
});
it("uses positive values for manual scroll position", () => {
const scrollPosition = 500;
const scrollPosition: number = 500;
const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(false);
@@ -452,9 +447,7 @@ describe("attachment management", () => {
});
it("clears all attachments", () => {
const attachments = [{ id: "att-1" }, { id: "att-2" }];
const cleared: typeof attachments = [];
const cleared: Array<{ id: string }> = [];
expect(cleared).toHaveLength(0);
});
});
@@ -468,7 +461,7 @@ describe("derived store behavior", () => {
});
it("defaults to disconnected when no active conversation", () => {
const activeConv = null;
const activeConv = null as { connectionStatus?: string } | null;
const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("disconnected");
@@ -487,7 +480,7 @@ describe("derived store behavior", () => {
});
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 || [];
expect(derivedLines).toEqual([]);
+1 -2
View File
@@ -264,8 +264,7 @@ describe("quickActionsStore", () => {
describe("quick action ID generation", () => {
it("generates unique custom action IDs", () => {
const generateId = () =>
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
+2 -3
View File
@@ -289,8 +289,7 @@ describe("snippetsStore", () => {
describe("snippet ID generation", () => {
it("generates unique custom snippet IDs", () => {
const generateId = () =>
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
@@ -335,7 +334,7 @@ git push`,
it("supports content with special characters", () => {
const snippet = {
content: 'echo "Hello, World!" && echo \'Single quotes\'',
content: "echo \"Hello, World!\" && echo 'Single quotes'",
};
expect(snippet.content).toContain('"');
-2
View File
@@ -319,8 +319,6 @@ describe("tauri event handling", () => {
describe("mock event system", () => {
it("can emit events through mock system", () => {
const handler = vi.fn();
// The emitMockEvent function should work
expect(typeof emitMockEvent).toBe("function");
});
+2 -2
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
import { vi, beforeEach } from "vitest";
// Mock Tauri invoke API
const mockInvokeResults: Record<string, unknown> = {};
@@ -13,7 +13,7 @@ export function clearMockInvokeResults() {
}
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn((command: string, _args?: Record<string, unknown>) => {
invoke: vi.fn((command: string) => {
if (command in mockInvokeResults) {
const result = mockInvokeResults[command];
if (result instanceof Error) {