diff --git a/package.json b/package.json
index 77a26ebc..f6c2b48b 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,9 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"dist": "electron-vite build --mode production && electron-builder --mac --dir",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
"doctor": "bash scripts/doctor.sh",
"postinstall": "electron-builder install-app-deps && bash scripts/patch-dev-icon.sh"
},
@@ -64,6 +67,9 @@
"react-dom": "^19.0.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.7.0",
- "vite": "^6.0.0"
+ "vite": "^6.0.0",
+ "@vitest/coverage-v8": "^4.1.0",
+ "jsdom": "^29.0.0",
+ "vitest": "^4.1.0"
}
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 6cf443b3..90fd63c4 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -52,6 +52,7 @@ export default function App() {
tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, id: tabId } : t)),
activeTabId: tabId,
}))
+ useSessionStore.getState().restoreLastSession(tabId).catch(() => {})
}).catch(() => {})
}
})
diff --git a/src/renderer/components/SettingsPopover.tsx b/src/renderer/components/SettingsPopover.tsx
index f184fb0a..e199c88a 100644
--- a/src/renderer/components/SettingsPopover.tsx
+++ b/src/renderer/components/SettingsPopover.tsx
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
-import { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react'
+import { DotsThree, Bell, ArrowsOutSimple, Moon, FolderOpen } from '@phosphor-icons/react'
import { useThemeStore } from '../theme'
import { useSessionStore } from '../stores/sessionStore'
import { usePopoverLayer } from './PopoverLayer'
@@ -50,6 +50,8 @@ export function SettingsPopover() {
const setThemeMode = useThemeStore((s) => s.setThemeMode)
const expandedUI = useThemeStore((s) => s.expandedUI)
const setExpandedUI = useThemeStore((s) => s.setExpandedUI)
+ const useLastFolder = useThemeStore((s) => s.useLastFolder)
+ const setUseLastFolder = useThemeStore((s) => s.setUseLastFolder)
const isExpanded = useSessionStore((s) => s.isExpanded)
const popoverLayer = usePopoverLayer()
const colors = useColors()
@@ -204,6 +206,26 @@ export function SettingsPopover() {
diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx
index 819dab86..4a699b43 100644
--- a/src/renderer/components/StatusBar.tsx
+++ b/src/renderer/components/StatusBar.tsx
@@ -345,7 +345,7 @@ export function StatusBar() {
disabled={isRunning}
>
- {tab.hasChosenDirectory ? compactPath(tab.workingDirectory) : '—'}
+ {compactPath(tab.workingDirectory)}
{hasExtraDirs && (
+{tab.additionalDirs.length}
)}
diff --git a/src/renderer/stores/sessionStore.test.ts b/src/renderer/stores/sessionStore.test.ts
new file mode 100644
index 00000000..bd331ec4
--- /dev/null
+++ b/src/renderer/stores/sessionStore.test.ts
@@ -0,0 +1,278 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { loadLastSession, useSessionStore } from './sessionStore'
+import { useThemeStore } from '../theme'
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function resetStore() {
+ const homeDir = '/home/user'
+ useSessionStore.setState({
+ tabs: [{
+ id: 'tab-1',
+ claudeSessionId: null,
+ status: 'idle',
+ activeRequestId: null,
+ hasUnread: false,
+ currentActivity: '',
+ permissionQueue: [],
+ permissionDenied: null,
+ attachments: [],
+ messages: [],
+ title: 'New Tab',
+ lastResult: null,
+ sessionModel: null,
+ sessionTools: [],
+ sessionMcpServers: [],
+ sessionSkills: [],
+ sessionVersion: null,
+ queuedPrompts: [],
+ workingDirectory: homeDir,
+ hasChosenDirectory: false,
+ additionalDirs: [],
+ }],
+ activeTabId: 'tab-1',
+ isExpanded: false,
+ staticInfo: { version: '1.0', email: null, subscriptionType: null, projectPath: homeDir, homePath: homeDir },
+ preferredModel: null,
+ permissionMode: 'ask',
+ })
+}
+
+// ─── Last-session persistence ─────────────────────────────────────────────────
+
+describe('loadLastSession', () => {
+ it('returns null when localStorage is empty', () => {
+ expect(loadLastSession()).toBeNull()
+ })
+
+ it('returns null for malformed JSON', () => {
+ localStorage.setItem('clui-last-session', 'not-json')
+ expect(loadLastSession()).toBeNull()
+ })
+
+ it('returns null when folder field is missing', () => {
+ localStorage.setItem('clui-last-session', JSON.stringify({ other: 'value' }))
+ expect(loadLastSession()).toBeNull()
+ })
+
+ it('returns null when folder is not a string', () => {
+ localStorage.setItem('clui-last-session', JSON.stringify({ folder: 42 }))
+ expect(loadLastSession()).toBeNull()
+ })
+
+ it('returns the folder when data is valid', () => {
+ localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/projects/myapp' }))
+ expect(loadLastSession()).toEqual({ folder: '/projects/myapp' })
+ })
+})
+
+// ─── setBaseDirectory ─────────────────────────────────────────────────────────
+
+describe('setBaseDirectory', () => {
+ beforeEach(resetStore)
+
+ it('updates workingDirectory and hasChosenDirectory on the active tab', () => {
+ useSessionStore.getState().setBaseDirectory('/new/path')
+ const tab = useSessionStore.getState().tabs[0]
+ expect(tab.workingDirectory).toBe('/new/path')
+ expect(tab.hasChosenDirectory).toBe(true)
+ })
+
+ it('clears claudeSessionId and additionalDirs', () => {
+ useSessionStore.setState((s) => ({
+ tabs: s.tabs.map((t) => ({ ...t, claudeSessionId: 'old-session', additionalDirs: ['/extra'] })),
+ }))
+ useSessionStore.getState().setBaseDirectory('/new/path')
+ const tab = useSessionStore.getState().tabs[0]
+ expect(tab.claudeSessionId).toBeNull()
+ expect(tab.additionalDirs).toEqual([])
+ })
+
+ it('persists the folder to localStorage', () => {
+ useSessionStore.getState().setBaseDirectory('/saved/dir')
+ const stored = JSON.parse(localStorage.getItem('clui-last-session')!)
+ expect(stored.folder).toBe('/saved/dir')
+ })
+
+ it('calls resetTabSession on the main process', () => {
+ useSessionStore.getState().setBaseDirectory('/any/path')
+ expect(window.clui.resetTabSession).toHaveBeenCalledWith('tab-1')
+ })
+})
+
+// ─── restoreLastSession ────────────────────────────────────────────────────────
+
+describe('restoreLastSession', () => {
+ beforeEach(() => {
+ resetStore()
+ useThemeStore.setState({ useLastFolder: true })
+ })
+
+ it('does nothing when useLastFolder is false', async () => {
+ useThemeStore.setState({ useLastFolder: false })
+ localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/saved' }))
+ await useSessionStore.getState().restoreLastSession('tab-1')
+ expect(useSessionStore.getState().tabs[0].workingDirectory).toBe('/home/user')
+ })
+
+ it('does nothing when localStorage has no saved session', async () => {
+ await useSessionStore.getState().restoreLastSession('tab-1')
+ expect(useSessionStore.getState().tabs[0].workingDirectory).toBe('/home/user')
+ expect(useSessionStore.getState().tabs[0].hasChosenDirectory).toBe(false)
+ })
+
+ it('restores the folder from localStorage', async () => {
+ localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/restored/project' }))
+ await useSessionStore.getState().restoreLastSession('tab-1')
+ const tab = useSessionStore.getState().tabs[0]
+ expect(tab.workingDirectory).toBe('/restored/project')
+ expect(tab.hasChosenDirectory).toBe(true)
+ })
+
+ it('only updates the target tab, not others', async () => {
+ useSessionStore.setState((s) => ({
+ tabs: [
+ ...s.tabs,
+ { ...s.tabs[0], id: 'tab-2', workingDirectory: '/other' },
+ ],
+ }))
+ localStorage.setItem('clui-last-session', JSON.stringify({ folder: '/restored' }))
+ await useSessionStore.getState().restoreLastSession('tab-1')
+ const tab2 = useSessionStore.getState().tabs.find((t) => t.id === 'tab-2')!
+ expect(tab2.workingDirectory).toBe('/other')
+ })
+})
+
+// ─── handleNormalizedEvent — text streaming ───────────────────────────────────
+
+describe('handleNormalizedEvent: text_chunk', () => {
+ beforeEach(resetStore)
+
+ it('appends a new assistant message for the first chunk', () => {
+ useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Hello' })
+ const msgs = useSessionStore.getState().tabs[0].messages
+ expect(msgs).toHaveLength(1)
+ expect(msgs[0]).toMatchObject({ role: 'assistant', content: 'Hello' })
+ })
+
+ it('concatenates subsequent chunks onto the last assistant message', () => {
+ useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Hello' })
+ useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: ' world' })
+ const msgs = useSessionStore.getState().tabs[0].messages
+ expect(msgs).toHaveLength(1)
+ expect(msgs[0].content).toBe('Hello world')
+ })
+
+ it('starts a new message after a tool call', () => {
+ useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'Before' })
+ // Simulate a tool message in between
+ useSessionStore.setState((s) => ({
+ tabs: s.tabs.map((t) => ({
+ ...t,
+ messages: [...t.messages, { id: 'tool-1', role: 'tool' as const, content: '', toolName: 'Bash', toolStatus: 'running' as const, timestamp: Date.now() }],
+ })),
+ }))
+ useSessionStore.getState().handleNormalizedEvent('tab-1', { type: 'text_chunk', text: 'After' })
+ const msgs = useSessionStore.getState().tabs[0].messages
+ expect(msgs).toHaveLength(3)
+ expect(msgs[2]).toMatchObject({ role: 'assistant', content: 'After' })
+ })
+})
+
+// ─── handleNormalizedEvent — task_complete ────────────────────────────────────
+
+describe('handleNormalizedEvent: task_complete', () => {
+ beforeEach(resetStore)
+
+ it('sets status to completed and stores lastResult', () => {
+ useSessionStore.setState((s) => ({
+ tabs: s.tabs.map((t) => ({ ...t, status: 'running' as const })),
+ }))
+ useSessionStore.getState().handleNormalizedEvent('tab-1', {
+ type: 'task_complete',
+ result: 'All done',
+ costUsd: 0.005,
+ durationMs: 2000,
+ numTurns: 3,
+ usage: {},
+ sessionId: 'sess-abc',
+ })
+ const tab = useSessionStore.getState().tabs[0]
+ expect(tab.status).toBe('completed')
+ expect(tab.lastResult?.totalCostUsd).toBe(0.005)
+ expect(tab.lastResult?.sessionId).toBe('sess-abc')
+ expect(tab.activeRequestId).toBeNull()
+ expect(tab.permissionQueue).toEqual([])
+ })
+
+ it('marks hasUnread when the tab is not the active expanded tab', () => {
+ useSessionStore.setState({ isExpanded: false })
+ useSessionStore.getState().handleNormalizedEvent('tab-1', {
+ type: 'task_complete',
+ result: '',
+ costUsd: 0,
+ durationMs: 0,
+ numTurns: 1,
+ usage: {},
+ sessionId: 'sess-x',
+ })
+ expect(useSessionStore.getState().tabs[0].hasUnread).toBe(true)
+ })
+})
+
+// ─── handleNormalizedEvent — session_init ─────────────────────────────────────
+
+describe('handleNormalizedEvent: session_init', () => {
+ beforeEach(resetStore)
+
+ it('stores sessionId and model on the tab', () => {
+ useSessionStore.getState().handleNormalizedEvent('tab-1', {
+ type: 'session_init',
+ sessionId: 'new-sess-1',
+ tools: ['Bash'],
+ model: 'claude-sonnet-4-6',
+ mcpServers: [],
+ skills: [],
+ version: '1.0',
+ })
+ const tab = useSessionStore.getState().tabs[0]
+ expect(tab.claudeSessionId).toBe('new-sess-1')
+ expect(tab.sessionModel).toBe('claude-sonnet-4-6')
+ })
+
+ it('does not change status for warmup inits', () => {
+ useSessionStore.setState((s) => ({
+ tabs: s.tabs.map((t) => ({ ...t, status: 'connecting' as const })),
+ }))
+ useSessionStore.getState().handleNormalizedEvent('tab-1', {
+ type: 'session_init',
+ sessionId: 'warmup-sess',
+ tools: [],
+ model: 'claude-sonnet-4-6',
+ mcpServers: [],
+ skills: [],
+ version: '1.0',
+ isWarmup: true,
+ })
+ expect(useSessionStore.getState().tabs[0].status).toBe('connecting')
+ })
+})
+
+// ─── addDirectory / removeDirectory ──────────────────────────────────────────
+
+describe('addDirectory / removeDirectory', () => {
+ beforeEach(resetStore)
+
+ it('adds a directory without duplicates', () => {
+ useSessionStore.getState().addDirectory('/extra/dir')
+ useSessionStore.getState().addDirectory('/extra/dir')
+ expect(useSessionStore.getState().tabs[0].additionalDirs).toEqual(['/extra/dir'])
+ })
+
+ it('removes a directory', () => {
+ useSessionStore.getState().addDirectory('/a')
+ useSessionStore.getState().addDirectory('/b')
+ useSessionStore.getState().removeDirectory('/a')
+ expect(useSessionStore.getState().tabs[0].additionalDirs).toEqual(['/b'])
+ })
+})
diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts
index 3bd31893..24c7ea1e 100644
--- a/src/renderer/stores/sessionStore.ts
+++ b/src/renderer/stores/sessionStore.ts
@@ -67,6 +67,7 @@ interface State {
addDirectory: (dir: string) => void
removeDirectory: (dir: string) => void
setBaseDirectory: (dir: string) => void
+ restoreLastSession: (tabId: string) => Promise
addAttachments: (attachments: Attachment[]) => void
removeAttachment: (attachmentId: string) => void
clearAttachments: () => void
@@ -75,6 +76,24 @@ interface State {
handleError: (tabId: string, error: EnrichedError) => void
}
+// ─── Last-session persistence ───
+
+const LAST_SESSION_KEY = 'clui-last-session'
+
+function persistLastSession(folder: string): void {
+ try { localStorage.setItem(LAST_SESSION_KEY, JSON.stringify({ folder })) } catch {}
+}
+
+export function loadLastSession(): { folder: string } | null {
+ try {
+ const raw = localStorage.getItem(LAST_SESSION_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw)
+ if (typeof parsed?.folder !== 'string') return null
+ return { folder: parsed.folder }
+ } catch { return null }
+}
+
let msgCounter = 0
const nextMsgId = () => `msg-${++msgCounter}`
@@ -467,6 +486,7 @@ export const useSessionStore = create((set, get) => ({
setBaseDirectory: (dir) => {
const { activeTabId } = get()
+ persistLastSession(dir)
window.clui.resetTabSession(activeTabId)
set((s) => ({
tabs: s.tabs.map((t) =>
@@ -483,6 +503,19 @@ export const useSessionStore = create((set, get) => ({
}))
},
+ restoreLastSession: async (tabId) => {
+ if (!useThemeStore.getState().useLastFolder) return
+ const lastSession = loadLastSession()
+ if (!lastSession) return
+ set((s) => ({
+ tabs: s.tabs.map((t) =>
+ t.id === tabId
+ ? { ...t, workingDirectory: lastSession.folder, hasChosenDirectory: true }
+ : t
+ ),
+ }))
+ },
+
// ─── Attachment management ───
addAttachments: (attachments) => {
diff --git a/src/renderer/theme.test.ts b/src/renderer/theme.test.ts
new file mode 100644
index 00000000..edbfa00d
--- /dev/null
+++ b/src/renderer/theme.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { useThemeStore } from './theme'
+
+const SETTINGS_KEY = 'clui-settings'
+
+function readStored() {
+ const raw = localStorage.getItem(SETTINGS_KEY)
+ return raw ? JSON.parse(raw) : null
+}
+
+// Re-apply defaults between tests since the store is a singleton
+function resetThemeStore() {
+ useThemeStore.setState({
+ themeMode: 'dark',
+ isDark: true,
+ soundEnabled: true,
+ expandedUI: false,
+ accentColor: '#d97757',
+ useLastFolder: true,
+ _systemIsDark: true,
+ })
+ localStorage.clear()
+}
+
+// ─── loadSettings defaults ────────────────────────────────────────────────────
+
+describe('settings defaults', () => {
+ beforeEach(resetThemeStore)
+
+ it('useLastFolder defaults to true when nothing is stored', () => {
+ expect(useThemeStore.getState().useLastFolder).toBe(true)
+ })
+
+ it('soundEnabled defaults to true', () => {
+ expect(useThemeStore.getState().soundEnabled).toBe(true)
+ })
+})
+
+// ─── setUseLastFolder ─────────────────────────────────────────────────────────
+
+describe('setUseLastFolder', () => {
+ beforeEach(resetThemeStore)
+
+ it('updates the in-memory store value', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ expect(useThemeStore.getState().useLastFolder).toBe(false)
+ })
+
+ it('persists the value to localStorage', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ expect(readStored()?.useLastFolder).toBe(false)
+ })
+
+ it('round-trips true back correctly', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ useThemeStore.getState().setUseLastFolder(true)
+ expect(useThemeStore.getState().useLastFolder).toBe(true)
+ expect(readStored()?.useLastFolder).toBe(true)
+ })
+})
+
+// ─── Other settings persist useLastFolder alongside them ─────────────────────
+
+describe('useLastFolder is preserved when other settings change', () => {
+ beforeEach(resetThemeStore)
+
+ it('survives a setSoundEnabled call', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ useThemeStore.getState().setSoundEnabled(false)
+ expect(readStored()?.useLastFolder).toBe(false)
+ })
+
+ it('survives a setThemeMode call', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ useThemeStore.getState().setThemeMode('light')
+ expect(readStored()?.useLastFolder).toBe(false)
+ })
+
+ it('survives a setAccentColor call', () => {
+ useThemeStore.getState().setUseLastFolder(false)
+ useThemeStore.getState().setAccentColor('#ff0000')
+ expect(readStored()?.useLastFolder).toBe(false)
+ })
+})
+
+// ─── setThemeMode ─────────────────────────────────────────────────────────────
+
+describe('setThemeMode', () => {
+ beforeEach(resetThemeStore)
+
+ it('sets isDark=true for dark mode', () => {
+ useThemeStore.getState().setThemeMode('dark')
+ expect(useThemeStore.getState().isDark).toBe(true)
+ })
+
+ it('sets isDark=false for light mode', () => {
+ useThemeStore.getState().setThemeMode('light')
+ expect(useThemeStore.getState().isDark).toBe(false)
+ })
+
+ it('persists themeMode to localStorage', () => {
+ useThemeStore.getState().setThemeMode('light')
+ expect(readStored()?.themeMode).toBe('light')
+ })
+
+ it('resolves system mode based on _systemIsDark', () => {
+ useThemeStore.setState({ _systemIsDark: false })
+ useThemeStore.getState().setThemeMode('system')
+ expect(useThemeStore.getState().isDark).toBe(false)
+ })
+})
+
+// ─── setAccentColor ───────────────────────────────────────────────────────────
+
+describe('setAccentColor', () => {
+ beforeEach(resetThemeStore)
+
+ it('stores the custom hex in-memory and in localStorage', () => {
+ useThemeStore.getState().setAccentColor('#abcdef')
+ expect(useThemeStore.getState().accentColor).toBe('#abcdef')
+ expect(readStored()?.accentColor).toBe('#abcdef')
+ })
+})
+
+// ─── setSoundEnabled ──────────────────────────────────────────────────────────
+
+describe('setSoundEnabled', () => {
+ beforeEach(resetThemeStore)
+
+ it('persists false to localStorage', () => {
+ useThemeStore.getState().setSoundEnabled(false)
+ expect(useThemeStore.getState().soundEnabled).toBe(false)
+ expect(readStored()?.soundEnabled).toBe(false)
+ })
+})
diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts
index bfe363c6..4880a426 100644
--- a/src/renderer/theme.ts
+++ b/src/renderer/theme.ts
@@ -277,12 +277,14 @@ interface ThemeState {
themeMode: ThemeMode
soundEnabled: boolean
expandedUI: boolean
+ useLastFolder: boolean
/** OS-reported dark mode — used when themeMode is 'system' */
_systemIsDark: boolean
setIsDark: (isDark: boolean) => void
setThemeMode: (mode: ThemeMode) => void
setSoundEnabled: (enabled: boolean) => void
setExpandedUI: (expanded: boolean) => void
+ setUseLastFolder: (enabled: boolean) => void
/** Called by OS theme change listener — updates system value */
setSystemTheme: (isDark: boolean) => void
}
@@ -308,7 +310,7 @@ function applyTheme(isDark: boolean): void {
const SETTINGS_KEY = 'clui-settings'
-function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean } {
+function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; useLastFolder: boolean } {
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
@@ -317,13 +319,14 @@ function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expanded
themeMode: ['light', 'dark'].includes(parsed.themeMode) ? parsed.themeMode : 'dark',
soundEnabled: typeof parsed.soundEnabled === 'boolean' ? parsed.soundEnabled : true,
expandedUI: typeof parsed.expandedUI === 'boolean' ? parsed.expandedUI : false,
+ useLastFolder: typeof parsed.useLastFolder === 'boolean' ? parsed.useLastFolder : true,
}
}
} catch {}
- return { themeMode: 'dark', soundEnabled: true, expandedUI: false }
+ return { themeMode: 'dark', soundEnabled: true, expandedUI: false, useLastFolder: true }
}
-function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean }): void {
+function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; useLastFolder: boolean }): void {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } catch {}
}
@@ -335,6 +338,7 @@ export const useThemeStore = create((set, get) => ({
themeMode: saved.themeMode,
soundEnabled: saved.soundEnabled,
expandedUI: saved.expandedUI,
+ useLastFolder: saved.useLastFolder,
_systemIsDark: true,
setIsDark: (isDark) => {
set({ isDark })
@@ -344,15 +348,19 @@ export const useThemeStore = create((set, get) => ({
const resolved = mode === 'system' ? get()._systemIsDark : mode === 'dark'
set({ themeMode: mode, isDark: resolved })
applyTheme(resolved)
- saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI })
+ saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, useLastFolder: get().useLastFolder })
},
setSoundEnabled: (enabled) => {
set({ soundEnabled: enabled })
- saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI })
+ saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI, useLastFolder: get().useLastFolder })
},
setExpandedUI: (expanded) => {
set({ expandedUI: expanded })
- saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded })
+ saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded, useLastFolder: get().useLastFolder })
+ },
+ setUseLastFolder: (enabled) => {
+ set({ useLastFolder: enabled })
+ saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, useLastFolder: enabled })
},
setSystemTheme: (isDark) => {
set({ _systemIsDark: isDark })
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 00000000..5c0f48f1
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,59 @@
+import { vi, beforeEach } from 'vitest'
+
+// ─── Mock localStorage (vitest 4.x jsdom requires explicit impl) ───
+const localStorageStore: Record = {}
+const localStorageMock = {
+ getItem: (key: string) => localStorageStore[key] ?? null,
+ setItem: (key: string, value: string) => { localStorageStore[key] = String(value) },
+ removeItem: (key: string) => { delete localStorageStore[key] },
+ clear: () => { Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]) },
+ key: (i: number) => Object.keys(localStorageStore)[i] ?? null,
+ get length() { return Object.keys(localStorageStore).length },
+}
+Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true, configurable: true })
+
+// ─── Mock Audio API — must use function syntax to support `new Audio()` ───
+global.Audio = vi.fn().mockImplementation(function (this: any) {
+ this.volume = 1.0
+ this.currentTime = 0
+ this.play = vi.fn().mockResolvedValue(undefined)
+}) as unknown as typeof Audio
+
+// ─── Mock window.clui (Electron IPC bridge) ───
+const mockClui = {
+ resetTabSession: vi.fn(),
+ createTab: vi.fn().mockResolvedValue({ tabId: 'mock-tab-id' }),
+ prompt: vi.fn().mockResolvedValue(undefined),
+ cancel: vi.fn().mockResolvedValue(true),
+ loadSession: vi.fn().mockResolvedValue([]),
+ listSessions: vi.fn().mockResolvedValue([]),
+ isVisible: vi.fn().mockResolvedValue(false),
+ setPermissionMode: vi.fn(),
+ respondPermission: vi.fn().mockResolvedValue(true),
+ closeTab: vi.fn().mockResolvedValue(undefined),
+ start: vi.fn().mockResolvedValue({
+ version: '1.0.0',
+ auth: {},
+ mcpServers: [],
+ projectPath: '/home/user',
+ homePath: '/home/user',
+ }),
+ getAutoStart: vi.fn().mockResolvedValue({ enabled: false, startMinimized: false }),
+ setAutoStart: vi.fn().mockResolvedValue({ enabled: false, startMinimized: false }),
+ setShortcut: vi.fn().mockResolvedValue({ primary: { ok: true }, secondary: { ok: true } }),
+ getShortcut: vi.fn().mockResolvedValue({ primary: 'Alt+Space', secondary: 'CommandOrControl+Shift+K' }),
+ getTheme: vi.fn().mockResolvedValue({ isDark: true }),
+ onThemeChange: vi.fn().mockReturnValue(() => {}),
+ hideWindow: vi.fn(),
+ setIgnoreMouseEvents: vi.fn(),
+}
+
+if (typeof window !== 'undefined') {
+ Object.defineProperty(window, 'clui', { value: mockClui, writable: true, configurable: true })
+}
+
+// ─── Reset mocks and localStorage between tests ───
+beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..31cc5097
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
+ },
+})