From 159b36a59b83d69e31bc1fe98ffee5740f1169d7 Mon Sep 17 00:00:00 2001 From: Master-cai Date: Wed, 4 Mar 2026 22:10:28 +0800 Subject: [PATCH 1/5] feat(client): add status filter dropdown and persistence --- .../__tests__/sessionListFilters.test.tsx | 84 ++++++++++- src/client/components/SessionList.tsx | 20 ++- .../components/StatusFilterDropdown.tsx | 133 ++++++++++++++++++ src/client/stores/settingsStore.ts | 5 + 4 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 src/client/components/StatusFilterDropdown.tsx 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/components/SessionList.tsx b/src/client/components/SessionList.tsx index 9080a5db..43c97d7a 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -40,6 +40,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 { @@ -176,6 +177,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) @@ -255,16 +258,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) @@ -309,6 +316,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]) @@ -470,6 +478,10 @@ export default function SessionList({ onSelect={setProjectFilters} hasHiddenPermissions={hiddenPermissionCount > 0} /> + {}) + void navigator.clipboard.writeText(pathToCopy).catch(() => { }) } document.body.removeChild(textarea) } diff --git a/src/client/components/StatusFilterDropdown.tsx b/src/client/components/StatusFilterDropdown.tsx new file mode 100644 index 00000000..ef1811f8 --- /dev/null +++ b/src/client/components/StatusFilterDropdown.tsx @@ -0,0 +1,133 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import ChevronDownIcon from '@untitledui-icons/react/line/esm/ChevronDownIcon' +import type { SessionStatus } from '@shared/types' + +const STATUS_OPTIONS: { value: SessionStatus; label: string; dotClass: string }[] = [ + { value: 'working', label: 'Working', dotClass: 'bg-green-500' }, + { value: 'waiting', label: 'Waiting', dotClass: 'bg-zinc-400' }, + { value: 'permission', label: 'Permission', dotClass: 'bg-amber-500' }, + { value: 'unknown', label: 'Unknown', dotClass: 'bg-zinc-400' }, +] + +interface StatusFilterDropdownProps { + selectedStatuses: SessionStatus[] + onSelect: (statuses: SessionStatus[]) => void +} + +export default function StatusFilterDropdown({ + selectedStatuses, + onSelect, +}: StatusFilterDropdownProps) { + const [open, setOpen] = useState(false) + const menuId = useId() + const containerRef = useRef(null) + const selectedSet = useMemo(() => new Set(selectedStatuses), [selectedStatuses]) + + const selectedTitle = useMemo(() => { + if (selectedStatuses.length === 0) return 'All Status' + return selectedStatuses + .map((s) => STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s) + .join(', ') + }, [selectedStatuses]) + + const selectedLabel = useMemo(() => { + if (selectedStatuses.length === 0) return 'All Status' + if (selectedStatuses.length === 1) { + return STATUS_OPTIONS.find((o) => o.value === selectedStatuses[0])?.label ?? selectedStatuses[0] + } + return `${selectedStatuses.length} statuses` + }, [selectedStatuses]) + + useEffect(() => { + if (!open || typeof document === 'undefined') return + if (!document.addEventListener || !document.removeEventListener) return + const handlePointer = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null + if (target && containerRef.current?.contains(target)) return + setOpen(false) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', handlePointer) + document.addEventListener('touchstart', handlePointer, { passive: true }) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handlePointer) + document.removeEventListener('touchstart', handlePointer) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + + const toggleStatus = (status: SessionStatus) => { + const next = new Set(selectedSet) + if (next.has(status)) { + next.delete(status) + } else { + next.add(status) + } + const ordered = STATUS_OPTIONS + .map((o) => o.value) + .filter((v) => next.has(v)) + onSelect(ordered) + } + + return ( +
+ + {open && ( + + ) +} diff --git a/src/client/stores/settingsStore.ts b/src/client/stores/settingsStore.ts index 8cd80888..b1bb80c6 100644 --- a/src/client/stores/settingsStore.ts +++ b/src/client/stores/settingsStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' +import type { SessionStatus } from '@shared/types' import { safeStorage } from '../utils/storage' const DEFAULT_PROJECT_DIR = '~/Documents/GitHub' @@ -149,6 +150,8 @@ interface SettingsState { setProjectFilters: (filters: string[]) => void hostFilters: string[] setHostFilters: (filters: string[]) => void + statusFilters: SessionStatus[] + setStatusFilters: (filters: SessionStatus[]) => void // Sound notifications soundOnPermission: boolean setSoundOnPermission: (enabled: boolean) => void @@ -216,6 +219,8 @@ export const useSettingsStore = create()( setProjectFilters: (filters) => set({ projectFilters: filters }), hostFilters: [], setHostFilters: (filters) => set({ hostFilters: filters }), + statusFilters: [] as SessionStatus[], + setStatusFilters: (filters) => set({ statusFilters: filters }), // Sound notifications soundOnPermission: false, setSoundOnPermission: (enabled) => set({ soundOnPermission: enabled }), From 2f1fbc1379f501076e7acdd382d41e6b699830a3 Mon Sep 17 00:00:00 2001 From: Master-cai Date: Wed, 4 Mar 2026 23:47:17 +0800 Subject: [PATCH 2/5] feat(client): add unread indicator for completed sessions --- src/client/__tests__/sessionState.test.ts | 33 ++++++++++++ src/client/components/SessionList.tsx | 16 +++++- src/client/stores/sessionStore.ts | 64 +++++++++++++++++++---- src/client/styles/index.css | 19 +++++++ 4 files changed, 121 insertions(+), 11 deletions(-) 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/SessionList.tsx b/src/client/components/SessionList.tsx index 43c97d7a..b0283255 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -553,7 +553,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)} @@ -867,6 +870,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 @@ -875,11 +880,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) @@ -1024,6 +1033,9 @@ function SessionRow({ {sessionIdPrefix} )} + {isUnread && !isSelected && ( + + )} {needsInput ? ( selectedSessionId: string | null + // Sessions with new completed output the user hasn't viewed yet + unreadSessionIds: Set hasLoaded: boolean connectionStatus: ConnectionStatus connectionError: string | null @@ -72,6 +74,10 @@ interface SessionState { setRemoteAllowAttach: (value: boolean) => void hostLabel: string | null setHostLabel: (value: string | null) => void + // Mark a session as having new unread output + markSessionUnread: (sessionId: string) => void + // Clear unread state (user has viewed the session) + markSessionRead: (sessionId: string) => void // Mark a session as exiting (preserves data for exit animation) markSessionExiting: (sessionId: string) => void // Clear a session from exiting state (after animation completes) @@ -86,6 +92,7 @@ export const useSessionStore = create()( hostStatuses: [], exitingSessions: new Map(), selectedSessionId: null, + unreadSessionIds: new Set(), hasLoaded: false, connectionStatus: 'connecting', connectionError: null, @@ -112,6 +119,17 @@ export const useSessionStore = create()( (s) => !newSessionIds.has(s.id) && !exitingSessions.has(s.id) ) + // Clean up unread IDs for removed sessions + const unreadSessionIds = state.unreadSessionIds + let nextUnread = unreadSessionIds + if (unreadSessionIds.size > 0) { + const staleIds = [...unreadSessionIds].filter((id) => !newSessionIds.has(id)) + if (staleIds.length > 0) { + nextUnread = new Set(unreadSessionIds) + for (const id of staleIds) nextUnread.delete(id) + } + } + let newSelectedId: string | null = selected if ( selected !== null && @@ -127,6 +145,13 @@ export const useSessionStore = create()( newSelectedId = sorted[0]?.id ?? null } + const baseUpdate = { + sessions, + hasLoaded: true, + selectedSessionId: newSelectedId, + ...(nextUnread !== unreadSessionIds ? { unreadSessionIds: nextUnread } : {}), + } + // Only update exitingSessions if there are newly removed sessions if (removedSessions.length > 0) { const nextExitingSessions = new Map(exitingSessions) @@ -134,17 +159,11 @@ export const useSessionStore = create()( nextExitingSessions.set(session.id, session) } set({ - sessions, - hasLoaded: true, - selectedSessionId: newSelectedId, + ...baseUpdate, exitingSessions: nextExitingSessions, }) } else { - set({ - sessions, - hasLoaded: true, - selectedSessionId: newSelectedId, - }) + set(baseUpdate) } }, setAgentSessions: (active, inactive) => @@ -164,6 +183,18 @@ export const useSessionStore = create()( setRemoteAllowControl: (value) => set({ remoteAllowControl: value }), setRemoteAllowAttach: (value) => set({ remoteAllowAttach: value }), setHostLabel: (value) => set({ hostLabel: value }), + markSessionUnread: (sessionId) => { + const next = new Set(get().unreadSessionIds) + next.add(sessionId) + set({ unreadSessionIds: next }) + }, + markSessionRead: (sessionId) => { + const current = get().unreadSessionIds + if (!current.has(sessionId)) return + const next = new Set(current) + next.delete(sessionId) + set({ unreadSessionIds: next }) + }, markSessionExiting: (sessionId) => { const session = get().sessions.find((s) => s.id === sessionId) if (session) { @@ -181,7 +212,22 @@ export const useSessionStore = create()( { name: SESSION_PERSIST_KEY, storage: createJSONStorage(() => tabStorage), - partialize: (state) => ({ selectedSessionId: state.selectedSessionId }), + partialize: (state) => ({ + selectedSessionId: state.selectedSessionId, + unreadSessionIds: [...state.unreadSessionIds], + }), + merge: (persisted, current) => { + const data = persisted as Record | undefined + return { + ...current, + ...(data ?? {}), + unreadSessionIds: new Set( + Array.isArray(data?.unreadSessionIds) + ? (data.unreadSessionIds as string[]) + : [] + ), + } + }, } ) ) diff --git a/src/client/styles/index.css b/src/client/styles/index.css index ea8a213f..ad3ee7eb 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -309,6 +309,21 @@ body { animation: pulse-complete 5s ease-out; } +/* Unread indicator dot for newly completed sessions */ +@keyframes unread-fade-in { + from { opacity: 0; transform: scale(0.5); } + to { opacity: 1; transform: scale(1); } +} + +.unread-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: unread-fade-in 0.2s ease-out; + flex-shrink: 0; +} + /* Terminal styles */ .xterm { font-family: "JetBrains Mono Variable", "JetBrains Mono", "SF Mono", "Fira Code", ui-monospace, monospace; @@ -559,4 +574,8 @@ body { .pulse-complete { animation: none; } + + .unread-dot { + animation: none; + } } From 789f26f763e72f5e3cd6e21f9d841f0636dc0b22 Mon Sep 17 00:00:00 2001 From: Master-cai Date: Thu, 5 Mar 2026 07:50:36 +0800 Subject: [PATCH 3/5] feat(agent): add Gemini agent type support across detection and preset UI --- assets/Google_Gemini_icon_2025.svg | 1 + src/client/components/AgentIcon.tsx | 16 + src/client/components/SettingsModal.tsx | 864 ++++++++++++------------ src/client/stores/settingsStore.ts | 16 +- src/server/agentDetection.ts | 5 +- src/shared/types.ts | 26 +- 6 files changed, 475 insertions(+), 453 deletions(-) create mode 100644 assets/Google_Gemini_icon_2025.svg 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/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/SettingsModal.tsx b/src/client/components/SettingsModal.tsx index d75a87e0..084debf0 100644 --- a/src/client/components/SettingsModal.tsx +++ b/src/client/components/SettingsModal.tsx @@ -124,7 +124,7 @@ export default function SettingsModal({ const [showAddForm, setShowAddForm] = useState(false) const [newLabel, setNewLabel] = useState('') const [newCommand, setNewCommand] = useState('') - const [newAgentType, setNewAgentType] = useState<'claude' | 'codex' | ''>('') + 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 */} -
-
-