feat: productivity suite — task loop, workflow, theming, docs & more #197

Merged
naomi merged 16 commits from feat/productivity into main 2026-03-07 03:08:33 -08:00
6 changed files with 305 additions and 0 deletions
Showing only changes of commit a98e544b52 - Show all commits
+56
View File
@@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
})
}
#[derive(Debug, serde::Deserialize)]
struct GiteaChangelogRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
created_at: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangelogEntry {
pub version: String,
pub url: String,
pub notes: Option<String>,
pub prerelease: bool,
pub created_at: String,
}
#[tauri::command]
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.query(&[("limit", "50")])
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaChangelogRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
Ok(releases
.into_iter()
.map(|r| ChangelogEntry {
version: r.tag_name,
url: r.html_url,
notes: r.body,
prerelease: r.prerelease,
created_at: r.created_at,
})
.collect())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
+1
View File
@@ -133,6 +133,7 @@ pub fn run() {
validate_directory,
list_skills,
check_for_updates,
fetch_changelog,
save_temp_file,
register_temp_file,
get_temp_files,
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getVersion } from "@tauri-apps/api/app";
import { onMount } from "svelte";
import type { ChangelogEntry } from "$lib/types/messages";
import Markdown from "./Markdown.svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let entries = $state<ChangelogEntry[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let currentVersion = $state("");
export function formatReleaseDate(isoString: string): string {
if (!isoString) return "Unknown date";
const date = new Date(isoString);
if (isNaN(date.getTime())) return "Unknown date";
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
async function loadChangelog(): Promise<void> {
loading = true;
error = null;
try {
entries = await invoke<ChangelogEntry[]>("fetch_changelog");
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(async () => {
currentVersion = await getVersion();
await loadChangelog();
});
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="changelog-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="changelog-title" class="text-xl font-semibold text-[var(--text-primary)]">
Changelog
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-2 border-[var(--accent-primary)] border-t-transparent rounded-full animate-spin"
></div>
<span class="ml-3 text-[var(--text-secondary)]">Fetching releases...</span>
</div>
{:else if error}
<div class="text-center py-12">
<p class="text-red-400 mb-4">{error}</p>
<button onclick={loadChangelog} class="btn-trans-gradient px-4 py-2 rounded text-sm">
Retry
</button>
</div>
{:else if entries.length === 0}
<p class="text-center text-[var(--text-secondary)] py-12">No releases found.</p>
{:else}
<div class="space-y-6">
{#each entries as entry (entry.version)}
<div class="border border-[var(--border-color)] rounded-lg overflow-hidden">
<div
class="flex flex-wrap items-center gap-2 px-4 py-3 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
<span
class="font-mono font-semibold text-sm {entry.version === `v${currentVersion}`
? 'text-[var(--trans-pink)]'
: 'text-[var(--text-primary)]'}"
>
{entry.version}
</span>
{#if entry.version === `v${currentVersion}`}
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--trans-pink)]/20 text-[var(--trans-pink)] border border-[var(--trans-pink)]/30"
>
current
</span>
{/if}
{#if entry.prerelease}
<span
class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
>
pre-release
</span>
{/if}
<span class="ml-auto text-xs text-[var(--text-muted)]">
{formatReleaseDate(entry.created_at)}
</span>
<button
onclick={() => openUrl(entry.url)}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View on Gitea
</button>
</div>
{#if entry.notes}
<div class="p-4 text-sm text-[var(--text-secondary)]">
<Markdown content={entry.notes} />
</div>
{:else}
<p class="p-4 text-xs text-[var(--text-muted)] italic">No release notes.</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
/**
* ChangelogPanel Component Tests
*
* Tests the pure helper function exported by ChangelogPanel for formatting
* ISO 8601 date strings into human-readable release dates.
*
* What this component does:
* - Opens as a modal dialog from the nav menu
* - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount
* - Shows a loading spinner while fetching
* - Renders each release with version badge, date, pre-release badge, and notes
* - Highlights the currently installed version with a pink "current" badge
* - Provides a "View on Gitea" link per release
* - Shows an error state with a Retry button if the fetch fails
*
* Manual testing checklist:
* - [ ] Changelog item appears in the nav dropdown
* - [ ] Clicking opens the panel with a loading spinner
* - [ ] Spinner resolves to a list of releases
* - [ ] Current version entry shows pink version text + "current" badge
* - [ ] Pre-release entries show a yellow "pre-release" badge
* - [ ] "View on Gitea" opens the release URL in the browser
* - [ ] Backdrop click and Escape key close the panel
* - [ ] Network error shows a red error message and a Retry button
* - [ ] Retry button re-fetches the changelog
*/
import { describe, it, expect } from "vitest";
function formatReleaseDate(isoString: string): string {
if (!isoString) return "Unknown date";
const date = new Date(isoString);
if (isNaN(date.getTime())) return "Unknown date";
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
// ---
describe("formatReleaseDate", () => {
it("formats a valid ISO 8601 timestamp to en-GB locale", () => {
const result = formatReleaseDate("2026-02-25T00:00:00Z");
// en-GB format: "25 February 2026"
expect(result).toBe("25 February 2026");
});
it("returns 'Unknown date' for an empty string", () => {
expect(formatReleaseDate("")).toBe("Unknown date");
});
it("returns 'Unknown date' for a non-date string", () => {
expect(formatReleaseDate("not-a-date")).toBe("Unknown date");
});
it("handles a timestamp with a time component", () => {
const result = formatReleaseDate("2025-12-01T14:32:00Z");
expect(result).toBe("1 December 2025");
});
it("formats a single-digit day without leading zero in en-GB", () => {
const result = formatReleaseDate("2026-03-06T00:00:00Z");
expect(result).toBe("6 March 2026");
});
});
+19
View File
@@ -21,6 +21,7 @@
import McpManagementPanel from "./McpManagementPanel.svelte";
import ProjectContextPanel from "./ProjectContextPanel.svelte";
import PrdPanel from "./PrdPanel.svelte";
import ChangelogPanel from "./ChangelogPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -63,6 +64,7 @@
let showMcpPanel = $state(false);
let showProjectContext = $state(false);
let showPrdPanel = $state(false);
let showChangelog = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
@@ -346,6 +348,19 @@
<span>Support Us</span>
</button>
<!-- Changelog -->
<button onclick={menuAction(() => (showChangelog = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6M9 16h4"
/>
</svg>
<span>Changelog</span>
</button>
<!-- About -->
<button onclick={menuAction(() => (showAbout = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -475,6 +490,10 @@
/>
{/if}
{#if showChangelog}
<ChangelogPanel onClose={() => (showChangelog = false)} />
{/if}
<style>
.nav-item {
display: flex;
+8
View File
@@ -188,3 +188,11 @@ export interface UpdateInfo {
release_url: string;
release_notes?: string;
}
export interface ChangelogEntry {
version: string;
url: string;
notes?: string;
prerelease: boolean;
created_at: string;
}