From cba038766c3106eefcba44c1d77c71cd25d9e8a0 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 30 May 2026 03:49:40 +0200 Subject: [PATCH] refactor: share ai workspace cross-window store --- docs/ABSTRACTIONS.md | 2 + docs/ARCHITECTURE.md | 2 +- src/lib/aiWorkspaceSessionStore.ts | 122 +++++++--------------- src/lib/aiWorkspaceWindowSharedContext.ts | 75 +++---------- src/lib/crossWindowPersistedStore.test.ts | 115 ++++++++++++++++++++ src/lib/crossWindowPersistedStore.ts | 91 ++++++++++++++++ 6 files changed, 261 insertions(+), 146 deletions(-) create mode 100644 src/lib/crossWindowPersistedStore.test.ts create mode 100644 src/lib/crossWindowPersistedStore.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7a55265c..18e354c7 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -373,6 +373,8 @@ In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-o `useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows. +`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell. + The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering. ### Neighborhood Mode diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1352b28b..4616044e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,7 +233,7 @@ flowchart TD Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. - **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). -- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. +- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior. Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. diff --git a/src/lib/aiWorkspaceSessionStore.ts b/src/lib/aiWorkspaceSessionStore.ts index 76146255..b1ac98b4 100644 --- a/src/lib/aiWorkspaceSessionStore.ts +++ b/src/lib/aiWorkspaceSessionStore.ts @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import type { AgentStatus, AiAgentMessage } from './aiAgentConversation' import { isTauri } from '../mock-tauri' +import { createCrossWindowPersistedStore, type CrossWindowStoreReadReason } from './crossWindowPersistedStore' const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1' const BROADCAST_CHANNEL = 'tolaria-ai-workspace-sessions' @@ -13,16 +14,19 @@ export interface AiWorkspaceSessionSnapshot { } type SessionMap = Record -type Listener = () => void const EMPTY_SESSION: AiWorkspaceSessionSnapshot = { messages: [], status: 'idle', } -let sessions: SessionMap = readStoredSessions() -let broadcastChannel: BroadcastChannel | null = null -const listeners = new Set() +const sessionStore = createCrossWindowPersistedStore({ + broadcastChannelName: BROADCAST_CHANNEL, + broadcastMessage: { type: 'ai-workspace-sessions-updated' }, + emptySnapshot: {}, + sanitizeStoredValue: normalizeStoredSessionsForReason, + storageKey: STORAGE_KEY, +}) let storeVersion = 0 let nativeWriteTimer: ReturnType | null = null let nativeWriteInFlight = false @@ -71,24 +75,11 @@ function normalizeStoredSessions(value: unknown, resetRunningStatus: boolean): S ) } -function readStoredSessions(resetRunningStatus = true): SessionMap { - if (typeof localStorage === 'undefined') return {} - - try { - return normalizeStoredSessions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'), resetRunningStatus) - } catch { - return {} - } -} - -function writeStoredSessions(): void { - if (typeof localStorage === 'undefined') return - - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)) - } catch { - // Native persistence is the durable backend; localStorage is only a fast browser cache. - } +function normalizeStoredSessionsForReason( + value: unknown, + reason: CrossWindowStoreReadReason, +): SessionMap { + return normalizeStoredSessions(value, reason !== 'storage') } async function readNativeSessions(): Promise { @@ -131,32 +122,10 @@ function scheduleNativeSessionsWrite(nextSessions: SessionMap): void { }, NATIVE_WRITE_DEBOUNCE_MS) } -function notifyListeners(): void { - for (const listener of listeners) listener() -} - -function broadcastSessions(): void { - if (typeof BroadcastChannel === 'undefined') return - - broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL) - broadcastChannel.postMessage({ type: 'ai-workspace-sessions-updated' }) -} - -function publishSessions(): void { +function publishSessions(nextSessions: SessionMap): void { storeVersion += 1 - writeStoredSessions() - scheduleNativeSessionsWrite(sessions) - broadcastSessions() - notifyListeners() -} - -function replaceSessions(nextSessions: SessionMap): void { - sessions = nextSessions - notifyListeners() -} - -function syncFromStorage(): void { - replaceSessions(readStoredSessions(false)) + sessionStore.publishSnapshot(nextSessions) + scheduleNativeSessionsWrite(nextSessions) } async function syncFromNativeStorage(): Promise { @@ -164,42 +133,35 @@ async function syncFromNativeStorage(): Promise { const nativeSessions = await readNativeSessions() if (storeVersion !== loadVersion) return if (Object.keys(nativeSessions).length === 0) { - if (Object.keys(sessions).length > 0) scheduleNativeSessionsWrite(sessions) + const currentSessions = sessionStore.getSnapshot() + if (Object.keys(currentSessions).length > 0) scheduleNativeSessionsWrite(currentSessions) return } - replaceSessions(nativeSessions) - writeStoredSessions() + sessionStore.replaceSnapshot(nativeSessions) + sessionStore.writeStoredSnapshot(nativeSessions) } -function ensureCrossWindowSync(): void { +function ensureSessionStoreSync(): void { if (typeof window === 'undefined') return - window.addEventListener('storage', (event) => { - if (event.key === STORAGE_KEY) syncFromStorage() - }) - + sessionStore.ensureCrossWindowSync() window.addEventListener('pagehide', () => { if (nativeWriteTimer) clearTimeout(nativeWriteTimer) nativeWriteTimer = null void flushNativeSessionsWrite() }) - - if (typeof BroadcastChannel === 'undefined') return - broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL) - broadcastChannel.onmessage = syncFromStorage } -ensureCrossWindowSync() +ensureSessionStoreSync() void syncFromNativeStorage() export function aiWorkspaceSessionSnapshot(sessionId: string): AiWorkspaceSessionSnapshot { - return sessions[sessionId] ?? EMPTY_SESSION + return sessionStore.getSnapshot()[sessionId] ?? EMPTY_SESSION } -export function subscribeAiWorkspaceSession(_sessionId: string, listener: Listener): () => void { - listeners.add(listener) - return () => listeners.delete(listener) +export function subscribeAiWorkspaceSession(_sessionId: string, listener: () => void): () => void { + return sessionStore.subscribe(listener) } export function setAiWorkspaceSessionMessages( @@ -208,14 +170,13 @@ export function setAiWorkspaceSessionMessages( ): void { const current = aiWorkspaceSessionSnapshot(sessionId) const messages = typeof next === 'function' ? next(current.messages) : next - sessions = { - ...sessions, + publishSessions({ + ...sessionStore.getSnapshot(), [sessionId]: { ...current, messages, }, - } - publishSessions() + }) } export function setAiWorkspaceSessionStatus( @@ -224,36 +185,33 @@ export function setAiWorkspaceSessionStatus( ): void { const current = aiWorkspaceSessionSnapshot(sessionId) const status = typeof next === 'function' ? next(current.status) : next - sessions = { - ...sessions, + publishSessions({ + ...sessionStore.getSnapshot(), [sessionId]: { ...current, status, }, - } - publishSessions() + }) } export function resetAiWorkspaceSession(sessionId: string): void { - sessions = { - ...sessions, + publishSessions({ + ...sessionStore.getSnapshot(), [sessionId]: EMPTY_SESSION, - } - publishSessions() + }) } export function cloneAiWorkspaceSessionUntilMessage(sourceSessionId: string, targetSessionId: string, messageId: string): void { const source = aiWorkspaceSessionSnapshot(sourceSessionId) const messageIndex = source.messages.findIndex((message) => message.id === messageId) const messages = messageIndex >= 0 ? source.messages.slice(0, messageIndex + 1) : source.messages - sessions = { - ...sessions, + publishSessions({ + ...sessionStore.getSnapshot(), [targetSessionId]: { messages: messages.map((message) => ({ ...message, isStreaming: false })), status: 'idle', }, - } - publishSessions() + }) } export function aiWorkspaceSessionDispatchers(sessionId: string): { @@ -267,12 +225,10 @@ export function aiWorkspaceSessionDispatchers(sessionId: string): { } export function resetAiWorkspaceSessionStoreForTests(): void { - sessions = {} storeVersion = 0 pendingNativeSessions = null if (nativeWriteTimer) clearTimeout(nativeWriteTimer) nativeWriteTimer = null nativeWriteInFlight = false - writeStoredSessions() - notifyListeners() + sessionStore.publishSnapshot({}) } diff --git a/src/lib/aiWorkspaceWindowSharedContext.ts b/src/lib/aiWorkspaceWindowSharedContext.ts index 06ce9a83..a7c8f208 100644 --- a/src/lib/aiWorkspaceWindowSharedContext.ts +++ b/src/lib/aiWorkspaceWindowSharedContext.ts @@ -1,12 +1,11 @@ import type { AiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow' import type { NoteListItem } from '../utils/ai-context' import type { VaultEntry } from '../types' +import { createCrossWindowPersistedStore } from './crossWindowPersistedStore' const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1' const BROADCAST_CHANNEL = 'tolaria-ai-workspace-window-context' -type Listener = () => void - export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext { activeEntry?: VaultEntry | null activeNoteContent?: string | null @@ -18,9 +17,13 @@ export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext const EMPTY_CONTEXT: AiWorkspaceWindowSharedContext = {} -let context = readStoredContext() -let broadcastChannel: BroadcastChannel | null = null -const listeners = new Set() +const contextStore = createCrossWindowPersistedStore({ + broadcastChannelName: BROADCAST_CHANNEL, + broadcastMessage: { type: 'ai-workspace-window-context-updated' }, + emptySnapshot: EMPTY_CONTEXT, + sanitizeStoredValue: (value) => sanitizeContext(value), + storageKey: STORAGE_KEY, +}) function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -80,54 +83,6 @@ function sanitizeContext(value: unknown): AiWorkspaceWindowSharedContext { } } -function readStoredContext(): AiWorkspaceWindowSharedContext { - if (typeof localStorage === 'undefined') return EMPTY_CONTEXT - - try { - return sanitizeContext(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')) - } catch { - return EMPTY_CONTEXT - } -} - -function writeStoredContext(): void { - if (typeof localStorage === 'undefined') return - - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(context)) - } catch { - // The AI window can still open without shared editor context. - } -} - -function notify(): void { - for (const listener of listeners) listener() -} - -function broadcast(): void { - if (typeof BroadcastChannel === 'undefined') return - - broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL) - broadcastChannel.postMessage({ type: 'ai-workspace-window-context-updated' }) -} - -function syncFromStorage(): void { - context = readStoredContext() - notify() -} - -function ensureCrossWindowSync(): void { - if (typeof window === 'undefined') return - - window.addEventListener('storage', (event) => { - if (event.key === STORAGE_KEY) syncFromStorage() - }) - - if (typeof BroadcastChannel === 'undefined') return - broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL) - broadcastChannel.onmessage = syncFromStorage -} - function cloneEntryForWindowContext(entry: VaultEntry): VaultEntry { return { ...entry, @@ -158,19 +113,15 @@ function cloneContextForWindow(nextContext: AiWorkspaceWindowSharedContext): AiW } export function aiWorkspaceWindowSharedContextSnapshot(): AiWorkspaceWindowSharedContext { - return context + return contextStore.getSnapshot() } -export function subscribeAiWorkspaceWindowSharedContext(listener: Listener): () => void { - listeners.add(listener) - return () => listeners.delete(listener) +export function subscribeAiWorkspaceWindowSharedContext(listener: () => void): () => void { + return contextStore.subscribe(listener) } export function publishAiWorkspaceWindowSharedContext(nextContext: AiWorkspaceWindowSharedContext): void { - context = cloneContextForWindow(nextContext) - writeStoredContext() - broadcast() - notify() + contextStore.publishSnapshot(cloneContextForWindow(nextContext)) } -ensureCrossWindowSync() +contextStore.ensureCrossWindowSync() diff --git a/src/lib/crossWindowPersistedStore.test.ts b/src/lib/crossWindowPersistedStore.test.ts new file mode 100644 index 00000000..fab5d42d --- /dev/null +++ b/src/lib/crossWindowPersistedStore.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createCrossWindowPersistedStore } from './crossWindowPersistedStore' + +const STORAGE_KEY = 'tolaria:test-cross-window-store' +const CHANNEL_NAME = 'tolaria-test-cross-window-store' + +interface TestSnapshot { + value: string +} + +class LocalStorageMock implements Storage { + private readonly store = new Map() + + get length() { + return this.store.size + } + + clear() { + this.store.clear() + } + + getItem(key: string) { + return this.store.get(key) ?? null + } + + key(index: number) { + return Array.from(this.store.keys())[index] ?? null + } + + removeItem(key: string) { + this.store.delete(key) + } + + setItem(key: string, value: string) { + this.store.set(key, value) + } +} + +class BroadcastChannelMock { + static channels: BroadcastChannelMock[] = [] + + onmessage: ((event: MessageEvent) => void) | null = null + + constructor(readonly name: string) { + BroadcastChannelMock.channels.push(this) + } + + postMessage(message: unknown) { + for (const channel of BroadcastChannelMock.channels) { + if (channel === this || channel.name !== this.name) continue + channel.onmessage?.(new MessageEvent('message', { data: message })) + } + } +} + +function createTestStore() { + return createCrossWindowPersistedStore({ + broadcastChannelName: CHANNEL_NAME, + broadcastMessage: { type: 'test-updated' }, + emptySnapshot: { value: '' }, + sanitizeStoredValue: (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return { value: '' } + const candidate = value as Partial + return typeof candidate.value === 'string' ? { value: candidate.value } : { value: '' } + }, + storageKey: STORAGE_KEY, + }) +} + +describe('createCrossWindowPersistedStore', () => { + beforeEach(() => { + BroadcastChannelMock.channels = [] + vi.stubGlobal('localStorage', new LocalStorageMock()) + vi.stubGlobal('BroadcastChannel', BroadcastChannelMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hydrates and sanitizes persisted snapshots', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'persisted', ignored: true })) + + const store = createTestStore() + + expect(store.getSnapshot()).toEqual({ value: 'persisted' }) + }) + + it('syncs from storage events and notifies subscribers', () => { + const store = createTestStore() + const listener = vi.fn() + store.subscribe(listener) + store.ensureCrossWindowSync() + + localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'external' })) + window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY })) + + expect(store.getSnapshot()).toEqual({ value: 'external' }) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('broadcasts local publishes to sibling stores through localStorage', () => { + const firstStore = createTestStore() + const secondStore = createTestStore() + const secondListener = vi.fn() + firstStore.ensureCrossWindowSync() + secondStore.ensureCrossWindowSync() + secondStore.subscribe(secondListener) + + firstStore.publishSnapshot({ value: 'published' }) + + expect(secondStore.getSnapshot()).toEqual({ value: 'published' }) + expect(secondListener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/crossWindowPersistedStore.ts b/src/lib/crossWindowPersistedStore.ts new file mode 100644 index 00000000..b868e087 --- /dev/null +++ b/src/lib/crossWindowPersistedStore.ts @@ -0,0 +1,91 @@ +export type CrossWindowStoreReadReason = 'initial' | 'storage' +type Listener = () => void + +interface CrossWindowPersistedStoreOptions { + broadcastChannelName: string + broadcastMessage: unknown + emptySnapshot: TSnapshot + sanitizeStoredValue: (value: unknown, reason: CrossWindowStoreReadReason) => TSnapshot + storageKey: string +} + +export function createCrossWindowPersistedStore({ + broadcastChannelName, + broadcastMessage, + emptySnapshot, + sanitizeStoredValue, + storageKey, +}: CrossWindowPersistedStoreOptions) { + let snapshot = readStoredSnapshot('initial') + let broadcastChannel: BroadcastChannel | null = null + const listeners = new Set() + function readStoredSnapshot(reason: CrossWindowStoreReadReason = 'initial'): TSnapshot { + if (typeof localStorage === 'undefined') return emptySnapshot + + try { + return sanitizeStoredValue(JSON.parse(localStorage.getItem(storageKey) ?? '{}'), reason) + } catch { + return emptySnapshot + } + } + + function writeStoredSnapshot(nextSnapshot = snapshot): void { + if (typeof localStorage === 'undefined') return + + try { + localStorage.setItem(storageKey, JSON.stringify(nextSnapshot)) + } catch { + // Cross-window localStorage is a best-effort cache; callers may have a durable backend. + } + } + + function notifyListeners(): void { + for (const listener of listeners) listener() + } + + function broadcastSnapshot(): void { + if (typeof BroadcastChannel === 'undefined') return + + broadcastChannel ??= new BroadcastChannel(broadcastChannelName) + broadcastChannel.postMessage(broadcastMessage) + } + + function replaceSnapshot(nextSnapshot: TSnapshot): void { + snapshot = nextSnapshot + notifyListeners() + } + + function publishSnapshot(nextSnapshot: TSnapshot): void { + snapshot = nextSnapshot + writeStoredSnapshot() + broadcastSnapshot() + notifyListeners() + } + + function syncFromStorage(): void { + replaceSnapshot(readStoredSnapshot('storage')) + } + + function ensureCrossWindowSync(): void { + if (typeof window === 'undefined') return + window.addEventListener('storage', (event) => { + if (event.key === storageKey) syncFromStorage() + }) + + if (typeof BroadcastChannel === 'undefined') return + broadcastChannel ??= new BroadcastChannel(broadcastChannelName) + broadcastChannel.onmessage = syncFromStorage + } + + return { + ensureCrossWindowSync, + getSnapshot: () => snapshot, + publishSnapshot, + replaceSnapshot, + subscribe(listener: Listener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) + }, + writeStoredSnapshot, + } +}