From f4ecdaefc08cf0f8816dca0f7293ec542c55506f Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 10 Jun 2026 07:48:35 +1000 Subject: [PATCH] feat: use acp list sessions and manage sessions in Desktop (#9687) --- crates/goose/src/acp/server.rs | 15 +- crates/goose/src/session/session_manager.rs | 15 + ui/desktop/src/__tests__/sessions.test.ts | 66 ++-- ui/desktop/src/acp/sessions.ts | 99 ++++++ .../src/components/Layout/NavigationPanel.tsx | 9 +- .../components/sessions/SessionListView.tsx | 299 ++++++++---------- ui/desktop/src/hooks/useNavigationSessions.ts | 68 ++-- ui/desktop/src/i18n/messages/en.json | 3 - ui/desktop/src/utils/dateUtils.ts | 8 +- 9 files changed, 325 insertions(+), 257 deletions(-) create mode 100644 ui/desktop/src/acp/sessions.ts diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 7cefd1e00..783ef7dd7 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -314,19 +314,6 @@ fn encode_session_list_cursor( Ok(URL_SAFE_NO_PAD.encode(bytes)) } -fn display_title(s: &Session) -> Option { - if !s.user_set_name { - if let Some(recipe) = &s.recipe { - return Some(recipe.title.clone()); - } - } - if s.name.is_empty() { - None - } else { - Some(s.name.clone()) - } -} - pub(super) fn session_meta(session: &Session) -> serde_json::Map { let mut meta = serde_json::Map::new(); meta.insert( @@ -2861,7 +2848,7 @@ impl GooseAcpAgent { .into_iter() .map(|s| { let meta = session_meta(&s); - let title = display_title(&s); + let title = s.display_title(); let mut info = SessionInfo::new(SessionId::new(s.id), s.working_dir) .updated_at(s.updated_at.to_rfc3339()) .meta(meta); diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 946d12629..f7205654c 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -88,6 +88,21 @@ pub struct Session { pub project_id: Option, } +impl Session { + pub fn display_title(&self) -> Option { + if !self.user_set_name && self.session_type != SessionType::Scheduled { + if let Some(recipe) = &self.recipe { + return Some(recipe.title.clone()); + } + } + if self.name.is_empty() { + None + } else { + Some(self.name.clone()) + } + } +} + pub struct SessionUpdateBuilder<'a> { session_manager: &'a SessionManager, session_id: String, diff --git a/ui/desktop/src/__tests__/sessions.test.ts b/ui/desktop/src/__tests__/sessions.test.ts index c5baf703d..97e7daca2 100644 --- a/ui/desktop/src/__tests__/sessions.test.ts +++ b/ui/desktop/src/__tests__/sessions.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { shouldShowNewChatTitle } from '../sessions'; -import { getSessionDisplayName, sortAndTrim, prependUnique } from '../hooks/useNavigationSessions'; +import { getSessionDisplayName, prependUnique } from '../hooks/useNavigationSessions'; import type { Session } from '../api'; +import type { SessionListItem } from '../acp/sessions'; // Helper to build a minimal Session object for testing. function makeSession(overrides: Partial = {}): Session { @@ -17,6 +18,18 @@ function makeSession(overrides: Partial = {}): Session { }; } +function makeListItem(overrides: Partial = {}): SessionListItem { + return { + id: 'sess-1', + name: 'untitled', + workingDir: '/tmp', + updatedAt: new Date().toISOString(), + messageCount: 0, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + describe('shouldShowNewChatTitle', () => { it('returns true for an empty session without a user-set name', () => { const session = makeSession({ message_count: 0, user_set_name: false }); @@ -65,61 +78,22 @@ describe('getSessionDisplayName (fix for #8865)', () => { }); }); -describe('sortAndTrim', () => { - it('sorts by updated_at descending', () => { - const result = sortAndTrim([ - makeSession({ - id: 'old-but-active', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-03-01T00:00:00Z', - }), - makeSession({ - id: 'newer-but-idle', - created_at: '2024-03-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }), - makeSession({ - id: 'mid', - created_at: '2024-02-01T00:00:00Z', - updated_at: '2024-02-01T00:00:00Z', - }), - ]); - expect(result.map((s) => s.id)).toEqual(['old-but-active', 'mid', 'newer-but-idle']); - }); - - it('caps the list at 25 sessions', () => { - const sessions = Array.from({ length: 40 }, (_, i) => - makeSession({ id: `s-${i}`, created_at: new Date(2024, 0, i + 1).toISOString() }) - ); - expect(sortAndTrim(sessions)).toHaveLength(25); - }); - - it('does not mutate the input array', () => { - const input = [ - makeSession({ id: 'a', updated_at: '2024-01-01T00:00:00Z' }), - makeSession({ id: 'b', updated_at: '2024-02-01T00:00:00Z' }), - ]; - sortAndTrim(input); - expect(input.map((s) => s.id)).toEqual(['a', 'b']); - }); -}); - describe('prependUnique', () => { it('prepends a new session to the front', () => { - const prev = [makeSession({ id: 'a' })]; - const result = prependUnique(prev, makeSession({ id: 'b' })); + const prev = [makeListItem({ id: 'a' })]; + const result = prependUnique(prev, makeListItem({ id: 'b' })); expect(result.map((s) => s.id)).toEqual(['b', 'a']); }); it('returns the same reference when the session is already present', () => { - const prev = [makeSession({ id: 'a' }), makeSession({ id: 'b' })]; - const result = prependUnique(prev, makeSession({ id: 'a' })); + const prev = [makeListItem({ id: 'a' }), makeListItem({ id: 'b' })]; + const result = prependUnique(prev, makeListItem({ id: 'a' })); expect(result).toBe(prev); }); it('caps the list at 25 sessions', () => { - const prev = Array.from({ length: 25 }, (_, i) => makeSession({ id: `s-${i}` })); - const result = prependUnique(prev, makeSession({ id: 'new' })); + const prev = Array.from({ length: 25 }, (_, i) => makeListItem({ id: `s-${i}` })); + const result = prependUnique(prev, makeListItem({ id: 'new' })); expect(result).toHaveLength(25); expect(result[0].id).toBe('new'); }); diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts new file mode 100644 index 000000000..9eeac367e --- /dev/null +++ b/ui/desktop/src/acp/sessions.ts @@ -0,0 +1,99 @@ +import type { ForkSessionRequest, ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk'; +import { getAcpClient } from './acpConnection'; +import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; + +interface GooseSessionInfoMeta { + messageCount?: number; + createdAt?: string; + archivedAt?: string; + projectId?: string; + providerId?: string; + modelId?: string; + userSetName?: boolean; + hasRecipe?: boolean; +} + +export interface SessionListItem { + id: string; + name: string; + workingDir: string; + updatedAt: string; + messageCount: number; + createdAt: string; + archivedAt?: string; + projectId?: string; + providerId?: string; + modelId?: string; + userSetName?: boolean; + hasRecipe?: boolean; +} + +export interface SessionListPage { + sessions: SessionListItem[]; + nextCursor: string | null; +} + +function sessionInfoToListItem(s: SessionInfo): SessionListItem { + const meta = (s._meta ?? {}) as GooseSessionInfoMeta; + return { + id: String(s.sessionId), + name: s.title ?? DEFAULT_CHAT_TITLE, + workingDir: s.cwd, + updatedAt: s.updatedAt ?? '', + messageCount: meta.messageCount ?? 0, + createdAt: meta.createdAt ?? s.updatedAt ?? '', + archivedAt: meta.archivedAt, + projectId: meta.projectId, + providerId: meta.providerId, + modelId: meta.modelId, + userSetName: meta.userSetName, + hasRecipe: meta.hasRecipe, + }; +} + +export async function acpListSessions(cursor?: string | null): Promise { + const client = await getAcpClient(); + const request: ListSessionsRequest = cursor ? { cursor } : {}; + const response = await client.listSessions(request); + return { + sessions: response.sessions.map(sessionInfoToListItem), + nextCursor: response.nextCursor ?? null, + }; +} + +export async function acpListRecentSessions(maxSessions: number): Promise { + if (maxSessions <= 0) { + return []; + } + + const client = await getAcpClient(); + const response = await client.listSessions({}); + return response.sessions.slice(0, maxSessions).map(sessionInfoToListItem); +} + +export async function acpDeleteSession(sessionId: string): Promise { + const client = await getAcpClient(); + await client.goose.sessionDelete({ sessionId }); +} + +export async function acpRenameSession(sessionId: string, title: string): Promise { + const client = await getAcpClient(); + await client.goose.sessionRename_unstable({ sessionId, title }); +} + +export async function acpForkSession(sessionId: string, cwd: string): Promise { + const client = await getAcpClient(); + const request: ForkSessionRequest = { sessionId, cwd }; + await client.unstable_forkSession(request); +} + +export async function acpExportSession(sessionId: string): Promise { + const client = await getAcpClient(); + const response = await client.goose.sessionExport_unstable({ sessionId }); + return response.data; +} + +export async function acpImportSession(data: string): Promise { + const client = await getAcpClient(); + await client.goose.sessionImport_unstable({ data }); +} diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx index bce83155a..b2cc8d13b 100644 --- a/ui/desktop/src/components/Layout/NavigationPanel.tsx +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, PanelLeft } from 'lucide-react'; import { motion } from 'framer-motion'; import { useNavigationContext } from './NavigationContext'; import { useConfig } from '../ConfigContext'; -import { useNavigationSessions, getSessionDisplayName } from '../../hooks/useNavigationSessions'; +import { useNavigationSessions } from '../../hooks/useNavigationSessions'; import { NAV_ITEMS, SETTINGS_NAV_ITEM, @@ -15,7 +15,8 @@ import { AppEvents } from '../../constants/events'; import { Goose } from '../icons/Goose'; import { InlineEditText } from '../common/InlineEditText'; import { SessionIndicators } from '../SessionIndicators'; -import { updateSessionName, type Session } from '../../api'; +import { updateSessionName } from '../../api'; +import type { SessionListItem } from '../../acp/sessions'; import { cn } from '../../utils'; import { defineMessages, useIntl } from '../../i18n'; @@ -75,7 +76,7 @@ const NavRow: React.FC = ({ item, active, onClick }) => { }; interface SessionRowProps { - session: Session; + session: SessionListItem; active: boolean; status: SessionStatus | undefined; onClick: () => void; @@ -99,7 +100,7 @@ const SessionRow: React.FC = ({ session, active, status, onClic )} > { await updateSessionName({ path: { session_id: session.id }, diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 1d962286e..b13eb3d29 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useState, useRef, useCallback, useMemo, startTransiti import { defineMessages, useIntl } from '../../i18n'; import { MessageSquareText, - Target, AlertCircle, Calendar, Folder, @@ -15,7 +14,6 @@ import { LoaderCircle, ExternalLink, Copy, - Puzzle, } from 'lucide-react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -29,7 +27,6 @@ import { errorMessage } from '../../utils/conversionUtils'; import { Skeleton } from '../ui/skeleton'; import { toast } from 'react-toastify'; import { ConfirmationModal } from '../ui/ConfirmationModal'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; import { Dialog, DialogContent, @@ -39,24 +36,22 @@ import { DialogTitle, } from '../ui/dialog'; import { - deleteSession, - exportSession, - forkSession, - importSession, importSessionNostr, - listSessions, searchSessions, shareSessionNostr, - Session, - updateSessionName, - ExtensionConfig, - ExtensionData, } from '../../api'; import { getTunnelStatus } from '../../api/sdk.gen'; -import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; +import { + acpDeleteSession, + acpExportSession, + acpForkSession, + acpImportSession, + acpListSessions, + acpRenameSession, + type SessionListItem, +} from '../../acp/sessions'; +import { sessionToListItem } from '../../hooks/useNavigationSessions'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; -import { shouldShowNewChatTitle } from '../../sessions'; -import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext'; const i18n = defineMessages({ editSessionTitle: { id: 'sessions.edit.title', defaultMessage: 'Edit Session Description' }, @@ -100,27 +95,13 @@ const i18n = defineMessages({ deleteSession: { id: 'sessions.action.delete', defaultMessage: 'Delete session' }, exportSession: { id: 'sessions.action.export', defaultMessage: 'Export session' }, shareNostrSession: { id: 'sessions.action.shareNostr', defaultMessage: 'Share encrypted Nostr link' }, - extensions: { id: 'sessions.extensions', defaultMessage: 'Extensions:' }, shareNostrTitle: { id: 'sessions.shareNostr.title', defaultMessage: 'Encrypted Nostr Share Link' }, shareNostrDesc: { id: 'sessions.shareNostr.description', defaultMessage: 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.' }, close: { id: 'sessions.close', defaultMessage: 'Close' }, }); -function getSessionExtensionNames(extensionData: ExtensionData): string[] { - try { - const enabledExtensionData = extensionData?.['enabled_extensions.v0'] as - | { extensions?: ExtensionConfig[] } - | undefined; - if (!enabledExtensionData?.extensions) return []; - - return enabledExtensionData.extensions.map((ext) => formatExtensionName(ext.name)); - } catch { - return []; - } -} - interface EditSessionModalProps { - session: Session | null; + session: SessionListItem | null; isOpen: boolean; onClose: () => void; onSave: (sessionId: string, newDescription: string) => Promise; @@ -153,11 +134,7 @@ const EditSessionModal = React.memo( setIsUpdating(true); try { - await updateSessionName({ - path: { session_id: session.id }, - body: { name: trimmedDescription }, - throwOnError: true, - }); + await acpRenameSession(session.id, trimmedDescription); await onSave(session.id, trimmedDescription); onClose(); setTimeout(() => { @@ -267,8 +244,9 @@ interface SessionListViewProps { const SessionListView: React.FC = React.memo( ({ onSelectSession, selectedSessionId }) => { const intl = useIntl(); - const [sessions, setSessions] = useState([]); - const [filteredSessions, setFilteredSessions] = useState([]); + const [sessions, setSessions] = useState([]); + const [filteredSessions, setFilteredSessions] = useState([]); + const [isPrefetchingSessions, setIsPrefetchingSessions] = useState(false); const [dateGroups, setDateGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); @@ -284,11 +262,11 @@ const SessionListView: React.FC = React.memo( // Edit modal state const [showEditModal, setShowEditModal] = useState(false); - const [editingSession, setEditingSession] = useState(null); + const [editingSession, setEditingSession] = useState(null); // Delete confirmation modal state const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); + const [sessionToDelete, setSessionToDelete] = useState(null); const [showImportLinkModal, setShowImportLinkModal] = useState(false); const [nostrImportLink, setNostrImportLink] = useState(''); @@ -304,6 +282,7 @@ const SessionListView: React.FC = React.memo( const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce const containerRef = useRef(null); + const loadGenerationRef = useRef(0); // Track session to element ref const sessionRefs = useRef>({}); @@ -321,15 +300,88 @@ const SessionListView: React.FC = React.memo( return dateGroups.slice(0, visibleGroupsCount); }, [dateGroups, visibleGroupsCount]); + const previousSearchTermRef = useRef(''); + useEffect(() => { + const wasSearching = previousSearchTermRef.current.length > 0; + const isSearching = debouncedSearchTerm.length > 0; + previousSearchTermRef.current = debouncedSearchTerm; + + if (isSearching) { + setVisibleGroupsCount(dateGroups.length); + } else if (wasSearching) { + setVisibleGroupsCount(15); + } + }, [debouncedSearchTerm, dateGroups.length]); + + const loadRemainingSessionPages = useCallback(async (initialCursor: string, loadId: number) => { + let cursor: string | null = initialCursor; + setIsPrefetchingSessions(true); + + try { + while (cursor && loadGenerationRef.current === loadId) { + const resp = await acpListSessions(cursor); + if (loadGenerationRef.current !== loadId) return; + + cursor = resp.nextCursor; + startTransition(() => { + setSessions((prev) => { + const seen = new Set(prev.map((s) => s.id)); + return [...prev, ...resp.sessions.filter((s) => !seen.has(s.id))]; + }); + }); + } + } catch (err) { + console.error('Failed to load remaining sessions:', err); + } finally { + if (loadGenerationRef.current === loadId) { + setIsPrefetchingSessions(false); + } + } + }, []); + + const loadSessions = useCallback(async () => { + const loadId = loadGenerationRef.current + 1; + loadGenerationRef.current = loadId; + setIsLoading(true); + setIsPrefetchingSessions(false); + setShowSkeleton(true); + setShowContent(false); + setError(null); + try { + const resp = await acpListSessions(); + if (loadGenerationRef.current !== loadId) return; + + // Use startTransition to make state updates non-blocking + startTransition(() => { + setSessions(resp.sessions); + setFilteredSessions(resp.sessions); + }); + + if (resp.nextCursor) { + void loadRemainingSessionPages(resp.nextCursor, loadId); + } + } catch (err) { + if (loadGenerationRef.current !== loadId) return; + + console.error('Failed to load sessions:', err); + setError('Failed to load sessions. Please try again later.'); + setSessions([]); + setFilteredSessions([]); + } finally { + if (loadGenerationRef.current === loadId) { + setIsLoading(false); + } + } + }, [loadRemainingSessionPages]); + const handleScroll = useCallback( (target: HTMLDivElement) => { const { scrollTop, scrollHeight, clientHeight } = target; const threshold = 200; - if ( - scrollHeight - scrollTop - clientHeight < threshold && - visibleGroupsCount < dateGroups.length - ) { + if (scrollHeight - scrollTop - clientHeight >= threshold) return; + + if (visibleGroupsCount < dateGroups.length) { setVisibleGroupsCount((prev) => Math.min(prev + 5, dateGroups.length)); } }, @@ -337,39 +389,17 @@ const SessionListView: React.FC = React.memo( ); useEffect(() => { - if (debouncedSearchTerm) { - setVisibleGroupsCount(dateGroups.length); - } else { - setVisibleGroupsCount(15); - } - }, [debouncedSearchTerm, dateGroups.length]); - - const loadSessions = useCallback(async () => { - setIsLoading(true); - setShowSkeleton(true); - setShowContent(false); - setError(null); - try { - const resp = await listSessions({ throwOnError: true }); - const sessions = resp.data.sessions; - // Use startTransition to make state updates non-blocking - startTransition(() => { - setSessions(sessions); - setFilteredSessions(sessions); - }); - } catch (err) { - console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again later.'); - setSessions([]); - setFilteredSessions([]); - } finally { - setIsLoading(false); - } - }, []); + loadSessions(); + return () => { + loadGenerationRef.current += 1; + }; + }, [loadSessions]); useEffect(() => { - loadSessions(); - }, [loadSessions]); + if (!debouncedSearchTerm) { + setFilteredSessions(sessions); + } + }, [sessions, debouncedSearchTerm]); // Hide Nostr sharing when tunnel is disabled (restricted/enterprise bundles) useEffect(() => { @@ -429,14 +459,10 @@ const SessionListView: React.FC = React.memo( // Debounced search effect - performs content search via API useEffect(() => { if (!debouncedSearchTerm) { - startTransition(() => { - setFilteredSessions(sessions); - setSearchResults(null); - }); + setSearchResults(null); return; } - // Call the backend search API for content search const performSearch = async () => { const resp = await searchSessions({ query: { query: debouncedSearchTerm }, @@ -444,20 +470,25 @@ const SessionListView: React.FC = React.memo( if (resp.data) { // Response is Vec - sessions that match the search - const matchedSessionIds = new Set(resp.data.map((s: { id: string }) => s.id)); - const filtered = sessions.filter((session) => matchedSessionIds.has(session.id)); + const matched = resp.data.map(sessionToListItem).sort((a, b) => { + const byUpdatedAt = new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + if (byUpdatedAt !== 0) return byUpdatedAt; + if (a.id > b.id) return -1; + if (a.id < b.id) return 1; + return 0; + }); startTransition(() => { - setFilteredSessions(filtered); + setFilteredSessions(matched); setSearchResults( - filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null + matched.length > 0 ? { count: matched.length, currentIndex: 1 } : null ); }); } }; performSearch(); - }, [debouncedSearchTerm, caseSensitive, sessions]); + }, [debouncedSearchTerm, caseSensitive]); // Handle immediate search input (updates search term for debouncing) const handleSearch = useCallback((term: string, caseSensitive: boolean) => { @@ -508,24 +539,20 @@ const SessionListView: React.FC = React.memo( ); }, []); - const handleEditSession = useCallback((session: Session) => { + const handleEditSession = useCallback((session: SessionListItem) => { setEditingSession(session); setShowEditModal(true); }, []); - const handleDeleteSession = useCallback((session: Session) => { + const handleDeleteSession = useCallback((session: SessionListItem) => { setSessionToDelete(session); setShowDeleteConfirmation(true); }, []); const handleDuplicateSession = useCallback( - async (session: Session) => { + async (session: SessionListItem) => { try { - await forkSession({ - path: { session_id: session.id }, - body: { truncate: false, copy: true }, - throwOnError: true, - }); + await acpForkSession(session.id, session.workingDir); toast.success(intl.formatMessage(i18n.duplicateSuccess, { name: session.name })); await loadSessions(); } catch (error) { @@ -545,10 +572,7 @@ const SessionListView: React.FC = React.memo( setSessionToDelete(null); try { - await deleteSession({ - path: { session_id: sessionToDeleteId }, - throwOnError: true, - }); + await acpDeleteSession(sessionToDeleteId); toast.success(intl.formatMessage(i18n.deleteSuccess)); window.dispatchEvent( new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } }) @@ -565,15 +589,10 @@ const SessionListView: React.FC = React.memo( setSessionToDelete(null); }, []); - const handleExportSession = useCallback(async (session: Session, e: React.MouseEvent) => { + const handleExportSession = useCallback(async (session: SessionListItem, e: React.MouseEvent) => { e.stopPropagation(); - const response = await exportSession({ - path: { session_id: session.id }, - throwOnError: true, - }); - - const json = response.data; + const json = await acpExportSession(session.id); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -587,7 +606,7 @@ const SessionListView: React.FC = React.memo( }, [intl]); const handleShareSessionNostr = useCallback( - async (session: Session, e: React.MouseEvent) => { + async (session: SessionListItem, e: React.MouseEvent) => { e.stopPropagation(); setSharingSessionId(session.id); try { @@ -618,10 +637,7 @@ const SessionListView: React.FC = React.memo( toast.error(intl.formatMessage(i18n.importFailed, { error: result.error })); return; } - await importSession({ - body: { json: result.contents }, - throwOnError: true, - }); + await acpImportSession(result.contents); toast.success(intl.formatMessage(i18n.importSuccess)); await loadSessions(); } catch (error) { @@ -672,10 +688,7 @@ const SessionListView: React.FC = React.memo( try { const json = await file.text(); - await importSession({ - body: { json }, - throwOnError: true, - }); + await acpImportSession(json); toast.success(intl.formatMessage(i18n.importSuccess)); await loadSessions(); @@ -690,10 +703,10 @@ const SessionListView: React.FC = React.memo( [loadSessions, intl] ); - const handleOpenInNewWindow = useCallback((session: Session, e: React.MouseEvent) => { + const handleOpenInNewWindow = useCallback((session: SessionListItem, e: React.MouseEvent) => { e.stopPropagation(); window.electron.createChatWindow({ - dir: session.working_dir, + dir: session.workingDir, resumeSessionId: session.id, viewType: 'pair', }); @@ -709,13 +722,13 @@ const SessionListView: React.FC = React.memo( onOpenInNewWindow, isSharing, }: { - session: Session; - onEditClick: (session: Session) => void; - onDuplicateClick: (session: Session) => void; - onDeleteClick: (session: Session) => void; - onExportClick: (session: Session, e: React.MouseEvent) => void; - onShareClick: (session: Session, e: React.MouseEvent) => void; - onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void; + session: SessionListItem; + onEditClick: (session: SessionListItem) => void; + onDuplicateClick: (session: SessionListItem) => void; + onDeleteClick: (session: SessionListItem) => void; + onExportClick: (session: SessionListItem, e: React.MouseEvent) => void; + onShareClick: (session: SessionListItem, e: React.MouseEvent) => void; + onOpenInNewWindow: (session: SessionListItem, e: React.MouseEvent) => void; isSharing: boolean; }) { const handleEditClick = useCallback( @@ -767,13 +780,7 @@ const SessionListView: React.FC = React.memo( [onOpenInNewWindow, session] ); - const displayName = shouldShowNewChatTitle(session) ? DEFAULT_CHAT_TITLE : session.name; - - // Get extension names for this session - const extensionNames = useMemo( - () => getSessionExtensionNames(session.extension_data), - [session.extension_data] - ); + const displayName = session.name; return ( = React.memo(
- {formatMessageTimestamp(Date.parse(session.updated_at) / 1000)} + {formatMessageTimestamp(Date.parse(session.updatedAt) / 1000)}
- {session.working_dir} + {session.workingDir}
@@ -798,36 +805,8 @@ const SessionListView: React.FC = React.memo(
- {session.message_count} + {session.messageCount}
- {session.total_tokens !== null && ( -
- - {(session.total_tokens || 0).toLocaleString()} -
- )} - {extensionNames.length > 0 && ( - - - -
e.stopPropagation()}> - - {extensionNames.length} -
-
- -
-
{intl.formatMessage(i18n.extensions)}
-
    - {extensionNames.map((name) => ( -
  • {name}
  • - ))} -
-
-
-
-
- )}
@@ -981,7 +960,7 @@ const SessionListView: React.FC = React.memo(
))} - {visibleGroupsCount < dateGroups.length && ( + {isPrefetchingSessions && (
diff --git a/ui/desktop/src/hooks/useNavigationSessions.ts b/ui/desktop/src/hooks/useNavigationSessions.ts index 180ff5f43..23441d16d 100644 --- a/ui/desktop/src/hooks/useNavigationSessions.ts +++ b/ui/desktop/src/hooks/useNavigationSessions.ts @@ -1,31 +1,53 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; -import { getSession, listSessions } from '../api'; +import { getSession } from '../api'; import { useChatContext } from '../contexts/ChatContext'; import { shouldShowNewChatTitle } from '../sessions'; import { AppEvents } from '../constants/events'; import type { Session } from '../api'; +import { acpListRecentSessions, type SessionListItem } from '../acp/sessions'; const MAX_RECENT_SESSIONS = 25; -export function sortAndTrim(sessions: Session[]): Session[] { - return [...sessions] - .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) - .slice(0, MAX_RECENT_SESSIONS); -} - -export function prependUnique(prev: Session[], session: Session): Session[] { +export function prependUnique(prev: SessionListItem[], session: SessionListItem): SessionListItem[] { if (prev.some((s) => s.id === session.id)) return prev; return [session, ...prev].slice(0, MAX_RECENT_SESSIONS); } +function mergeWithEmptyLocals( + prev: SessionListItem[], + listed: SessionListItem[] +): SessionListItem[] { + const emptyLocals = prev.filter( + (local) => local.messageCount === 0 && !listed.some((s) => s.id === local.id) + ); + return [...emptyLocals, ...listed].slice(0, MAX_RECENT_SESSIONS); +} + +export function sessionToListItem(s: Session): SessionListItem { + return { + id: s.id, + name: getSessionDisplayName(s), + workingDir: s.working_dir, + updatedAt: s.updated_at, + messageCount: s.message_count, + createdAt: s.created_at, + archivedAt: s.archived_at ?? undefined, + projectId: s.project_id ?? undefined, + providerId: s.provider_name ?? undefined, + modelId: s.model_config?.model_name ?? undefined, + userSetName: s.user_set_name ?? undefined, + hasRecipe: !!s.recipe, + }; +} + export function useNavigationSessions() { const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); const chatContext = useChatContext(); - const [recentSessions, setRecentSessions] = useState([]); + const [recentSessions, setRecentSessions] = useState([]); const lastSessionIdRef = useRef(null); const activeSessionId = searchParams.get('resumeSessionId') ?? undefined; @@ -40,11 +62,8 @@ export function useNavigationSessions() { const fetchSessions = useCallback(async () => { try { - const response = await listSessions({ throwOnError: false }); - if (response.data) { - const apiSessions = sortAndTrim(response.data.sessions); - setRecentSessions(apiSessions); - } + const sessions = await acpListRecentSessions(MAX_RECENT_SESSIONS); + setRecentSessions(sessions); } catch (error) { console.error('Failed to fetch sessions:', error); } @@ -56,7 +75,8 @@ export function useNavigationSessions() { getSession({ path: { session_id: activeSessionId }, throwOnError: false }).then((response) => { if (!response.data) return; - setRecentSessions((prev) => prependUnique(prev, response.data as Session)); + const item = sessionToListItem(response.data as Session); + setRecentSessions((prev) => prependUnique(prev, item)); }); }, [activeSessionId, recentSessions]); @@ -67,7 +87,7 @@ export function useNavigationSessions() { const handleSessionCreated = (event: Event) => { const { session } = (event as CustomEvent<{ session?: Session }>).detail || {}; if (session) { - setRecentSessions((prev) => prependUnique(prev, session)); + setRecentSessions((prev) => prependUnique(prev, sessionToListItem(session))); } if (isPolling) return; @@ -81,11 +101,8 @@ export function useNavigationSessions() { const pollForUpdates = async () => { pollCount++; try { - const response = await listSessions({ throwOnError: false }); - if (response.data) { - const apiSessions = sortAndTrim(response.data.sessions); - setRecentSessions(apiSessions); - } + const listed = await acpListRecentSessions(MAX_RECENT_SESSIONS); + setRecentSessions((prev) => mergeWithEmptyLocals(prev, listed)); } catch (error) { console.error('Failed to poll sessions:', error); } @@ -120,11 +137,10 @@ export function useNavigationSessions() { lastSessionIdRef.current = null; } const version = ++fetchVersion; - listSessions({ throwOnError: false }) - .then((response) => { - if (version !== fetchVersion || !response.data) return; - const apiSessions = sortAndTrim(response.data.sessions); - setRecentSessions(apiSessions); + acpListRecentSessions(MAX_RECENT_SESSIONS) + .then((sessions) => { + if (version !== fetchVersion) return; + setRecentSessions(sessions.filter((session) => session.id !== sessionId)); }) .catch((error) => console.error('Failed to fetch sessions:', error)); }; diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 8150b81a0..6f87de4f0 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -3923,9 +3923,6 @@ "sessions.error.tryAgain": { "defaultMessage": "Try Again" }, - "sessions.extensions": { - "defaultMessage": "Extensions:" - }, "sessions.import": { "defaultMessage": "Import Session" }, diff --git a/ui/desktop/src/utils/dateUtils.ts b/ui/desktop/src/utils/dateUtils.ts index 1902e057e..542ea344a 100644 --- a/ui/desktop/src/utils/dateUtils.ts +++ b/ui/desktop/src/utils/dateUtils.ts @@ -1,12 +1,12 @@ -import { Session } from '../api'; +import type { SessionListItem } from '../acp/sessions'; export interface DateGroup { label: string; - sessions: Session[]; + sessions: SessionListItem[]; date: Date; } -export function groupSessionsByDate(sessions: Session[]): DateGroup[] { +export function groupSessionsByDate(sessions: SessionListItem[]): DateGroup[] { const today = new Date(); today.setHours(0, 0, 0, 0); @@ -16,7 +16,7 @@ export function groupSessionsByDate(sessions: Session[]): DateGroup[] { const groups: { [key: string]: DateGroup } = {}; sessions.forEach((session) => { - const sessionDate = new Date(session.updated_at); + const sessionDate = new Date(session.updatedAt); const sessionDateStart = new Date(sessionDate); sessionDateStart.setHours(0, 0, 0, 0);