feat: use acp list sessions and manage sessions in Desktop (#9687)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -3923,9 +3923,6 @@
|
||||
"sessions.error.tryAgain": {
|
||||
"defaultMessage": "Try Again"
|
||||
},
|
||||
"sessions.extensions": {
|
||||
"defaultMessage": "Extensions:"
|
||||
},
|
||||
"sessions.import": {
|
||||
"defaultMessage": "Import Session"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user