feat: use acp list sessions and manage sessions in Desktop (#9687)

This commit is contained in:
Lifei Zhou
2026-06-10 07:48:35 +10:00
committed by GitHub
parent e3090836e4
commit f4ecdaefc0
9 changed files with 325 additions and 257 deletions
+1 -14
View File
@@ -314,19 +314,6 @@ fn encode_session_list_cursor(
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
fn display_title(s: &Session) -> Option<String> {
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<String, serde_json::Value> {
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);
@@ -88,6 +88,21 @@ pub struct Session {
pub project_id: Option<String>,
}
impl Session {
pub fn display_title(&self) -> Option<String> {
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,
+20 -46
View File
@@ -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> = {}): Session {
@@ -17,6 +18,18 @@ function makeSession(overrides: Partial<Session> = {}): Session {
};
}
function makeListItem(overrides: Partial<SessionListItem> = {}): 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');
});
+99
View File
@@ -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<SessionListPage> {
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<SessionListItem[]> {
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<void> {
const client = await getAcpClient();
await client.goose.sessionDelete({ sessionId });
}
export async function acpRenameSession(sessionId: string, title: string): Promise<void> {
const client = await getAcpClient();
await client.goose.sessionRename_unstable({ sessionId, title });
}
export async function acpForkSession(sessionId: string, cwd: string): Promise<void> {
const client = await getAcpClient();
const request: ForkSessionRequest = { sessionId, cwd };
await client.unstable_forkSession(request);
}
export async function acpExportSession(sessionId: string): Promise<string> {
const client = await getAcpClient();
const response = await client.goose.sessionExport_unstable({ sessionId });
return response.data;
}
export async function acpImportSession(data: string): Promise<void> {
const client = await getAcpClient();
await client.goose.sessionImport_unstable({ data });
}
@@ -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<NavRowProps> = ({ item, active, onClick }) => {
};
interface SessionRowProps {
session: Session;
session: SessionListItem;
active: boolean;
status: SessionStatus | undefined;
onClick: () => void;
@@ -99,7 +100,7 @@ const SessionRow: React.FC<SessionRowProps> = ({ session, active, status, onClic
)}
>
<InlineEditText
value={getSessionDisplayName(session)}
value={session.name}
onSave={async (newName) => {
await updateSessionName({
path: { session_id: session.id },
@@ -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<void>;
@@ -153,11 +134,7 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
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<SessionListViewProps> = React.memo(
({ onSelectSession, selectedSessionId }) => {
const intl = useIntl();
const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [filteredSessions, setFilteredSessions] = useState<SessionListItem[]>([]);
const [isPrefetchingSessions, setIsPrefetchingSessions] = useState(false);
const [dateGroups, setDateGroups] = useState<DateGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
@@ -284,11 +262,11 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
// Edit modal state
const [showEditModal, setShowEditModal] = useState(false);
const [editingSession, setEditingSession] = useState<Session | null>(null);
const [editingSession, setEditingSession] = useState<SessionListItem | null>(null);
// Delete confirmation modal state
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<Session | null>(null);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [showImportLinkModal, setShowImportLinkModal] = useState(false);
const [nostrImportLink, setNostrImportLink] = useState('');
@@ -304,6 +282,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce
const containerRef = useRef<HTMLDivElement>(null);
const loadGenerationRef = useRef(0);
// Track session to element ref
const sessionRefs = useRef<Record<string, HTMLElement>>({});
@@ -321,15 +300,88 @@ const SessionListView: React.FC<SessionListViewProps> = 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<SessionListViewProps> = 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<true>({ 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<SessionListViewProps> = 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<SessionListViewProps> = React.memo(
if (resp.data) {
// Response is Vec<Session> - 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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<SessionListViewProps> = 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 (
<Card
@@ -786,11 +793,11 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex-1 mt-2">
<div className="flex items-center text-text-secondary text-xs">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.updated_at) / 1000)}</span>
<span>{formatMessageTimestamp(Date.parse(session.updatedAt) / 1000)}</span>
</div>
<div className="flex items-center text-text-secondary text-xs">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.working_dir}</span>
<span className="truncate">{session.workingDir}</span>
</div>
</div>
</div>
@@ -798,36 +805,8 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex items-center space-x-3 text-xs text-text-secondary">
<div className="flex items-center">
<MessageSquareText className="w-3 h-3 mr-1" />
<span className="font-mono">{session.message_count}</span>
<span className="font-mono">{session.messageCount}</span>
</div>
{session.total_tokens !== null && (
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span className="font-mono">{(session.total_tokens || 0).toLocaleString()}</span>
</div>
)}
{extensionNames.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<Puzzle className="w-3 h-3 mr-1" />
<span className="font-mono">{extensionNames.length}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs">
<div className="font-medium mb-1">{intl.formatMessage(i18n.extensions)}</div>
<ul className="list-disc list-inside">
{extensionNames.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
<div className="flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -981,7 +960,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
</div>
))}
{visibleGroupsCount < dateGroups.length && (
{isPrefetchingSessions && (
<div className="flex justify-center py-8">
<div className="flex items-center space-x-2 text-text-secondary">
<div className="animate-spin rounded-full h-4 w-4 border-b-2"></div>
+42 -26
View File
@@ -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<Session[]>([]);
const [recentSessions, setRecentSessions] = useState<SessionListItem[]>([]);
const lastSessionIdRef = useRef<string | null>(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));
};
-3
View File
@@ -3923,9 +3923,6 @@
"sessions.error.tryAgain": {
"defaultMessage": "Try Again"
},
"sessions.extensions": {
"defaultMessage": "Extensions:"
},
"sessions.import": {
"defaultMessage": "Import Session"
},
+4 -4
View File
@@ -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);