Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
Expand Down Expand Up @@ -227,6 +235,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onLoadMoreSessions: () => Promise<void> | void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onArchiveAllSessions: () => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
Expand All @@ -244,6 +253,7 @@ export function ChatSidebar({
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onArchiveAllSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
Expand Down Expand Up @@ -338,6 +348,8 @@ export function ChatSidebar({
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const [archiveAllOpen, setArchiveAllOpen] = useState(false)
const [archiveAllSubmitting, setArchiveAllSubmitting] = useState(false)
const trimmedQuery = searchQuery.trim()

// Hotkey (session.focusSearch) → focus the field once it's mounted.
Expand Down Expand Up @@ -1011,6 +1023,25 @@ export function ChatSidebar({
? Object.values(sessionProfilesTruncated).some(Boolean)
: Boolean(sessionProfilesTruncated[profileScope])

const archiveAllDisabled = sessionsLoading || agentSessions.length === 0 || archiveAllSubmitting

const handleArchiveAll = async () => {
if (archiveAllSubmitting) {
return
}

setArchiveAllSubmitting(true)

try {
await onArchiveAllSessions()
setArchiveAllOpen(false)
} catch {
// The caller owns the error toast/rollback; keep the dialog open.
} finally {
setArchiveAllSubmitting(false)
}
}

const displayRecentsCountRef = useRef(0)
const loadedRecentsCountRef = useRef(0)
displayRecentsCountRef.current = displayAgentSessions.length
Expand Down Expand Up @@ -1379,6 +1410,30 @@ export function ChatSidebar({
</div>
) : (
<div className="flex shrink-0 items-center gap-0.5">
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition hides the only bulk-archive entry point in All Profiles mode, so the profile="__all__" path is unreachable despite being implemented below the UI. Expose the action in that mode or remove the cross-profile behavior and update the stated scope.

<Tip label="Archive all sessions">
<Button
aria-label="Archive all unpinned sessions"
className="text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
disabled={archiveAllDisabled}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setArchiveAllOpen(true)
}}
size="icon-xs"
variant="ghost"
>
<Codicon
name={archiveAllSubmitting ? 'loading' : 'archive'}
size="0.75rem"
spinning={archiveAllSubmitting}
/>
</Button>
</Tip>
) : null}
</div>
{!showAllProfiles ? (
<Tip label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}>
<Button
Expand Down Expand Up @@ -1532,10 +1587,52 @@ export function ChatSidebar({
</div>
</SidebarContent>
<ProjectDialog />
<ArchiveAllSessionsDialog
count={displayAgentSessions.length}
onConfirm={handleArchiveAll}
onOpenChange={setArchiveAllOpen}
open={archiveAllOpen}
submitting={archiveAllSubmitting}
/>
</Sidebar>
)
}

interface ArchiveAllSessionsDialogProps {
count: number
open: boolean
onConfirm: () => void | Promise<void>
onOpenChange: (open: boolean) => void
submitting: boolean
}

function ArchiveAllSessionsDialog({ count, open, onConfirm, onOpenChange, submitting }: ArchiveAllSessionsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Archive all sessions</DialogTitle>
<DialogDescription>
Archive unpinned sessions from the sidebar. Pinned chats, the current chat, and running sessions stay
visible.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-control-background) px-3 py-2 text-xs text-(--ui-text-secondary)">
{count > 0 ? `${count} visible session${count === 1 ? '' : 's'} will be checked.` : 'No sessions to archive.'}
</div>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} onClick={() => void onConfirm()} type="button" variant="destructive">
{submitting ? 'Archiving...' : 'Archive all'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

interface MessagingSection {
sourceId: string
label: string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/contrib/latest-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function makeChatActions(): ChatActions {

function makeSidebarActions(): SidebarActions {
return {
onArchiveAllSessions: vi.fn(),
onArchiveSession: vi.fn(),
onBranchSession: vi.fn(),
onDeleteSession: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/contrib/latest-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function latestChatActions(actions: ChatActions): ChatActions {

export function latestSidebarActions(actions: SidebarActions): SidebarActions {
return {
onArchiveAllSessions: (...args) => actions.onArchiveAllSessions(...args),
onArchiveSession: (...args) => actions.onArchiveSession(...args),
onBranchSession: (...args) => actions.onBranchSession(...args),
onDeleteSession: (...args) => actions.onDeleteSession(...args),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/contrib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGate
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
export type SidebarActions = Pick<
ComponentProps<typeof ChatSidebar>,
| 'onArchiveAllSessions'
| 'onArchiveSession'
| 'onBranchSession'
| 'onDeleteSession'
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}, [restartPreviewServer])

const {
archiveAllSessions,
archiveSession,
branchCurrentSession,
branchStoredSession,
Expand Down Expand Up @@ -827,6 +828,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const nextActions: WiringActions = {
onAddContextRef: composer.addContextRefAttachment,
onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
onArchiveAllSessions: () => archiveAllSessions().then(() => refreshSessions()),
onArchiveSession: sessionId => void archiveSession(sessionId),
onAttachDroppedItems: composer.attachDroppedItems,
onAttachImageBlob: composer.attachImageBlob,
Expand Down
50 changes: 47 additions & 3 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'

import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { bulkArchiveSessions, deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
Expand All @@ -13,7 +13,7 @@ import { migrateSessionDraft } from '@/store/composer'
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeGatewayProfile, $newChatProfile, $profileScope, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
beginSessionMutation,
endSessionMutation,
Expand Down Expand Up @@ -58,6 +58,7 @@ import {
} from '@/store/session'
import {
$sessionTiles,
$workingSessionIds,
closeSessionTile,
dropSessionState,
openSessionTile,
Expand All @@ -67,7 +68,7 @@ import {
} from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes'

import { navigateToWorkspacePage, NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../../types'
Expand Down Expand Up @@ -1449,7 +1450,50 @@ export function useSessionActions({
[copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft]
)

const archiveAllSessions = useCallback(async () => {
clearNotifications()

const previousSessions = $sessions.get()
const preserveIds = new Set<string>([...$pinnedSessionIds.get(), ...$workingSessionIds.get()])

if (selectedStoredSessionId) {
preserveIds.add(selectedStoredSessionId)
}

if (activeSessionId) {
preserveIds.add(activeSessionId)
}

for (const session of previousSessions) {
if (session.id === selectedStoredSessionId || session.id === activeSessionId) {
preserveIds.add(sessionPinId(session))
}
}

const shouldPreserve = (session: SessionInfo) =>
preserveIds.has(session.id) || (session._lineage_root_id != null && preserveIds.has(session._lineage_root_id))

const keptSessions = previousSessions.filter(shouldPreserve)
setSessions(keptSessions)

try {
const result = await bulkArchiveSessions([...preserveIds], $profileScope.get())
notify({
durationMs: 2_500,
kind: 'success',
message: result.archived === 1 ? 'Archived 1 session' : `Archived ${result.archived} sessions`
})

return result
} catch (err) {
setSessions(previousSessions)
notifyError(err, 'Archive all failed')
throw err
}
}, [activeSessionId, selectedStoredSessionId])

return {
archiveAllSessions,
archiveSession,
branchCurrentSession,
branchStoredSession,
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
audioSpeakRequestTimeoutMs,
audioTranscribeRequestTimeoutMs,
bulkArchiveSessions,
getCronJobs,
getGlobalModelInfo,
getGlobalModelOptions,
Expand Down Expand Up @@ -411,3 +412,50 @@ describe('Hermes REST helpers', () => {
)
})
})

describe('bulkArchiveSessions', () => {
const originalHermesDesktop = window.hermesDesktop

afterEach(() => {
vi.restoreAllMocks()
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: originalHermesDesktop,
writable: true
})
})

it('posts deduped preserve ids to the manual bulk archive endpoint', async () => {
const api = vi.fn().mockResolvedValue({ ok: true, archived: 12 })
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api },
writable: true
})

await expect(bulkArchiveSessions(['pin', '', 'current', 'pin'])).resolves.toEqual({ ok: true, archived: 12 })

expect(api).toHaveBeenCalledWith({
path: '/api/sessions/bulk-archive',
method: 'POST',
body: { preserve_ids: ['pin', 'current'] }
})
})

it('passes the visible profile scope to the bulk archive endpoint', async () => {
const api = vi.fn().mockResolvedValue({ ok: true, archived: 3 })
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api },
writable: true
})

await bulkArchiveSessions(['pin'], '__all__')

expect(api).toHaveBeenCalledWith({
path: '/api/sessions/bulk-archive',
method: 'POST',
body: { preserve_ids: ['pin'], profile: '__all__' }
})
})
})
21 changes: 21 additions & 0 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,27 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
}
}

export function bulkArchiveSessions(
preserveIds: string[] = [],
profile?: null | string
): Promise<{ ok: boolean; archived: number }> {
const body: { preserve_ids: string[]; profile?: string } = {
preserve_ids: Array.from(new Set(preserveIds.filter(Boolean))).slice(0, 5000)
}

const scopedProfile = profile?.trim()

if (scopedProfile) {
body.profile = scopedProfile
}

return window.hermesDesktop.api<{ ok: boolean; archived: number }>({
path: '/api/sessions/bulk-archive',
method: 'POST',
body
})
}

// Mutations take the owning `profile` so Electron routes them to that profile's
// backend (remote pool or local primary) via request.profile — matching the
// read path. A remote session's row lives only on its remote host, so a mutation
Expand Down
Loading
Loading