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
35 changes: 35 additions & 0 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-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 @@ -166,6 +167,12 @@ const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT)
const HEADER_ACTION_BTN =
'text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100'

// Same hover-revealed affordance as HEADER_ACTION_BTN, but turns destructive-red
// on hover — used for the "Delete all chats" trash so it reads as dangerous
// without shouting at rest.
const HEADER_DESTRUCTIVE_BTN =
'text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover/section:opacity-100 focus-visible:opacity-100'

// The view toggle (overview group toggle / in-project back) is the one control
// that stays visible at all times — it's the stable navigation affordance, not
// a hover-revealed action.
Expand Down Expand Up @@ -206,6 +213,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onDeleteAllSessions: () => Promise<void> | void
onArchiveSession: (sessionId: string) => void
onBranchSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
Expand All @@ -221,6 +229,7 @@ export function ChatSidebar({
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onDeleteAllSessions,
onArchiveSession,
onBranchSession,
onNewSessionInWorkspace,
Expand Down Expand Up @@ -278,6 +287,7 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [searchPending, setSearchPending] = useState(false)
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [deleteAllOpen, setDeleteAllOpen] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const [recentsLoadMorePending, setRecentsLoadMorePending] = useState(false)
Expand Down Expand Up @@ -1286,6 +1296,20 @@ export function ChatSidebar({
</Button>
) : null}
</div>
{!showAllProfiles && !agentsGrouped && (agentSessions.length > 0 || pinnedSessions.length > 0) ? (
<Button
aria-label={s.deleteAll.action}
className={HEADER_DESTRUCTIVE_BTN}
onClick={event => {
event.stopPropagation()
setDeleteAllOpen(true)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="trash" size="0.75rem" />
</Button>
) : null}
</div>
)
}
Expand Down Expand Up @@ -1401,6 +1425,17 @@ export function ChatSidebar({
)}
</SidebarContent>
<ProjectDialog />
<ConfirmDialog
busyLabel={s.deleteAll.busy}
confirmLabel={s.deleteAll.confirm}
description={s.deleteAll.body(Math.max(sessionsTotal, agentSessions.length + pinnedSessions.length))}
destructive
doneLabel={s.deleteAll.done}
onClose={() => setDeleteAllOpen(false)}
onConfirm={() => onDeleteAllSessions()}
open={deleteAllOpen}
title={s.deleteAll.title}
/>
</Sidebar>
)
}
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export function DesktopController() {
}, [])

const {
clearAllSessions,
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
Expand Down Expand Up @@ -554,6 +555,26 @@ export function DesktopController() {
updateSessionState
})

// "Delete all chats" from the sidebar: tear the open chat down to a fresh
// draft first (so the route effect can't resume a session we're deleting),
// then clear the scope's history and close the orphaned runtime — the same
// teardown removeSession does for a single chat, lifted to the whole list.
const clearAllChats = useCallback(async () => {
const closingRuntimeId = activeSessionId

if (selectedStoredSessionId) {
startFreshSessionDraft(true)
}

try {
await clearAllSessions()
} finally {
if (closingRuntimeId) {
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
}
}, [activeSessionId, clearAllSessions, requestGateway, selectedStoredSessionId, startFreshSessionDraft])

// Single global listener for every rebindable hotkey (incl. profile switching)
// plus the on-screen keybind editor's capture mode.
useKeybinds({
Expand Down Expand Up @@ -890,6 +911,7 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onBranchSession={sessionId => void branchStoredSession(sessionId)}
onDeleteAllSessions={clearAllChats}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { bulkDeleteSessions, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { $pinnedSessionIds } from '@/store/layout'
import { $sessions, setSessions, setSessionsTotal } from '@/store/session'

import { useSessionListActions } from './use-session-list-actions'

vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
bulkDeleteSessions: vi.fn(),
getCronJobs: vi.fn().mockResolvedValue([]),
listAllProfileSessions: vi.fn()
}))

const mockedList = vi.mocked(listAllProfileSessions)
const mockedBulkDelete = vi.mocked(bulkDeleteSessions)

function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
ended_at: null,
id: 'stored-1',
input_tokens: 0,
is_active: false,
last_active: 1,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: 'desktop',
started_at: 1,
title: 'stored',
tool_call_count: 0,
...overrides
}
}

function emptyPage() {
return { limit: 0, offset: 0, sessions: [] as SessionInfo[], total: 0 }
}

function Harness({ onReady }: { onReady: (clear: () => Promise<number>) => void }) {
const { clearAllSessions } = useSessionListActions({ profileScope: 'default' })

useEffect(() => {
onReady(clearAllSessions)
}, [clearAllSessions, onReady])

return null
}

async function getClear(): Promise<() => Promise<number>> {
let clear: (() => Promise<number>) | null = null
render(<Harness onReady={c => (clear = c)} />)
await waitFor(() => expect(clear).not.toBeNull())

return clear!
}

afterEach(() => {
cleanup()
vi.clearAllMocks()
setSessions([])
setSessionsTotal(0)
$pinnedSessionIds.set([])
})

describe('useSessionListActions › clearAllSessions', () => {
it('pages the scope, bulk-deletes every chat, and clears the list + pins', async () => {
const rows = [storedSession({ id: 's1' }), storedSession({ id: 's2' })]
setSessions(rows)
setSessionsTotal(2)
$pinnedSessionIds.set(['s2'])

let drained = false
mockedList.mockImplementation((limit, _min, _archived, _order, _profile, filter) => {
// Cron / messaging slices fetched by the closing refresh stay empty.
if (filter?.source) {
return Promise.resolve(emptyPage())
}

// The clear loop pages with limit === BULK_DELETE_MAX_IDS (500): hand back
// the rows once, then empty so the loop terminates.
if (limit === 500 && !drained) {
drained = true

return Promise.resolve({ limit: 500, offset: 0, sessions: rows, total: 2 })
}

return Promise.resolve(emptyPage())
})
mockedBulkDelete.mockImplementation((ids: string[]) => Promise.resolve({ deleted: ids.length, ok: true }))

const clear = await getClear()
const removed = await clear()

expect(removed).toBe(2)
expect(mockedBulkDelete).toHaveBeenCalledTimes(1)
expect(mockedBulkDelete).toHaveBeenCalledWith(['s1', 's2'], 'default')
await waitFor(() => expect($sessions.get()).toHaveLength(0))
expect($pinnedSessionIds.get()).toEqual([])
})

it('groups ids by owning profile so each profile is deleted against its own db', async () => {
const rows = [
storedSession({ id: 'a1', profile: 'default' }),
storedSession({ id: 'b1', profile: 'work' }),
storedSession({ id: 'a2', profile: 'default' })
]

let drained = false
mockedList.mockImplementation((limit, _min, _archived, _order, _profile, filter) => {
if (filter?.source) {
return Promise.resolve(emptyPage())
}

if (limit === 500 && !drained) {
drained = true

return Promise.resolve({ limit: 500, offset: 0, sessions: rows, total: rows.length })
}

return Promise.resolve(emptyPage())
})
mockedBulkDelete.mockImplementation((ids: string[]) => Promise.resolve({ deleted: ids.length, ok: true }))

const clear = await getClear()
await clear()

expect(mockedBulkDelete).toHaveBeenCalledWith(['a1', 'a2'], 'default')
expect(mockedBulkDelete).toHaveBeenCalledWith(['b1'], 'work')
})

it('is a no-op (no delete calls) when the scope is already empty', async () => {
mockedList.mockResolvedValue(emptyPage())

const clear = await getClear()
const removed = await clear()

expect(removed).toBe(0)
expect(mockedBulkDelete).not.toHaveBeenCalled()
})
})
76 changes: 75 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-list-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useRef } from 'react'

import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { BULK_DELETE_MAX_IDS, bulkDeleteSessions, getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
Expand All @@ -19,6 +19,7 @@ import {
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setCronSessions,
setMessagingPlatformTotals,
setMessagingSessions,
Expand All @@ -41,6 +42,12 @@ const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSI
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]

// Upper bound on the page-and-delete loop in clearAllSessions. Each pass clears
// up to BULK_DELETE_MAX_IDS rows, so this caps a single "Delete all" at ~500k
// chats — far past any real history — while guaranteeing the loop terminates if
// the backend ever stops actually deleting.
const SESSION_CLEAR_MAX_PAGES = 1_000

// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, the
// actively-viewed chat (its "working" flag clears a beat before the aggregator
Expand Down Expand Up @@ -198,6 +205,72 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
await refreshSessions()
}, [refreshSessions])

// Permanently delete every chat in the active profile scope — the same rows
// the recents list shows (non-archived; cron/messaging/subagent excluded).
// Pages the scope in <=500-id chunks and deletes each via the bulk endpoint
// until it's empty, so it clears the whole history rather than only the
// currently-loaded window, with no new backend route. Archived chats live in
// Settings and are intentionally left untouched. Returns the number removed.
const clearAllSessions = useCallback(async (): Promise<number> => {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope

let removed = 0

for (let page = 0; page < SESSION_CLEAR_MAX_PAGES; page++) {
const result = await listAllProfileSessions(BULK_DELETE_MAX_IDS, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})

if (result.sessions.length === 0) {
break
}

// Group by owning profile: each profile has its own state.db and the
// endpoint scopes to one profile per call, mirroring the single-session
// delete which routes by the row's own `profile`.
const idsByProfile = new Map<string, string[]>()

for (const session of result.sessions) {
const key = session.profile ?? 'default'
const ids = idsByProfile.get(key)

if (ids) {
ids.push(session.id)
} else {
idsByProfile.set(key, [session.id])
}
}

let deletedThisPage = 0

for (const [profile, ids] of idsByProfile) {
const { deleted } = await bulkDeleteSessions(ids, profile)
deletedThisPage += deleted

// Drop the rows + their pins optimistically so the sidebar empties as we
// page rather than snapping clear only at the closing refresh.
const goneIds = new Set(ids)
const gonePins = new Set(result.sessions.filter(s => goneIds.has(s.id)).map(sessionPinId))
setSessions(prev => prev.filter(s => !goneIds.has(s.id)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => !goneIds.has(id) && !gonePins.has(id)))
}

removed += deletedThisPage

// Nothing in this page actually deleted (every id was already gone, or the
// backend declined) — bail instead of re-fetching the same page forever.
if (deletedThisPage === 0) {
break
}
}

// Re-pull the authoritative list so totals/footer — and any chat created
// mid-clear — are accurate.
await refreshSessions()

return removed
}, [profileScope, refreshSessions])

// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
Expand All @@ -221,6 +294,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
}, [])

return {
clearAllSessions,
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
Expand Down
Loading
Loading