feat(desktop): auto-archive toggle + mirror sidebar pins to the backend

Sessions settings gain an "Auto-archive stale chats" toggle with a
configurable idle threshold, persisted to sessions.* in config.yaml so
the backend sweep owns the policy. Sidebar pins (localStorage) are
mirrored to the backend pinned flag at boot and on every change —
pre-existing pins migrate transparently — so the sweep can never hide a
pinned chat.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 10:17:40 -05:00
parent f16b80362c
commit 99ffea6d00
10 changed files with 337 additions and 3 deletions

View file

@ -54,6 +54,7 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@ -397,6 +398,10 @@ watchContributedPanes()
watchSessionTiles()
watchRouteTiles()
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()
// The main tab reads as its SESSION (the loaded title, "New session" on a
// fresh draft) — a stack of main + tiles is then just a row of session names.
// register() replaces same-id in place; the render fn is the shared constant

View file

@ -1,8 +1,15 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import {
deleteSession,
getHermesConfigRecord,
listAllProfileSessions,
saveHermesConfig,
setSessionArchived
} from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@ -10,11 +17,13 @@ import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import type { HermesConfigRecord, SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const DEFAULT_AUTO_ARCHIVE_DAYS = 3
const ARCHIVED_FETCH_LIMIT = 200
function workspaceLabel(cwd: null | string | undefined): string {
@ -114,6 +123,8 @@ export function SessionsSettings() {
<SettingsContent>
<DefaultProjectDirSetting />
<AutoArchiveSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@ -174,6 +185,112 @@ export function SessionsSettings() {
)
}
// Opt-in retention: soft-hide chats untouched for N days. The policy itself
// (last-activity sweep, pin exemption) lives in the backend
// (sessions.auto_archive in config.yaml + SessionDB.maybe_auto_archive); this
// just toggles the config keys, so CLI / gateway / Desktop all honour one
// setting. Pins are exempt on the backend, so pinned chats survive regardless.
function AutoArchiveSetting() {
const { t } = useI18n()
const s = t.settings.sessions
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [enabled, setEnabled] = useState(false)
const [days, setDays] = useState(DEFAULT_AUTO_ARCHIVE_DAYS)
useEffect(() => {
// Config REST is only reachable through the Electron bridge; skip in
// non-Electron contexts (tests/storybook) rather than throwing.
if (!window.hermesDesktop) {
return
}
let alive = true
void getHermesConfigRecord()
.then(record => {
if (!alive) {
return
}
const sessions = (record.sessions ?? {}) as Record<string, unknown>
const parsedDays = Number(sessions.auto_archive_days)
setConfig(record)
setEnabled(Boolean(sessions.auto_archive))
setDays(Number.isFinite(parsedDays) && parsedDays > 0 ? Math.round(parsedDays) : DEFAULT_AUTO_ARCHIVE_DAYS)
})
.catch(() => {
// Leave the control unmounted if config can't be read.
})
return () => {
alive = false
}
}, [])
const persist = useCallback(
async (autoArchive: boolean, archiveDays: number) => {
if (!config) {
return
}
const sessions = {
...((config.sessions ?? {}) as Record<string, unknown>),
auto_archive: autoArchive,
auto_archive_days: archiveDays
}
const updated = { ...config, sessions }
setConfig(updated)
try {
await saveHermesConfig(updated)
} catch (err) {
notifyError(err, s.autoArchiveFailed)
}
},
[config, s.autoArchiveFailed]
)
if (!config) {
return null
}
return (
<div className="mb-6">
<ToggleRow
checked={enabled}
description={s.autoArchiveDesc}
label={s.autoArchiveTitle}
onChange={on => {
setEnabled(on)
void persist(on, days)
}}
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={s.autoArchiveDaysLabel}
className="w-20"
min={1}
onBlur={() => void persist(true, days)}
onChange={e => setDays(Math.max(1, Math.round(Number(e.target.value) || 1)))}
type="number"
value={days}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.autoArchiveDaysUnit}
</span>
</div>
}
title={s.autoArchiveDaysLabel}
/>
)}
</div>
)
}
// Lets the user pin the default cwd for new sessions. Without this, packaged
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.

View file

@ -546,6 +546,19 @@ export function setSessionArchived(id: string, archived: boolean, profile?: stri
})
}
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
// display; this only feeds the backend policy.
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { pinned }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`

View file

@ -843,6 +843,12 @@ export const en: Translations = {
messages: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
restored: 'Restored',
deleteConfirm: title => `Permanently delete "${title}"? This cannot be undone.`,
autoArchiveTitle: 'Auto-archive stale chats',
autoArchiveDesc:
"Automatically archive chats you haven't touched in a while. Pinned chats are never archived, and nothing is deleted — archived chats just move here.",
autoArchiveDaysLabel: 'Archive after',
autoArchiveDaysUnit: 'days of inactivity',
autoArchiveFailed: 'Could not update auto-archive',
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',

View file

@ -890,6 +890,12 @@ export const ja = defineLocale({
messages: count => `${count} 件のメッセージ`,
restored: '復元しました',
deleteConfirm: title => `"${title}" を完全に削除しますか?この操作は元に戻せません。`,
autoArchiveTitle: '古いチャットを自動アーカイブ',
autoArchiveDesc:
'しばらく操作していないチャットを自動的にアーカイブします。ピン留めしたチャットはアーカイブされず、削除もされません。アーカイブされたチャットはここに移動します。',
autoArchiveDaysLabel: 'アーカイブまでの日数',
autoArchiveDaysUnit: '日間操作なし',
autoArchiveFailed: '自動アーカイブを更新できませんでした',
defaultDirTitle: 'デフォルトのプロジェクトディレクトリ',
defaultDirDesc:
'別のフォルダーを選択しない限り、新しいセッションはこのフォルダーで開始します。未設定の場合はホームディレクトリが使用されます。',

View file

@ -723,6 +723,11 @@ export interface Translations {
messages: (count: number) => string
restored: string
deleteConfirm: (title: string) => string
autoArchiveTitle: string
autoArchiveDesc: string
autoArchiveDaysLabel: string
autoArchiveDaysUnit: string
autoArchiveFailed: string
defaultDirTitle: string
defaultDirDesc: string
defaultDirUpdated: string

View file

@ -863,6 +863,12 @@ export const zhHant = defineLocale({
messages: count => `${count} 則訊息`,
restored: '已還原',
deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`,
autoArchiveTitle: '自動封存閒置對話',
autoArchiveDesc:
'自動封存你一段時間未使用的對話。已釘選的對話永遠不會被封存,也不會刪除任何內容——封存的對話會移到這裡。',
autoArchiveDaysLabel: '封存前',
autoArchiveDaysUnit: '天無活動',
autoArchiveFailed: '無法更新自動封存設定',
defaultDirTitle: '預設專案目錄',
defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
defaultDirUpdated: '預設專案目錄已更新',

View file

