From 372701432a925b590b805e19f574bbb18b01560c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 15:12:47 +1100 Subject: [PATCH 1/3] feat(mark): persist unread project state and badge dock icon Unread project state was previously ephemeral and lost on app restart. Now persisted to preferences.json and the macOS dock icon is badged with the count of unread projects. Co-Authored-By: Claude Opus 4.6 --- apps/mark/src-tauri/capabilities/default.json | 3 +- apps/mark/src/App.svelte | 14 ++++ .../lib/features/layout/navigation.svelte.ts | 6 +- .../src/lib/stores/projectState.svelte.ts | 71 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/mark/src-tauri/capabilities/default.json b/apps/mark/src-tauri/capabilities/default.json index 7cd99d65..74b20594 100644 --- a/apps/mark/src-tauri/capabilities/default.json +++ b/apps/mark/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-close", "clipboard-manager:allow-write-text", "window-state:default", - "store:default" + "store:default", + "core:window:allow-set-badge-count" ] } diff --git a/apps/mark/src/App.svelte b/apps/mark/src/App.svelte index 616ad0a9..b76296de 100644 --- a/apps/mark/src/App.svelte +++ b/apps/mark/src/App.svelte @@ -287,6 +287,20 @@ // Cache the BLOX_ENV value once at startup (process-level, never changes). initBloxEnv(); + // Restore persisted unread state and sync the dock badge. + try { + await projectStateStore.initFromStore(); + // If we restored into a project that was unread, mark it as read now + if ( + navigation.selectedProjectId && + projectStateStore.isUnread(navigation.selectedProjectId) + ) { + projectStateStore.markAsRead(navigation.selectedProjectId); + } + } catch (e) { + console.error('Failed to restore unread state:', e); + } + try { await initializeShortcutBindings(); } catch (e) { diff --git a/apps/mark/src/lib/features/layout/navigation.svelte.ts b/apps/mark/src/lib/features/layout/navigation.svelte.ts index 5c081d20..5dedeed1 100644 --- a/apps/mark/src/lib/features/layout/navigation.svelte.ts +++ b/apps/mark/src/lib/features/layout/navigation.svelte.ts @@ -50,13 +50,15 @@ export async function initNavigation(): Promise { // Validate the project still exists before navigating to it try { const projects = await commands.listProjects(); - const exists = projects.some((p) => p.id === lastProjectId); - if (exists) { + const existingIds = new Set(projects.map((p) => p.id)); + if (existingIds.has(lastProjectId)) { navigation.selectedProjectId = lastProjectId; } else { // Project was deleted — clear the stale value await setStoreValue(LAST_PROJECT_STORE_KEY, null); } + // Remove unread entries for projects that no longer exist + projectStateStore.pruneDeletedProjects(existingIds); } catch { // If we can't list projects (e.g. store error), stay on home console.warn('[Navigation] Could not verify last project, falling back to home'); diff --git a/apps/mark/src/lib/stores/projectState.svelte.ts b/apps/mark/src/lib/stores/projectState.svelte.ts index 46506e04..fca79c3c 100644 --- a/apps/mark/src/lib/stores/projectState.svelte.ts +++ b/apps/mark/src/lib/stores/projectState.svelte.ts @@ -9,12 +9,17 @@ * - The user navigates to that project * * Running sessions are tracked to show spinners on project cards + * Unread state is persisted to disk and the macOS dock badge is kept in sync. * * Note: Session-to-project lookups are now delegated to the unified sessionRegistry */ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getStoreValue, setStoreValue } from '../shared/persistentStore'; import { sessionRegistry, type SessionType } from './sessionRegistry.svelte'; +const UNREAD_PROJECTS_STORE_KEY = 'unread-projects'; + interface ProjectState { unread: boolean; runningSessions: Set; // Set of session IDs currently running in this project @@ -84,6 +89,7 @@ class ProjectStateStore { const state = this.getOrCreateState(projectId); state.unread = false; this.version++; // Trigger reactivity + this.persistAndBadge(); } /** @@ -94,6 +100,7 @@ class ProjectStateStore { const state = this.getOrCreateState(projectId); state.unread = true; this.version++; // Trigger reactivity + this.persistAndBadge(); } /** @@ -190,6 +197,70 @@ class ProjectStateStore { } return types; } + + /** + * Get the number of projects currently in the unread state. + */ + getUnreadCount(): number { + this.version; + let count = 0; + for (const state of this.states.values()) { + if (state.unread) count++; + } + return count; + } + + /** + * Persist the current set of unread project IDs to disk and update the dock badge. + */ + private persistAndBadge(): void { + const ids: string[] = []; + for (const [id, state] of this.states) { + if (state.unread) ids.push(id); + } + setStoreValue(UNREAD_PROJECTS_STORE_KEY, ids); + getCurrentWindow() + .setBadgeCount(ids.length || null) + .catch(() => { + // setBadgeCount may be unsupported on some platforms — ignore + }); + } + + /** + * Restore unread state from the persistent store and sync the badge. + * Must be called after initPersistentStore(). + */ + async initFromStore(): Promise { + const ids = await getStoreValue(UNREAD_PROJECTS_STORE_KEY); + if (ids && ids.length > 0) { + for (const id of ids) { + const state = this.getOrCreateState(id); + state.unread = true; + } + this.version++; + getCurrentWindow() + .setBadgeCount(ids.length) + .catch(() => {}); + } + } + + /** + * Remove unread entries for projects that no longer exist. + * Called during navigation init after the project list is fetched. + */ + pruneDeletedProjects(existingProjectIds: Set): void { + let pruned = false; + for (const [id, state] of this.states) { + if (state.unread && !existingProjectIds.has(id)) { + state.unread = false; + pruned = true; + } + } + if (pruned) { + this.version++; + this.persistAndBadge(); + } + } } export const projectStateStore = new ProjectStateStore(); From 5e66cdf87241d01591ed4d0fa6802d08ab08993b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:23:02 +1100 Subject: [PATCH 2/3] fix(mark): use undefined instead of null for setBadgeCount parameter Co-Authored-By: Claude Opus 4.6 --- apps/mark/src/lib/stores/projectState.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mark/src/lib/stores/projectState.svelte.ts b/apps/mark/src/lib/stores/projectState.svelte.ts index fca79c3c..1e67f0ef 100644 --- a/apps/mark/src/lib/stores/projectState.svelte.ts +++ b/apps/mark/src/lib/stores/projectState.svelte.ts @@ -220,7 +220,7 @@ class ProjectStateStore { } setStoreValue(UNREAD_PROJECTS_STORE_KEY, ids); getCurrentWindow() - .setBadgeCount(ids.length || null) + .setBadgeCount(ids.length || undefined) .catch(() => { // setBadgeCount may be unsupported on some platforms — ignore }); From 0529499b7e1776a1ac035b9e51378a2dfadea7b9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:30:45 +1100 Subject: [PATCH 3/3] fix(mark): make pruneDeletedProjects operate on persisted data Previously pruneDeletedProjects iterated over the in-memory states map, which was empty when called before initFromStore(). Now reads directly from the persistent store so stale unread entries are correctly removed regardless of initialization order. Co-Authored-By: Claude Opus 4.6 --- .../lib/features/layout/navigation.svelte.ts | 2 +- .../src/lib/stores/projectState.svelte.ts | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/mark/src/lib/features/layout/navigation.svelte.ts b/apps/mark/src/lib/features/layout/navigation.svelte.ts index 5dedeed1..0a4844f0 100644 --- a/apps/mark/src/lib/features/layout/navigation.svelte.ts +++ b/apps/mark/src/lib/features/layout/navigation.svelte.ts @@ -58,7 +58,7 @@ export async function initNavigation(): Promise { await setStoreValue(LAST_PROJECT_STORE_KEY, null); } // Remove unread entries for projects that no longer exist - projectStateStore.pruneDeletedProjects(existingIds); + await projectStateStore.pruneDeletedProjects(existingIds); } catch { // If we can't list projects (e.g. store error), stay on home console.warn('[Navigation] Could not verify last project, falling back to home'); diff --git a/apps/mark/src/lib/stores/projectState.svelte.ts b/apps/mark/src/lib/stores/projectState.svelte.ts index 1e67f0ef..2804cc03 100644 --- a/apps/mark/src/lib/stores/projectState.svelte.ts +++ b/apps/mark/src/lib/stores/projectState.svelte.ts @@ -246,20 +246,29 @@ class ProjectStateStore { /** * Remove unread entries for projects that no longer exist. - * Called during navigation init after the project list is fetched. + * Operates directly on persisted data so it works regardless of + * whether initFromStore() has run yet. */ - pruneDeletedProjects(existingProjectIds: Set): void { - let pruned = false; - for (const [id, state] of this.states) { - if (state.unread && !existingProjectIds.has(id)) { - state.unread = false; - pruned = true; + async pruneDeletedProjects(existingProjectIds: Set): Promise { + const ids = await getStoreValue(UNREAD_PROJECTS_STORE_KEY); + if (!ids || ids.length === 0) return; + + const kept = ids.filter((id) => existingProjectIds.has(id)); + if (kept.length === ids.length) return; + + await setStoreValue(UNREAD_PROJECTS_STORE_KEY, kept); + + // Also update in-memory state if it has been initialized + for (const id of ids) { + if (!existingProjectIds.has(id)) { + const state = this.states.get(id); + if (state) state.unread = false; } } - if (pruned) { - this.version++; - this.persistAndBadge(); - } + this.version++; + getCurrentWindow() + .setBadgeCount(kept.length || undefined) + .catch(() => {}); } }