Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
}
})
Expand Down
24 changes: 23 additions & 1 deletion src/renderer/components/SettingsPopover.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -204,6 +206,26 @@ export function SettingsPopover() {

<div style={{ height: 1, background: colors.popoverBorder }} />

{/* Restore last folder */}
<div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen size={14} style={{ color: colors.textTertiary }} />
<div className="text-[12px] font-medium" style={{ color: colors.textPrimary }}>
Restore last folder
</div>
</div>
<RowToggle
checked={useLastFolder}
onChange={setUseLastFolder}
colors={colors}
label="Toggle restore last folder on startup"
/>
</div>
</div>

<div style={{ height: 1, background: colors.popoverBorder }} />

{/* Theme */}
<div>
<div className="flex items-center justify-between gap-3">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export function StatusBar() {
disabled={isRunning}
>
<FolderOpen size={11} className="flex-shrink-0" />
<span className="truncate">{tab.hasChosenDirectory ? compactPath(tab.workingDirectory) : '—'}</span>
<span className="truncate">{compactPath(tab.workingDirectory)}</span>
{hasExtraDirs && (
<span style={{ color: colors.textTertiary, fontWeight: 600 }}>+{tab.additionalDirs.length}</span>
)}
Expand Down
278 changes: 278 additions & 0 deletions src/renderer/stores/sessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading