refactor: share ai workspace cross-window store

This commit is contained in:
lucaronin
2026-05-30 03:49:40 +02:00
parent 944ab9705a
commit cba038766c
6 changed files with 261 additions and 146 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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<string, AiWorkspaceSessionSnapshot>
type Listener = () => void
const EMPTY_SESSION: AiWorkspaceSessionSnapshot = {
messages: [],
status: 'idle',
}
let sessions: SessionMap = readStoredSessions()
let broadcastChannel: BroadcastChannel | null = null
const listeners = new Set<Listener>()
const sessionStore = createCrossWindowPersistedStore<SessionMap>({
broadcastChannelName: BROADCAST_CHANNEL,
broadcastMessage: { type: 'ai-workspace-sessions-updated' },
emptySnapshot: {},
sanitizeStoredValue: normalizeStoredSessionsForReason,
storageKey: STORAGE_KEY,
})
let storeVersion = 0
let nativeWriteTimer: ReturnType<typeof setTimeout> | 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<SessionMap> {
@@ -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<void> {
@@ -164,42 +133,35 @@ async function syncFromNativeStorage(): Promise<void> {
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({})
}

View File

@@ -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<Listener>()
const contextStore = createCrossWindowPersistedStore<AiWorkspaceWindowSharedContext>({
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<string, unknown> {
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()

View File

@@ -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<string, string>()
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<unknown>) => 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<TestSnapshot>({
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<TestSnapshot>
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)
})
})

View File

@@ -0,0 +1,91 @@
export type CrossWindowStoreReadReason = 'initial' | 'storage'
type Listener = () => void
interface CrossWindowPersistedStoreOptions<TSnapshot> {
broadcastChannelName: string
broadcastMessage: unknown
emptySnapshot: TSnapshot
sanitizeStoredValue: (value: unknown, reason: CrossWindowStoreReadReason) => TSnapshot
storageKey: string
}
export function createCrossWindowPersistedStore<TSnapshot>({
broadcastChannelName,
broadcastMessage,
emptySnapshot,
sanitizeStoredValue,
storageKey,
}: CrossWindowPersistedStoreOptions<TSnapshot>) {
let snapshot = readStoredSnapshot('initial')
let broadcastChannel: BroadcastChannel | null = null
const listeners = new Set<Listener>()
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,
}
}