mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 22:18:19 +00:00
Fix/websocket streaming issues (#748)
This commit is contained in:
@@ -34,7 +34,6 @@ function AppContentInner() {
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
replaceTemporarySession,
|
||||
} = useSessionProtection();
|
||||
|
||||
const {
|
||||
@@ -191,7 +190,6 @@ function AppContentInner() {
|
||||
onSessionProcessing={markSessionAsProcessing}
|
||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={replaceTemporarySession}
|
||||
onNavigateToSession={(targetSessionId: string, options) =>
|
||||
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { thinkingModes } from '../constants/thinkingModes';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
@@ -80,9 +82,6 @@ const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
||||
const isTemporarySessionId = (sessionId: string | null | undefined) =>
|
||||
Boolean(sessionId && sessionId.startsWith('new-session-'));
|
||||
|
||||
const getNotificationSessionSummary = (
|
||||
selectedSession: ProjectSession | null,
|
||||
fallbackInput: string,
|
||||
@@ -533,7 +532,6 @@ export function useChatComposerState({
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
@@ -559,10 +557,12 @@ export function useChatComposerState({
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
// For new sessions we intentionally keep this as `null` until the backend
|
||||
// emits `session_created` with the canonical provider session id.
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
}
|
||||
onSessionActive?.(sessionToActivate);
|
||||
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
|
||||
if (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
onSessionProcessing?.(effectiveSessionId);
|
||||
}
|
||||
|
||||
@@ -868,7 +868,7 @@ export function useChatComposerState({
|
||||
];
|
||||
|
||||
const targetSessionId =
|
||||
candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
|
||||
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
|
||||
|
||||
if (!targetSessionId) {
|
||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
||||
|
||||
@@ -11,8 +11,9 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText }
|
||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||
* that the existing UI components expect.
|
||||
*
|
||||
* Internal/system content (e.g. <system-reminder>, <command-name>) is already
|
||||
* filtered server-side by the Claude provider module.
|
||||
* Truly internal/system content is already filtered server-side. Some Claude
|
||||
* transcript artifacts such as local slash commands and compact summaries are
|
||||
* intentionally preserved and annotated so they can render like normal chat.
|
||||
*/
|
||||
export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] {
|
||||
const converted: ChatMessage[] = [];
|
||||
@@ -26,6 +27,16 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const sharedMetadata = {
|
||||
displayText: msg.displayText,
|
||||
commandName: msg.commandName,
|
||||
commandMessage: msg.commandMessage,
|
||||
commandArgs: msg.commandArgs,
|
||||
isLocalCommand: msg.isLocalCommand,
|
||||
isLocalCommandStdout: msg.isLocalCommandStdout,
|
||||
isCompactSummary: msg.isCompactSummary,
|
||||
};
|
||||
|
||||
switch (msg.kind) {
|
||||
case 'text': {
|
||||
const content = msg.content || '';
|
||||
@@ -42,12 +53,14 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
timestamp: msg.timestamp,
|
||||
isTaskNotification: true,
|
||||
taskStatus: taskNotifMatch[1]?.trim() || 'completed',
|
||||
...sharedMetadata,
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(decodeHtmlEntities(content)),
|
||||
timestamp: msg.timestamp,
|
||||
...sharedMetadata,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -58,6 +71,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
type: 'assistant',
|
||||
content: text,
|
||||
timestamp: msg.timestamp,
|
||||
...sharedMetadata,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -106,6 +120,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
isComplete: Boolean(toolResult),
|
||||
}
|
||||
: undefined,
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -117,6 +132,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
content: unescapeWithMathProtection(msg.content),
|
||||
timestamp: msg.timestamp,
|
||||
isThinking: true,
|
||||
...sharedMetadata,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -126,6 +142,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
type: 'error',
|
||||
content: msg.content || 'Unknown error',
|
||||
timestamp: msg.timestamp,
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -135,6 +152,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
content: msg.content || '',
|
||||
timestamp: msg.timestamp,
|
||||
isInteractivePrompt: true,
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -145,6 +163,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
timestamp: msg.timestamp,
|
||||
isTaskNotification: true,
|
||||
taskStatus: msg.status || 'completed',
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -155,6 +174,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
isStreaming: true,
|
||||
...sharedMetadata,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
@@ -51,7 +51,6 @@ type LatestChatMessage = {
|
||||
interface UseChatRealtimeHandlersArgs {
|
||||
latestMessage: LatestChatMessage | null;
|
||||
provider: LLMProvider;
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
setCurrentSessionId: (sessionId: string | null) => void;
|
||||
@@ -61,13 +60,11 @@ interface UseChatRealtimeHandlersArgs {
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
streamBufferRef: MutableRefObject<string>;
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onWebSocketReconnect?: () => void;
|
||||
sessionStore: SessionStore;
|
||||
@@ -80,7 +77,6 @@ interface UseChatRealtimeHandlersArgs {
|
||||
export function useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
provider,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
@@ -90,13 +86,11 @@ export function useChatRealtimeHandlers({
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
@@ -187,7 +181,6 @@ export function useChatRealtimeHandlers({
|
||||
if (msg.kind === 'stream_delta') {
|
||||
const text = msg.content || '';
|
||||
if (!text) return;
|
||||
streamBufferRef.current += text;
|
||||
accumulatedStreamRef.current += text;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
@@ -216,12 +209,18 @@ export function useChatRealtimeHandlers({
|
||||
sessionStore.finalizeStreaming(sid);
|
||||
}
|
||||
accumulatedStreamRef.current = '';
|
||||
streamBufferRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- All other messages: route to store ---
|
||||
if (sid) {
|
||||
const shouldPersist =
|
||||
msg.kind !== 'session_created'
|
||||
&& msg.kind !== 'complete'
|
||||
&& msg.kind !== 'status'
|
||||
&& msg.kind !== 'permission_request'
|
||||
&& msg.kind !== 'permission_cancelled';
|
||||
|
||||
if (sid && shouldPersist) {
|
||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
||||
}
|
||||
|
||||
@@ -231,13 +230,16 @@ export function useChatRealtimeHandlers({
|
||||
const newSessionId = msg.newSessionId;
|
||||
if (!newSessionId) break;
|
||||
|
||||
if (!currentSessionId || currentSessionId.startsWith('new-session-')) {
|
||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
||||
// announces `session_created`, the active id is expected to be null.
|
||||
if (!currentSessionId) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', currentSessionId);
|
||||
sessionStorage.setItem('pendingSessionId', newSessionId);
|
||||
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||
pendingViewSessionRef.current.sessionId = newSessionId;
|
||||
}
|
||||
setCurrentSessionId(newSessionId);
|
||||
onReplaceTemporarySession?.(newSessionId);
|
||||
setPendingPermissionRequests((prev) =>
|
||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||
);
|
||||
@@ -257,7 +259,6 @@ export function useChatRealtimeHandlers({
|
||||
sessionStore.finalizeStreaming(sid);
|
||||
}
|
||||
accumulatedStreamRef.current = '';
|
||||
streamBufferRef.current = '';
|
||||
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
@@ -386,7 +387,6 @@ export function useChatRealtimeHandlers({
|
||||
}, [
|
||||
latestMessage,
|
||||
provider,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
@@ -396,13 +396,11 @@ export function useChatRealtimeHandlers({
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
|
||||
@@ -182,6 +182,7 @@ export function useChatSessionState({
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
|
||||
setTokenBudget(null);
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setAllMessagesLoaded(false);
|
||||
@@ -318,7 +319,6 @@ export function useChatSessionState({
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') return false;
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
@@ -551,7 +551,6 @@ export function useChatSessionState({
|
||||
const scrollToTarget = async () => {
|
||||
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'cursor') {
|
||||
try {
|
||||
// Load all messages into the store for search navigation
|
||||
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
||||
@@ -573,7 +572,6 @@ export function useChatSessionState({
|
||||
} catch {
|
||||
// Fall through and scroll in current messages
|
||||
}
|
||||
}
|
||||
}
|
||||
setVisibleMessageCount(Infinity);
|
||||
|
||||
@@ -628,7 +626,7 @@ export function useChatSessionState({
|
||||
|
||||
// Token usage fetch for Claude
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
if (!selectedProject || !selectedSession?.id) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
@@ -721,15 +719,6 @@ export function useChatSessionState({
|
||||
if (!selectedSession || !selectedProject) return;
|
||||
if (isLoadingAllMessages) return;
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') {
|
||||
setVisibleMessageCount(Infinity);
|
||||
setAllMessagesLoaded(true);
|
||||
allMessagesLoadedRef.current = true;
|
||||
setLoadAllJustFinished(true);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSessionId = selectedSession.id;
|
||||
allMessagesLoadedRef.current = true;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SubagentChildTool {
|
||||
export interface ChatMessage {
|
||||
type: string;
|
||||
content?: string;
|
||||
displayText?: string;
|
||||
timestamp: string | number | Date;
|
||||
images?: ChatImage[];
|
||||
reasoning?: string;
|
||||
@@ -40,6 +41,12 @@ export interface ChatMessage {
|
||||
toolResult?: ToolResult | null;
|
||||
toolId?: string;
|
||||
toolCallId?: string;
|
||||
commandName?: string;
|
||||
commandMessage?: string;
|
||||
commandArgs?: string;
|
||||
isLocalCommand?: boolean;
|
||||
isLocalCommandStdout?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
isSubagentContainer?: boolean;
|
||||
subagentState?: {
|
||||
childTools: SubagentChildTool[];
|
||||
@@ -108,7 +115,6 @@ export interface ChatInterfaceProps {
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
processingSessions?: Set<string>;
|
||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onShowSettings?: () => void;
|
||||
autoExpandTools?: boolean;
|
||||
|
||||
@@ -34,7 +34,6 @@ function ChatInterface({
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
processingSessions,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onShowSettings,
|
||||
autoExpandTools,
|
||||
@@ -50,7 +49,6 @@ function ChatInterface({
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const streamBufferRef = useRef('');
|
||||
const streamTimerRef = useRef<number | null>(null);
|
||||
const accumulatedStreamRef = useRef('');
|
||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||
@@ -60,7 +58,6 @@ function ChatInterface({
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
streamBufferRef.current = '';
|
||||
accumulatedStreamRef.current = '';
|
||||
}, []);
|
||||
|
||||
@@ -225,7 +222,6 @@ function ChatInterface({
|
||||
useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
provider,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
@@ -235,13 +231,11 @@ function ChatInterface({
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onWebSocketReconnect: handleWebSocketReconnect,
|
||||
sessionStore,
|
||||
|
||||
@@ -213,13 +213,6 @@ export default function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance warning when all messages are loaded */}
|
||||
{allMessagesLoaded && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 py-1.5 text-center text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400">
|
||||
{t('session.messages.perfWarning')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy message count indicator (for non-paginated view) */}
|
||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||
<div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
|
||||
@@ -51,7 +51,6 @@ export type MainContentProps = {
|
||||
onSessionProcessing: SessionLifecycleHandler;
|
||||
onSessionNotProcessing: SessionLifecycleHandler;
|
||||
processingSessions: Set<string>;
|
||||
onReplaceTemporarySession: SessionLifecycleHandler;
|
||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onShowSettings: () => void;
|
||||
externalMessageUpdate: number;
|
||||
|
||||
@@ -47,7 +47,6 @@ function MainContent({
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
processingSessions,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onShowSettings,
|
||||
externalMessageUpdate,
|
||||
@@ -137,7 +136,6 @@ function MainContent({
|
||||
onSessionProcessing={onSessionProcessing}
|
||||
onSessionNotProcessing={onSessionNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { api } from '../../../utils/api';
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type {
|
||||
ArchivedProjectListItem,
|
||||
ArchivedSessionListItem,
|
||||
DeleteProjectConfirmation,
|
||||
ProjectSortOrder,
|
||||
SidebarSearchMode,
|
||||
SessionDeleteConfirmation,
|
||||
SessionWithProvider,
|
||||
} from '../types/types';
|
||||
@@ -60,6 +63,20 @@ export type SearchProgress = {
|
||||
totalProjects: number;
|
||||
};
|
||||
|
||||
type ArchivedSessionsApiPayload = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
sessions?: ArchivedSessionListItem[];
|
||||
};
|
||||
};
|
||||
|
||||
type ArchivedProjectsApiPayload = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
projects?: ArchivedProjectListItem[];
|
||||
};
|
||||
};
|
||||
|
||||
type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
@@ -112,10 +129,13 @@ export function useSidebarController({
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
|
||||
const [searchMode, setSearchMode] = useState<SidebarSearchMode>('projects');
|
||||
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
|
||||
const [archivedProjects, setArchivedProjects] = useState<ArchivedProjectListItem[]>([]);
|
||||
const [archivedSessions, setArchivedSessions] = useState<ArchivedSessionListItem[]>([]);
|
||||
const [isArchivedSessionsLoading, setIsArchivedSessionsLoading] = useState(false);
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState<Map<string, boolean>>(new Map());
|
||||
const [loadingMoreProjects, setLoadingMoreProjects] = useState<Set<string>>(new Set());
|
||||
@@ -201,6 +221,40 @@ export function useSidebarController({
|
||||
onRefreshRef.current = onRefresh;
|
||||
}, [onRefresh]);
|
||||
|
||||
const fetchArchivedSessions = useCallback(async () => {
|
||||
setIsArchivedSessionsLoading(true);
|
||||
|
||||
try {
|
||||
const [archivedProjectsResponse, archivedSessionsResponse] = await Promise.all([
|
||||
api.archivedProjects(),
|
||||
api.getArchivedSessions(),
|
||||
]);
|
||||
|
||||
if (!archivedProjectsResponse.ok) {
|
||||
throw new Error(`Failed to load archived projects: ${archivedProjectsResponse.status}`);
|
||||
}
|
||||
|
||||
if (!archivedSessionsResponse.ok) {
|
||||
throw new Error(`Failed to load archived sessions: ${archivedSessionsResponse.status}`);
|
||||
}
|
||||
|
||||
const archivedProjectsPayload = (await archivedProjectsResponse.json()) as ArchivedProjectsApiPayload;
|
||||
const archivedSessionsPayload = (await archivedSessionsResponse.json()) as ArchivedSessionsApiPayload;
|
||||
const nextProjects = Array.isArray(archivedProjectsPayload.data?.projects) ? archivedProjectsPayload.data.projects : [];
|
||||
const archivedProjectIds = new Set(nextProjects.map((project) => project.projectId));
|
||||
const nextStandaloneSessions = Array.isArray(archivedSessionsPayload.data?.sessions)
|
||||
? archivedSessionsPayload.data.sessions.filter((session) => !session.projectId || !archivedProjectIds.has(session.projectId))
|
||||
: [];
|
||||
|
||||
setArchivedProjects(nextProjects);
|
||||
setArchivedSessions(nextStandaloneSessions);
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to load archived sessions:', error);
|
||||
} finally {
|
||||
setIsArchivedSessionsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (migrationStartedRef.current) {
|
||||
return;
|
||||
@@ -227,6 +281,20 @@ export function useSidebarController({
|
||||
void migrateLegacyStars();
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchArchivedSessions();
|
||||
}, [fetchArchivedSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMode !== 'archived') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh archive contents when the archived tab opens so restore actions
|
||||
// and background synchronizer updates are reflected without a full reload.
|
||||
void fetchArchivedSessions();
|
||||
}, [fetchArchivedSessions, searchMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticStarByProjectId((previous) => {
|
||||
if (previous.size === 0) {
|
||||
@@ -519,6 +587,56 @@ export function useSidebarController({
|
||||
[debouncedSearchQuery, sortedProjects],
|
||||
);
|
||||
|
||||
const filteredArchivedSessions = useMemo(() => {
|
||||
const normalizedSearch = debouncedSearchQuery.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return archivedSessions;
|
||||
}
|
||||
|
||||
return archivedSessions.filter((session) => {
|
||||
const searchableFields = [
|
||||
session.sessionTitle,
|
||||
session.projectDisplayName,
|
||||
session.projectPath ?? '',
|
||||
session.provider,
|
||||
];
|
||||
|
||||
return searchableFields.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}, [archivedSessions, debouncedSearchQuery]);
|
||||
|
||||
const filteredArchivedProjects = useMemo(() => {
|
||||
const normalizedSearch = debouncedSearchQuery.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return archivedProjects;
|
||||
}
|
||||
|
||||
return archivedProjects.filter((project) => {
|
||||
const projectMatches = [
|
||||
project.displayName,
|
||||
project.fullPath || '',
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
|
||||
if (projectMatches) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getAllSessions(project).some((session) => {
|
||||
const sessionSummary =
|
||||
typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string'
|
||||
? session.name
|
||||
: '';
|
||||
|
||||
return [
|
||||
sessionSummary,
|
||||
session.__provider,
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
}, [archivedProjects, debouncedSearchQuery]);
|
||||
|
||||
const startEditing = useCallback((project: Project) => {
|
||||
// `editingProject` is keyed by projectId so it stays stable across
|
||||
// display-name mutations that happen while the input is open.
|
||||
@@ -556,17 +674,26 @@ export function useSidebarController({
|
||||
// Kept with project/provider arguments for component wiring compatibility;
|
||||
// deletion now uses only `sessionId` via /api/providers/sessions/:sessionId.
|
||||
(
|
||||
projectId: string,
|
||||
projectId: string | null,
|
||||
sessionId: string,
|
||||
sessionTitle: string,
|
||||
provider: SessionDeleteConfirmation['provider'] = 'claude',
|
||||
options: {
|
||||
isArchived?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider });
|
||||
setSessionDeleteConfirmation({
|
||||
projectId,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
provider,
|
||||
isArchived: Boolean(options.isArchived),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const confirmDeleteSession = useCallback(async () => {
|
||||
const confirmDeleteSession = useCallback(async (hardDelete = false) => {
|
||||
if (!sessionDeleteConfirmation) {
|
||||
return;
|
||||
}
|
||||
@@ -575,10 +702,11 @@ export function useSidebarController({
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
const response = await api.deleteSession(sessionId);
|
||||
const response = await api.deleteSession(sessionId, hardDelete);
|
||||
|
||||
if (response.ok) {
|
||||
onSessionDelete?.(sessionId);
|
||||
await fetchArchivedSessions();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to delete session:', {
|
||||
@@ -591,7 +719,7 @@ export function useSidebarController({
|
||||
console.error('[Sidebar] Error deleting session:', error);
|
||||
alert(t('messages.deleteSessionError'));
|
||||
}
|
||||
}, [onSessionDelete, sessionDeleteConfirmation, t]);
|
||||
}, [fetchArchivedSessions, onSessionDelete, sessionDeleteConfirmation, t]);
|
||||
|
||||
const requestProjectDelete = useCallback(
|
||||
(project: Project) => {
|
||||
@@ -647,14 +775,88 @@ export function useSidebarController({
|
||||
[onProjectSelect, setCurrentProject],
|
||||
);
|
||||
|
||||
const openArchivedSession = useCallback((session: ArchivedSessionListItem) => {
|
||||
const activeProject = session.projectId
|
||||
? projects.find((candidate) => candidate.projectId === session.projectId)
|
||||
: null;
|
||||
const archivedProject = session.projectId
|
||||
? archivedProjects.find((candidate) => candidate.projectId === session.projectId)
|
||||
: null;
|
||||
const matchingProject = activeProject ?? archivedProject ?? null;
|
||||
const sessionPayload: ProjectSession = {
|
||||
id: session.sessionId,
|
||||
summary: session.sessionTitle,
|
||||
__provider: session.provider,
|
||||
__projectId: matchingProject?.projectId ?? session.projectId ?? undefined,
|
||||
};
|
||||
|
||||
// Archived sessions still need a selected project context. Active projects
|
||||
// come from the normal sidebar list, while archived-project sessions resolve
|
||||
// through the archive payload loaded by this controller.
|
||||
if (matchingProject) {
|
||||
handleProjectSelect(matchingProject);
|
||||
}
|
||||
|
||||
onSessionSelect(sessionPayload);
|
||||
}, [archivedProjects, handleProjectSelect, onSessionSelect, projects]);
|
||||
|
||||
const restoreArchivedProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
const response = await api.restoreProject(projectId);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to restore project:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
alert(t('messages.restoreProjectFailed', 'Failed to restore project. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
Promise.resolve(onRefresh()),
|
||||
fetchArchivedSessions(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error restoring project:', error);
|
||||
alert(t('messages.restoreProjectError', 'Error restoring project. Please try again.'));
|
||||
}
|
||||
}, [fetchArchivedSessions, onRefresh, t]);
|
||||
|
||||
const restoreArchivedSession = useCallback(async (sessionId: string) => {
|
||||
try {
|
||||
const response = await api.restoreSession(sessionId);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to restore session:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
alert(t('messages.restoreSessionFailed', 'Failed to restore session. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
Promise.resolve(onRefresh()),
|
||||
fetchArchivedSessions(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error restoring session:', error);
|
||||
alert(t('messages.restoreSessionError', 'Error restoring session. Please try again.'));
|
||||
}
|
||||
}, [fetchArchivedSessions, onRefresh, t]);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
await Promise.all([
|
||||
Promise.resolve(onRefresh()),
|
||||
fetchArchivedSessions(),
|
||||
]);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [onRefresh]);
|
||||
}, [fetchArchivedSessions, onRefresh]);
|
||||
|
||||
const updateSessionSummary = useCallback(
|
||||
// `_projectId` and `_provider` are preserved for compatibility with
|
||||
@@ -712,6 +914,10 @@ export function useSidebarController({
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
filteredProjects,
|
||||
archivedProjects: filteredArchivedProjects,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
||||
isArchivedSessionsLoading,
|
||||
toggleProject,
|
||||
handleSessionClick,
|
||||
toggleStarProject,
|
||||
@@ -726,6 +932,9 @@ export function useSidebarController({
|
||||
requestProjectDelete,
|
||||
confirmDeleteProject,
|
||||
handleProjectSelect,
|
||||
openArchivedSession,
|
||||
restoreArchivedProject,
|
||||
restoreArchivedSession,
|
||||
refreshProjects,
|
||||
updateSessionSummary,
|
||||
collapseSidebar,
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'archived';
|
||||
export type ArchivedProjectListItem = Project & { isArchived: true };
|
||||
|
||||
export type SessionWithProvider = ProjectSession & {
|
||||
__provider: LLMProvider;
|
||||
};
|
||||
|
||||
export type ArchivedSessionListItem = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
projectId: string | null;
|
||||
projectPath: string | null;
|
||||
projectDisplayName: string;
|
||||
sessionTitle: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
lastActivity: string | null;
|
||||
isProjectArchived: boolean;
|
||||
};
|
||||
|
||||
export type DeleteProjectConfirmation = {
|
||||
project: Project;
|
||||
sessionCount: number;
|
||||
@@ -14,10 +29,11 @@ export type DeleteProjectConfirmation = {
|
||||
// Delete confirmation payload used by sidebar UX. `projectId`/`provider` are
|
||||
// kept for wiring compatibility, while API deletion now keys only by sessionId.
|
||||
export type SessionDeleteConfirmation = {
|
||||
projectId: string;
|
||||
projectId: string | null;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
provider: LLMProvider;
|
||||
isArchived: boolean;
|
||||
};
|
||||
|
||||
export type SidebarProps = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
|
||||
|
||||
@@ -52,44 +53,24 @@ export const clearLegacyStarredProjectIds = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getCreatedTimestamp = (session: SessionWithProvider): string => {
|
||||
return String(session.createdAt || session.created_at || '');
|
||||
};
|
||||
|
||||
const getUpdatedTimestamp = (session: SessionWithProvider): string => {
|
||||
return String(session.lastActivity || '');
|
||||
};
|
||||
|
||||
export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||
if (session.__provider === 'cursor') {
|
||||
return new Date(session.createdAt || 0);
|
||||
}
|
||||
|
||||
if (session.__provider === 'codex') {
|
||||
return new Date(session.createdAt || session.lastActivity || 0);
|
||||
}
|
||||
|
||||
return new Date(session.lastActivity || session.createdAt || 0);
|
||||
return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0);
|
||||
};
|
||||
|
||||
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
|
||||
if (session.__provider === 'cursor') {
|
||||
return session.summary || session.name || t('projects.untitledSession');
|
||||
}
|
||||
|
||||
if (session.__provider === 'codex') {
|
||||
return session.summary || session.name || t('projects.codexSession');
|
||||
}
|
||||
|
||||
if (session.__provider === 'gemini') {
|
||||
return session.summary || session.name || t('projects.newSession');
|
||||
}
|
||||
|
||||
return session.summary || t('projects.newSession');
|
||||
return session.summary || session.name || t('projects.newSession');
|
||||
};
|
||||
|
||||
export const getSessionTime = (session: SessionWithProvider): string => {
|
||||
if (session.__provider === 'cursor') {
|
||||
return String(session.createdAt || '');
|
||||
}
|
||||
|
||||
if (session.__provider === 'codex') {
|
||||
return String(session.createdAt || session.lastActivity || '');
|
||||
}
|
||||
|
||||
return String(session.lastActivity || session.createdAt || '');
|
||||
return getUpdatedTimestamp(session) || getCreatedTimestamp(session);
|
||||
};
|
||||
|
||||
export const createSessionViewModel = (
|
||||
|
||||
@@ -75,6 +75,10 @@ function Sidebar({
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
filteredProjects,
|
||||
archivedProjects,
|
||||
archivedSessions,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
toggleProject,
|
||||
handleSessionClick,
|
||||
toggleStarProject,
|
||||
@@ -90,6 +94,9 @@ function Sidebar({
|
||||
requestProjectDelete,
|
||||
confirmDeleteProject,
|
||||
handleProjectSelect,
|
||||
openArchivedSession,
|
||||
restoreArchivedProject,
|
||||
restoreArchivedSession,
|
||||
refreshProjects,
|
||||
updateSessionSummary,
|
||||
collapseSidebar: handleCollapseSidebar,
|
||||
@@ -184,8 +191,8 @@ function Sidebar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarModals
|
||||
projects={projects}
|
||||
<SidebarModals
|
||||
projects={projects}
|
||||
showSettings={showSettings}
|
||||
settingsInitialTab={settingsInitialTab}
|
||||
onCloseSettings={onCloseSettings}
|
||||
@@ -217,22 +224,38 @@ function Sidebar({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SidebarContent
|
||||
<SidebarContent
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projects={projects}
|
||||
archivedProjects={archivedProjects}
|
||||
archivedSessions={archivedSessions}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onClearSearchFilter={() => setSearchFilter('')}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={(mode: 'projects' | 'conversations') => {
|
||||
onSearchModeChange={(mode) => {
|
||||
setSearchMode(mode);
|
||||
if (mode === 'projects') clearConversationResults();
|
||||
}}
|
||||
conversationResults={conversationResults}
|
||||
isSearching={isSearching}
|
||||
searchProgress={searchProgress}
|
||||
onRestoreArchivedProject={restoreArchivedProject}
|
||||
onArchivedSessionClick={openArchivedSession}
|
||||
onRestoreArchivedSession={restoreArchivedSession}
|
||||
onDeleteArchivedSession={(session) => {
|
||||
showDeleteSessionConfirmation(
|
||||
session.projectId,
|
||||
session.sessionId,
|
||||
session.sessionTitle,
|
||||
session.provider,
|
||||
{ isArchived: true },
|
||||
);
|
||||
}}
|
||||
onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
|
||||
// `projectId` (DB key) is the canonical identifier post-migration.
|
||||
// The server emits null when it can't resolve a project row for
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Folder, MessageSquare, Search } from 'lucide-react';
|
||||
import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ScrollArea } from '../../../../shared/view/ui';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
import { getAllSessions } from '../../utils/utils';
|
||||
|
||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||
const parts: ReactNode[] = [];
|
||||
@@ -35,19 +36,100 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
);
|
||||
}
|
||||
|
||||
type ArchivedSessionGroup = {
|
||||
key: string;
|
||||
projectId: string | null;
|
||||
projectDisplayName: string;
|
||||
projectPath: string | null;
|
||||
isProjectArchived: boolean;
|
||||
sessions: ArchivedSessionListItem[];
|
||||
latestActivity: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups archived sessions by project metadata so the archive view preserves
|
||||
* the same mental model as the active sidebar: projects first, then sessions.
|
||||
*/
|
||||
function groupArchivedSessionsByProject(sessions: ArchivedSessionListItem[]): ArchivedSessionGroup[] {
|
||||
const groups = new Map<string, ArchivedSessionGroup>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.projectId ?? session.projectPath ?? `session:${session.sessionId}`;
|
||||
const existingGroup = groups.get(key);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.sessions.push(session);
|
||||
if (!existingGroup.latestActivity || (session.lastActivity && session.lastActivity > existingGroup.latestActivity)) {
|
||||
existingGroup.latestActivity = session.lastActivity;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
projectId: session.projectId,
|
||||
projectDisplayName: session.projectDisplayName,
|
||||
projectPath: session.projectPath,
|
||||
isProjectArchived: session.isProjectArchived,
|
||||
sessions: [session],
|
||||
latestActivity: session.lastActivity,
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((groupA, groupB) => {
|
||||
const a = groupA.latestActivity ?? '';
|
||||
const b = groupB.latestActivity ?? '';
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
|
||||
function formatCompactArchivedAge(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(Math.max(0, Date.now() - date.getTime()) / (1000 * 60));
|
||||
if (diffInMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes}m`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours}hr`;
|
||||
}
|
||||
|
||||
return `${Math.floor(diffInHours / 24)}d`;
|
||||
}
|
||||
|
||||
type SidebarContentProps = {
|
||||
isPWA: boolean;
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projects: Project[];
|
||||
archivedProjects: ArchivedProjectListItem[];
|
||||
archivedSessions: ArchivedSessionListItem[];
|
||||
archivedSessionsCount: number;
|
||||
isArchivedSessionsLoading: boolean;
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onClearSearchFilter: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
searchMode: SidebarSearchMode;
|
||||
onSearchModeChange: (mode: SidebarSearchMode) => void;
|
||||
conversationResults: ConversationSearchResults | null;
|
||||
isSearching: boolean;
|
||||
searchProgress: SearchProgress | null;
|
||||
onRestoreArchivedProject: (projectId: string) => void;
|
||||
onArchivedSessionClick: (session: ArchivedSessionListItem) => void;
|
||||
onRestoreArchivedSession: (sessionId: string) => void;
|
||||
onDeleteArchivedSession: (session: ArchivedSessionListItem) => void;
|
||||
// Conversation result clicks pass back the DB projectId (or null when the
|
||||
// server couldn't resolve it). Consumers must handle the null case.
|
||||
onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||
@@ -70,6 +152,10 @@ export default function SidebarContent({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projects,
|
||||
archivedProjects,
|
||||
archivedSessions,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
onClearSearchFilter,
|
||||
@@ -78,6 +164,10 @@ export default function SidebarContent({
|
||||
conversationResults,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
onRestoreArchivedProject,
|
||||
onArchivedSessionClick,
|
||||
onRestoreArchivedSession,
|
||||
onDeleteArchivedSession,
|
||||
onConversationResultClick,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
@@ -94,6 +184,7 @@ export default function SidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;
|
||||
const hasPartialResults = conversationResults && conversationResults.results.length > 0;
|
||||
const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -105,6 +196,8 @@ export default function SidebarContent({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projectsCount={projects.length}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={onSearchFilterChange}
|
||||
onClearSearchFilter={onClearSearchFilter}
|
||||
@@ -214,6 +307,207 @@ export default function SidebarContent({
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : searchMode === 'archived' ? (
|
||||
isArchivedSessionsLoading ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{t('archived.loadingTitle', 'Loading archive...')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('archived.loadingDescription', 'Fetching hidden workspaces and sessions you can restore later.')}
|
||||
</p>
|
||||
</div>
|
||||
) : archivedProjects.length === 0 && groupedArchivedSessions.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<Archive className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{archivedSessionsCount > 0
|
||||
? t('archived.noMatchingSessions', 'No matching archived items')
|
||||
: t('archived.emptyTitle', 'No archived items')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{archivedSessionsCount > 0
|
||||
? t('archived.tryDifferentSearch', 'Try a different search term.')
|
||||
: t('archived.emptyDescription', 'Archived workspaces and sessions will appear here when you hide them from the active list.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 px-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{`${archivedSessionsCount} ${t(
|
||||
archivedSessionsCount === 1 ? 'archived.sessionCountOne' : 'archived.sessionCountOther',
|
||||
archivedSessionsCount === 1 ? 'archived item' : 'archived items',
|
||||
)}`}
|
||||
</p>
|
||||
</div>
|
||||
{archivedProjects.map((project) => {
|
||||
const projectSessions = getAllSessions(project);
|
||||
|
||||
return (
|
||||
<div key={project.projectId} className="overflow-hidden rounded-xl border border-border/70 bg-card/60 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{project.displayName}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground/70" title={project.fullPath}>
|
||||
{project.fullPath}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 transition-colors hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
|
||||
onClick={() => onRestoreArchivedProject(project.projectId)}
|
||||
title={t('archived.restoreProject', 'Restore workspace')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{projectSessions.length > 0 && (
|
||||
<div className="divide-y divide-border/50">
|
||||
{projectSessions.map((session) => (
|
||||
<button
|
||||
key={String(session.id)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-accent/40"
|
||||
onClick={() => onArchivedSessionClick({
|
||||
sessionId: String(session.id),
|
||||
provider: session.__provider,
|
||||
projectId: project.projectId,
|
||||
projectPath: project.fullPath,
|
||||
projectDisplayName: project.displayName,
|
||||
sessionTitle:
|
||||
(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
? session.name
|
||||
: String(session.id)),
|
||||
createdAt: typeof session.created_at === 'string' ? session.created_at : null,
|
||||
updatedAt: typeof session.updated_at === 'string' ? session.updated_at : null,
|
||||
lastActivity:
|
||||
typeof session.lastActivity === 'string'
|
||||
? session.lastActivity
|
||||
: typeof session.updated_at === 'string'
|
||||
? session.updated_at
|
||||
: typeof session.created_at === 'string'
|
||||
? session.created_at
|
||||
: null,
|
||||
isProjectArchived: true,
|
||||
})}
|
||||
>
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
? session.name
|
||||
: String(session.id))}
|
||||
</span>
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatCompactArchivedAge(
|
||||
typeof session.lastActivity === 'string'
|
||||
? session.lastActivity
|
||||
: typeof session.updated_at === 'string'
|
||||
? session.updated_at
|
||||
: typeof session.created_at === 'string'
|
||||
? session.created_at
|
||||
: null,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{session.__provider}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{groupedArchivedSessions.map((group) => (
|
||||
<div key={group.key} className="overflow-hidden rounded-xl border border-border/70 bg-card/60 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{group.projectDisplayName}
|
||||
</span>
|
||||
{group.isProjectArchived && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{group.projectPath && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground/70" title={group.projectPath}>
|
||||
{group.projectPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{group.sessions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{group.sessions.map((session) => (
|
||||
<div key={session.sessionId} className="flex items-center gap-2 px-3 py-2.5">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left transition-colors hover:text-foreground"
|
||||
onClick={() => onArchivedSessionClick(session)}
|
||||
>
|
||||
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{session.sessionTitle}
|
||||
</span>
|
||||
{session.lastActivity && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatCompactArchivedAge(session.lastActivity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{session.provider}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 transition-colors hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
|
||||
onClick={() => onRestoreArchivedSession(session.sessionId)}
|
||||
title={t('archived.restore', 'Restore session')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
onClick={() => onDeleteArchivedSession(session)}
|
||||
title={t('archived.deletePermanently', 'Delete permanently')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { SidebarSearchMode } from '../../types/types';
|
||||
import GitHubStarBadge from './GitHubStarBadge';
|
||||
|
||||
const MOD_KEY =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
isPWA: boolean;
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projectsCount: number;
|
||||
archivedSessionsCount: number;
|
||||
isArchivedSessionsLoading: boolean;
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onClearSearchFilter: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
searchMode: SidebarSearchMode;
|
||||
onSearchModeChange: (mode: SidebarSearchMode) => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
onCreateProject: () => void;
|
||||
@@ -32,6 +33,8 @@ export default function SidebarHeader({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projectsCount,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
onClearSearchFilter,
|
||||
@@ -43,6 +46,13 @@ export default function SidebarHeader({
|
||||
onCollapseSidebar,
|
||||
t,
|
||||
}: SidebarHeaderProps) {
|
||||
const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
||||
const searchPlaceholder = searchMode === 'conversations'
|
||||
? t('search.conversationsPlaceholder')
|
||||
: searchMode === 'archived'
|
||||
? t('search.archivedPlaceholder', 'Search archived sessions...')
|
||||
: t('projects.searchPlaceholder');
|
||||
|
||||
const LogoBlock = () => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
|
||||
@@ -113,7 +123,7 @@ export default function SidebarHeader({
|
||||
<GitHubStarBadge />
|
||||
|
||||
{/* Search bar */}
|
||||
{projectsCount > 0 && !isLoading && (
|
||||
{showSearchTools && (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{/* Search mode toggle */}
|
||||
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||
@@ -143,12 +153,28 @@ export default function SidebarHeader({
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('archived')}
|
||||
aria-pressed={searchMode === 'archived'}
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
@@ -215,7 +241,7 @@ export default function SidebarHeader({
|
||||
</div>
|
||||
|
||||
{/* Mobile search */}
|
||||
{projectsCount > 0 && !isLoading && (
|
||||
{showSearchTools && (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||
<button
|
||||
@@ -244,12 +270,28 @@ export default function SidebarHeader({
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('archived')}
|
||||
aria-pressed={searchMode === 'archived'}
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
@@ -25,7 +25,7 @@ type SidebarModalsProps = {
|
||||
onConfirmDeleteProject: (deleteData?: boolean) => void;
|
||||
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
|
||||
onCancelDeleteSession: () => void;
|
||||
onConfirmDeleteSession: () => void;
|
||||
onConfirmDeleteSession: (hardDelete?: boolean) => void;
|
||||
showVersionModal: boolean;
|
||||
onCloseVersionModal: () => void;
|
||||
releaseInfo: ReleaseInfo | null;
|
||||
@@ -133,7 +133,7 @@ export default function SidebarModals({
|
||||
onClick={() => onConfirmDeleteProject(false)}
|
||||
>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
{t('deleteConfirmation.removeFromSidebar')}
|
||||
{t('deleteConfirmation.archiveProject', 'Archive project')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -173,22 +173,34 @@ export default function SidebarModals({
|
||||
?
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t('deleteConfirmation.cannotUndo')}
|
||||
{sessionDeleteConfirmation.isArchived
|
||||
? t('deleteConfirmation.archivedSessionNotice', 'This session is already archived. You can keep it hidden or delete it permanently.')
|
||||
: t('deleteConfirmation.archiveSessionNotice', 'Archive keeps the session out of the active list while preserving its history.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancelDeleteSession}>
|
||||
{t('actions.cancel')}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
|
||||
{!sessionDeleteConfirmation.isArchived && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onConfirmDeleteSession(false)}
|
||||
>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
{t('deleteConfirmation.archiveSession', 'Archive session')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={onConfirmDeleteSession}
|
||||
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={() => onConfirmDeleteSession(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('actions.delete')}
|
||||
{t('deleteConfirmation.deleteSessionPermanently', 'Delete permanently')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" onClick={onCancelDeleteSession}>
|
||||
{t('actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function SidebarSessionItem({
|
||||
event.stopPropagation();
|
||||
requestDeleteSession();
|
||||
}}
|
||||
title={t('tooltips.deleteSession')}
|
||||
title={t('tooltips.deleteSessionOptions', 'Archive or permanently delete this session')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
|
||||
@@ -435,9 +435,7 @@ export function useProjectsState({
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveSession =
|
||||
(selectedSession && activeSessions.has(selectedSession.id)) ||
|
||||
(activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
|
||||
const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id));
|
||||
|
||||
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
|
||||
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
|
||||
|
||||
@@ -44,23 +44,6 @@ export function useSessionProtection() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replaceTemporarySession = useCallback((realSessionId?: string | null) => {
|
||||
if (!realSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSessions((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const sessionId of prev) {
|
||||
if (!sessionId.startsWith('new-session-')) {
|
||||
next.add(sessionId);
|
||||
}
|
||||
}
|
||||
next.add(realSessionId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
@@ -68,6 +51,5 @@ export function useSessionProtection() {
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
replaceTemporarySession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,20 @@ export interface NormalizedMessage {
|
||||
// kind-specific fields (flat for simplicity)
|
||||
role?: 'user' | 'assistant';
|
||||
content?: string;
|
||||
/**
|
||||
* Mirrors optional transcript metadata from the server.
|
||||
*
|
||||
* These fields are currently used by Claude history normalization so local
|
||||
* slash commands, local stdout, and compact summaries do not disappear when
|
||||
* the session store hydrates from REST history.
|
||||
*/
|
||||
displayText?: string;
|
||||
commandName?: string;
|
||||
commandMessage?: string;
|
||||
commandArgs?: string;
|
||||
isLocalCommand?: boolean;
|
||||
isLocalCommandStdout?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
images?: string[];
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
|
||||
@@ -54,6 +54,7 @@ export const api = {
|
||||
// After the projectName → projectId migration the path/query identifier is
|
||||
// the DB-assigned `projectId`; parameter names reflect that for clarity.
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
archivedProjects: () => authenticatedFetch('/api/projects/archived'),
|
||||
projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
@@ -78,9 +79,28 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ displayName }),
|
||||
}),
|
||||
deleteSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/providers/sessions/${sessionId}`, {
|
||||
restoreProject: (projectId) =>
|
||||
authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/restore`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
// Session deletion now mirrors project deletion:
|
||||
// - default: archive only (`isArchived = 1`)
|
||||
// - hardDelete: remove the row and, by default, its persisted transcript file
|
||||
deleteSession: (sessionId, hardDelete = false) => {
|
||||
const params = new URLSearchParams();
|
||||
if (hardDelete) {
|
||||
params.set('force', 'true');
|
||||
}
|
||||
const qs = params.toString();
|
||||
return authenticatedFetch(`/api/providers/sessions/${sessionId}${qs ? `?${qs}` : ''}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
getArchivedSessions: () =>
|
||||
authenticatedFetch('/api/providers/sessions/archived'),
|
||||
restoreSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
renameSession: (sessionId, summary) =>
|
||||
authenticatedFetch(`/api/providers/sessions/${sessionId}`, {
|
||||
|
||||
Reference in New Issue
Block a user