@ -1042,6 +1042,12 @@ export const zh: Translations = {
messages: count => `${count} 条消息`,
restored: '已恢复',
deleteConfirm: title => `永久删除“${title}”?此操作无法撤销。`,
autoArchiveTitle: '自动归档闲置会话',
autoArchiveDesc:
'自动归档你一段时间未使用的会话。已置顶的会话永远不会被归档,也不会删除任何内容——归档的会话会移动到这里。',
autoArchiveDaysLabel: '归档前',
autoArchiveDaysUnit: '天无活动',
autoArchiveFailed: '无法更新自动归档设置',
defaultDirTitle: '默认项目目录',
defaultDirDesc: '新会话默认从此文件夹开始,除非你选择其他目录。留空则使用你的 home 目录。',
defaultDirUpdated: '默认项目目录已更新',

View file

@ -0,0 +1,95 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
const patch = vi.fn<(id: string, pinned: boolean, profile?: null | string) => Promise<{ ok: boolean }>>(() =>
Promise.resolve({ ok: true })
)
vi.mock('@/hermes', () => ({
setSessionPinnedRemote: (id: string, pinned: boolean, profile?: null | string) => patch(id, pinned, profile)
}))
import { $pinnedSessionIds } from '@/store/layout'
import { $sessions } from '@/store/session'
import { watchSessionPins } from './session-pin-sync'
const row = (id: string, extra: Partial<SessionInfo> = {}): SessionInfo =>
({ id, message_count: 1, source: 'cli', started_at: 0, title: id, ...extra }) as SessionInfo
const flush = () => Promise.resolve()
beforeAll(() => {
;(globalThis as { window?: unknown }).window ??= {}
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {}
// Attach the listeners once — module state is process-global.
watchSessionPins()
})
beforeEach(() => {
$sessions.set([])
$pinnedSessionIds.set([])
patch.mockClear()
})
afterEach(() => {
$sessions.set([])
$pinnedSessionIds.set([])
})
describe('watchSessionPins', () => {
it('mirrors a new pin as pinned=true with the row profile', async () => {
$sessions.set([row('a', { profile: 'work' })])
$pinnedSessionIds.set(['a'])
await flush()
expect(patch).toHaveBeenCalledWith('a', true, 'work')
})
it('mirrors an unpin as pinned=false', async () => {
$sessions.set([row('b')])
$pinnedSessionIds.set(['b'])
await flush()
patch.mockClear()
$pinnedSessionIds.set([])
await flush()
expect(patch).toHaveBeenCalledWith('b', false, undefined)
})
it('defers a pin whose row is not loaded, then flushes once it appears', async () => {
$pinnedSessionIds.set(['c'])
await flush()
// No row yet -> nothing sent.
expect(patch).not.toHaveBeenCalled()
$sessions.set([row('c', { profile: 'p2' })])
await flush()
expect(patch).toHaveBeenCalledWith('c', true, 'p2')
})
it('matches a pin id against the lineage root', async () => {
// pin id is the lineage root; the live row carries it as _lineage_root_id.
$sessions.set([row('tip', { _lineage_root_id: 'root' })])
$pinnedSessionIds.set(['root'])
await flush()
expect(patch).toHaveBeenCalledWith('root', true, undefined)
})
it('does not re-PATCH an already-mirrored pin on unrelated session updates', async () => {
$sessions.set([row('d')])
$pinnedSessionIds.set(['d'])
await flush()
patch.mockClear()
// A session-list refresh that doesn't change the pinned set.
$sessions.set([row('d'), row('e')])
await flush()
expect(patch).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,75 @@
/**
* Mirror the sidebar's localStorage pins into the backend "keep" flag.
*
* Pins live in `$pinnedSessionIds` (localStorage) and drive the sidebar UI.
* The `sessions.auto_archive` sweep, however, runs backend-side and is blind to
* localStorage so without this bridge it could hide a pinned chat. This
* watcher PATCHes `pinned` on the session REST endpoint whenever the pinned set
* changes, and re-asserts the whole current set at boot, which transparently
* migrates pre-existing pins (no flag, no user action the sweep just starts
* honouring them). It never touches the sidebar's own display; localStorage
* stays the source of truth there.
*/
import { setSessionPinnedRemote } from '@/hermes'
import { $pinnedSessionIds } from '@/store/layout'
import { $sessions, sessionMatchesStoredId } from '@/store/session'
// pin ids we've successfully PATCHed pinned=true this session.
const mirrored = new Set<string>()
// pin ids awaiting their row so we can resolve the owning profile before PATCH.
const pending = new Set<string>()
function profileFor(pinId: string): null | string | undefined {
return $sessions.get().find(row => sessionMatchesStoredId(row, pinId))?.profile
}
function reconcile(): void {
// Config/session REST is only reachable through the Electron bridge.
if (!window.hermesDesktop) {
return
}
const current = new Set($pinnedSessionIds.get())
// Unpinned: anything we were tracking that's no longer in the set.
for (const id of [...mirrored, ...pending]) {
if (!current.has(id)) {
mirrored.delete(id)
pending.delete(id)
void setSessionPinnedRemote(id, false, profileFor(id)).catch(() => {})
}
}
// Newly pinned: hold until we can resolve the row (for its profile).
for (const id of current) {
if (!mirrored.has(id)) {
pending.add(id)
}
}
// Flush whatever we can resolve now; unresolved ids (row not loaded yet)
// retry on the next $sessions change.
for (const id of [...pending]) {
const row = $sessions.get().find(entry => sessionMatchesStoredId(entry, id))
if (!row) {
continue
}
pending.delete(id)
mirrored.add(id)
void setSessionPinnedRemote(id, true, row.profile).catch(() => {
// Let a later reconcile retry the mirror.
mirrored.delete(id)
pending.add(id)
})
}
}
// Sync once, then re-sync on pin-set and session-list changes. Call once per app.
export function watchSessionPins(): void {
reconcile()
$pinnedSessionIds.listen(reconcile)
$sessions.listen(reconcile)
}