diff --git a/assets/Google_Gemini_icon_2025.svg b/assets/Google_Gemini_icon_2025.svg new file mode 100644 index 00000000..ecc24b6c --- /dev/null +++ b/assets/Google_Gemini_icon_2025.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/client/__tests__/sessionListFilters.test.tsx b/src/client/__tests__/sessionListFilters.test.tsx index 3ea08625..c21b62bb 100644 --- a/src/client/__tests__/sessionListFilters.test.tsx +++ b/src/client/__tests__/sessionListFilters.test.tsx @@ -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' @@ -16,10 +17,10 @@ beforeEach(() => { globalAny.window = { matchMedia: () => ({ matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, + addEventListener: () => { }, + removeEventListener: () => { }, + addListener: () => { }, + removeListener: () => { }, }), } as unknown as Window & typeof globalThis @@ -33,6 +34,7 @@ beforeEach(() => { showSessionIdPrefix: false, projectFilters: [], hostFilters: [], + statusFilters: [], }) useSessionStore.setState({ @@ -52,6 +54,7 @@ afterEach(() => { showSessionIdPrefix: false, projectFilters: [], hostFilters: [], + statusFilters: [], }) useSessionStore.setState({ exitingSessions: new Map(), @@ -87,8 +90,8 @@ describe('SessionList project filters', () => { selectedSessionId={null} loading={false} error={null} - onSelect={() => {}} - onRename={() => {}} + onSelect={() => { }} + onRename={() => { }} /> ) }) @@ -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( + { }} + 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( + { }} + onRename={() => { }} + /> + ) + }) + + const statusDropdown = renderer.root.findByType(StatusFilterDropdown) + expect(statusDropdown.props.selectedStatuses).toEqual(['permission']) + + act(() => { + renderer.unmount() + }) + }) +}) diff --git a/src/client/__tests__/sessionState.test.ts b/src/client/__tests__/sessionState.test.ts index ac323839..0d3e0c03 100644 --- a/src/client/__tests__/sessionState.test.ts +++ b/src/client/__tests__/sessionState.test.ts @@ -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) + }) }) diff --git a/src/client/components/AgentIcon.tsx b/src/client/components/AgentIcon.tsx index adc81f1b..e308ea25 100644 --- a/src/client/components/AgentIcon.tsx +++ b/src/client/components/AgentIcon.tsx @@ -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 ( + + + + ) +} + 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], ] diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx index af5a66dd..ef1b7a26 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -41,6 +41,7 @@ import ProjectBadge from './ProjectBadge' import HostBadge from './HostBadge' import HostFilterDropdown from './HostFilterDropdown' import ProjectFilterDropdown from './ProjectFilterDropdown' +import StatusFilterDropdown from './StatusFilterDropdown' import SessionPreviewModal from './SessionPreviewModal' interface SessionListProps { @@ -177,6 +178,8 @@ export default function SessionList({ const setProjectFilters = useSettingsStore((state) => state.setProjectFilters) const hostFilters = useSettingsStore((state) => state.hostFilters) const setHostFilters = useSettingsStore((state) => state.setHostFilters) + const statusFilters = useSettingsStore((state) => state.statusFilters) + const setStatusFilters = useSettingsStore((state) => state.setStatusFilters) // Get exiting sessions from store (for kill-failed rollback only) const exitingSessions = useSessionStore((state) => state.exitingSessions) @@ -256,16 +259,20 @@ export default function SessionList({ if (hostFilters.length > 0) { next = next.filter((session) => hostFilters.includes(session.host ?? '')) } + if (statusFilters.length > 0) { + next = next.filter((session) => statusFilters.includes(session.status)) + } return next - }, [sortedSessions, projectFilters, hostFilters]) + }, [sortedSessions, projectFilters, hostFilters, statusFilters]) const filterKey = useMemo( () => { const projectKey = projectFilters.length === 0 ? 'all-projects' : projectFilters.join('|') const hostKey = hostFilters.length === 0 ? 'all-hosts' : hostFilters.join('|') - return `${projectKey}::${hostKey}` + const statusKey = statusFilters.length === 0 ? 'all-statuses' : statusFilters.join('|') + return `${projectKey}::${hostKey}::${statusKey}` }, - [projectFilters, hostFilters] + [projectFilters, hostFilters, statusFilters] ) // Track sessions that became visible due to filter changes (for entry animation) @@ -310,6 +317,7 @@ export default function SessionList({ if (hostFilters.length > 0) { next = next.filter((session) => hostFilters.includes(session.host ?? '')) } + // Inactive sessions don't have runtime status; skip status filtering for them return next }, [inactiveSessions, projectFilters, hostFilters]) @@ -471,6 +479,10 @@ export default function SessionList({ onSelect={setProjectFilters} hasHiddenPermissions={hiddenPermissionCount > 0} /> + {filteredSessions.map((session, index) => { const isTrulyNew = newlyActiveIds.has(session.id) @@ -542,7 +554,10 @@ export default function SessionList({ showLastUserMessage={showLastUserMessage} showHostInfo={showHostInfo} dropIndicator={showDropIndicator} - onSelect={() => onSelect(session.id)} + onSelect={() => { + useSessionStore.getState().markSessionRead(session.id) + onSelect(session.id) + }} onStartEdit={canControl ? () => setEditingSessionId(session.id) : undefined} onCancelEdit={() => setEditingSessionId(null)} onRename={(newName) => handleRename(session.id, newName)} @@ -856,6 +871,8 @@ function SessionRow({ // Track previous status for transition animation const prevStatusRef = useRef(session.status) const [isPulsingComplete, setIsPulsingComplete] = useState(false) + const markSessionUnread = useSessionStore((state) => state.markSessionUnread) + const isUnread = useSessionStore((state) => state.unreadSessionIds.has(session.id)) useEffect(() => { const prevStatus = prevStatusRef.current @@ -864,11 +881,15 @@ function SessionRow({ // Detect transition from working → waiting (not permission, which needs immediate attention) if (prevStatus === 'working' && currentStatus === 'waiting') { setIsPulsingComplete(true) + // Mark as unread if the user isn't currently viewing this session + if (!isSelected) { + markSessionUnread(session.id) + } // Don't update ref yet - will update when animation ends } else { prevStatusRef.current = currentStatus } - }, [session.status]) + }, [session.status, isSelected, session.id, markSessionUnread]) const handlePulseAnimationEnd = () => { setIsPulsingComplete(false) @@ -1013,6 +1034,9 @@ function SessionRow({ {sessionIdPrefix} )} + {isUnread && !isSelected && ( + + )} {needsInput ? ( ('') + const [newAgentType, setNewAgentType] = useState<'claude' | 'codex' | 'gemini' | ''>('') const reenableTimeoutRef = useRef | null>(null) useEffect(() => { @@ -160,11 +160,11 @@ export default function SettingsModal({ fetch('/api/settings/tmux-mouse-mode') .then((res) => res.json()) .then((data: { enabled: boolean }) => setTmuxMouseMode(data.enabled)) - .catch(() => {}) + .catch(() => { }) fetch('/api/settings/inactive-max-age-hours') .then((res) => res.json()) .then((data: { hours: number }) => setInactiveMaxAgeHours(data.hours)) - .catch(() => {}) + .catch(() => { }) // Disable terminal textarea when modal opens to prevent keyboard capture if (typeof document !== 'undefined') { const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null @@ -347,508 +347,510 @@ export default function SettingsModal({
-
-
- - setDraftDir(event.target.value)} - placeholder={DEFAULT_PROJECT_DIR} - className="input" - autoFocus - /> -
- - {/* Command Presets Section */} -
-
-