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
1 change: 1 addition & 0 deletions assets/Google_Gemini_icon_2025.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 78 additions & 6 deletions src/client/__tests__/sessionListFilters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import TestRenderer, { act } from 'react-test-renderer'
import type { Session } from '@shared/types'
import ProjectFilterDropdown from '../components/ProjectFilterDropdown'
import StatusFilterDropdown from '../components/StatusFilterDropdown'
import SessionList from '../components/SessionList'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
Expand All @@ -16,10 +17,10 @@ beforeEach(() => {
globalAny.window = {
matchMedia: () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
addEventListener: () => { },
removeEventListener: () => { },
addListener: () => { },
removeListener: () => { },
}),
} as unknown as Window & typeof globalThis

Expand All @@ -33,6 +34,7 @@ beforeEach(() => {
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
statusFilters: [],
})

useSessionStore.setState({
Expand All @@ -52,6 +54,7 @@ afterEach(() => {
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
statusFilters: [],
})
useSessionStore.setState({
exitingSessions: new Map(),
Expand Down Expand Up @@ -87,8 +90,8 @@ describe('SessionList project filters', () => {
selectedSessionId={null}
loading={false}
error={null}
onSelect={() => {}}
onRename={() => {}}
onSelect={() => { }}
onRename={() => { }}
/>
)
})
Expand All @@ -105,3 +108,72 @@ describe('SessionList project filters', () => {
})
})
})

describe('SessionList status filters', () => {
test('filters active sessions by status', () => {
useSettingsStore.setState({ statusFilters: ['working'] })

const sessions: Session[] = [
{ ...baseSession, id: 's1', status: 'working' },
{ ...baseSession, id: 's2', status: 'waiting' },
{ ...baseSession, id: 's3', status: 'permission' },
]

let renderer!: TestRenderer.ReactTestRenderer
act(() => {
renderer = TestRenderer.create(
<SessionList
sessions={sessions}
inactiveSessions={[]}
selectedSessionId={null}
loading={false}
error={null}
onSelect={() => { }}
onRename={() => { }}
/>
)
})

const statusDropdown = renderer.root.findByType(StatusFilterDropdown)
expect(statusDropdown.props.selectedStatuses).toEqual(['working'])

act(() => {
renderer.unmount()
})
})

test('combines project and status filters', () => {
useSettingsStore.setState({
projectFilters: ['/tmp/alpha'],
statusFilters: ['permission'],
})

const sessions: Session[] = [
{ ...baseSession, id: 's1', projectPath: '/tmp/alpha', status: 'working' },
{ ...baseSession, id: 's2', projectPath: '/tmp/alpha', status: 'permission' },
{ ...baseSession, id: 's3', projectPath: '/tmp/beta', status: 'permission' },
]

let renderer!: TestRenderer.ReactTestRenderer
act(() => {
renderer = TestRenderer.create(
<SessionList
sessions={sessions}
inactiveSessions={[]}
selectedSessionId={null}
loading={false}
error={null}
onSelect={() => { }}
onRename={() => { }}
/>
)
})

const statusDropdown = renderer.root.findByType(StatusFilterDropdown)
expect(statusDropdown.props.selectedStatuses).toEqual(['permission'])

act(() => {
renderer.unmount()
})
})
})
33 changes: 33 additions & 0 deletions src/client/__tests__/sessionState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,37 @@ describe('useSessionStore', () => {

expect(useSessionStore.getState().sessions).toEqual(sessions)
})

test('markSessionUnread adds session id to unreadSessionIds', () => {
useSessionStore.setState({ unreadSessionIds: new Set() })
useSessionStore.getState().markSessionUnread('session-1')
expect(useSessionStore.getState().unreadSessionIds.has('session-1')).toBe(true)
})

test('markSessionRead removes session id from unreadSessionIds', () => {
useSessionStore.setState({ unreadSessionIds: new Set(['session-1', 'session-2']) })
useSessionStore.getState().markSessionRead('session-1')
const unread = useSessionStore.getState().unreadSessionIds
expect(unread.has('session-1')).toBe(false)
expect(unread.has('session-2')).toBe(true)
})

test('markSessionRead is a no-op for unknown session ids', () => {
const original = new Set(['session-1'])
useSessionStore.setState({ unreadSessionIds: original })
useSessionStore.getState().markSessionRead('unknown')
// Should be the same Set reference (no unnecessary update)
expect(useSessionStore.getState().unreadSessionIds).toBe(original)
})

test('setSessions cleans up removed sessions from unreadSessionIds', () => {
useSessionStore.setState({
unreadSessionIds: new Set(['session-1', 'session-2']),
})
// Only session-1 survives in the new session list
useSessionStore.getState().setSessions([makeSession({ id: 'session-1' })])
const unread = useSessionStore.getState().unreadSessionIds
expect(unread.has('session-1')).toBe(true)
expect(unread.has('session-2')).toBe(false)
})
})
16 changes: 16 additions & 0 deletions src/client/components/AgentIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,28 @@ function PiIcon({ className }: { className?: string }) {
)
}

function GeminiIcon({ className }: { className?: string }) {
// Sparkle shape from Google Gemini 2025 icon (viewBox 0 0 65 65)
return (
<svg
viewBox="0 0 65 65"
fill="currentColor"
className={className}
aria-label="Gemini"
>
<path d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z" />
</svg>
)
}

type IconComponent = ({ className }: { className?: string }) => JSX.Element

/** Prefix patterns mapped to icons - order matters, first match wins */
const iconPrefixes: [string, IconComponent][] = [
['claude', AnthropicIcon],
['mlflow', AnthropicIcon],
['codex', OpenAIIcon],
['gemini', GeminiIcon],
['pi', PiIcon],
]

Expand Down
Loading