mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
f16b80362c
commit
99ffea6d00
10 changed files with 337 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -890,6 +890,12 @@ export const ja = defineLocale({
|
|||
messages: count => `${count} 件のメッセージ`,
|
||||
restored: '復元しました',
|
||||
deleteConfirm: title => `"${title}" を完全に削除しますか?この操作は元に戻せません。`,
|
||||
autoArchiveTitle: '古いチャットを自動アーカイブ',
|
||||
autoArchiveDesc:
|
||||
'しばらく操作していないチャットを自動的にアーカイブします。ピン留めしたチャットはアーカイブされず、削除もされません。アーカイブされたチャットはここに移動します。',
|
||||
autoArchiveDaysLabel: 'アーカイブまでの日数',
|
||||
autoArchiveDaysUnit: '日間操作なし',
|
||||
autoArchiveFailed: '自動アーカイブを更新できませんでした',
|
||||
defaultDirTitle: 'デフォルトのプロジェクトディレクトリ',
|
||||
defaultDirDesc:
|
||||
'別のフォルダーを選択しない限り、新しいセッションはこのフォルダーで開始します。未設定の場合はホームディレクトリが使用されます。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -863,6 +863,12 @@ export const zhHant = defineLocale({
|
|||
messages: count => `${count} 則訊息`,
|
||||
restored: '已還原',
|
||||
deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`,
|
||||
autoArchiveTitle: '自動封存閒置對話',
|
||||
autoArchiveDesc:
|
||||
'自動封存你一段時間未使用的對話。已釘選的對話永遠不會被封存,也不會刪除任何內容——封存的對話會移到這裡。',
|
||||
autoArchiveDaysLabel: '封存前',
|
||||
autoArchiveDaysUnit: '天無活動',
|
||||
autoArchiveFailed: '無法更新自動封存設定',
|
||||
defaultDirTitle: '預設專案目錄',
|
||||
defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
|
||||
defaultDirUpdated: '預設專案目錄已更新',
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,12 @@ export const zh: Translations = {
|
|||
messages: count => `${count} 条消息`,
|
||||
restored: '已恢复',
|
||||
deleteConfirm: title => `永久删除“${title}”?此操作无法撤销。`,
|
||||
autoArchiveTitle: '自动归档闲置会话',
|
||||
autoArchiveDesc:
|
||||
'自动归档你一段时间未使用的会话。已置顶的会话永远不会被归档,也不会删除任何内容——归档的会话会移动到这里。',
|
||||
autoArchiveDaysLabel: '归档前',
|
||||
autoArchiveDaysUnit: '天无活动',
|
||||
autoArchiveFailed: '无法更新自动归档设置',
|
||||
defaultDirTitle: '默认项目目录',
|
||||
defaultDirDesc: '新会话默认从此文件夹开始,除非你选择其他目录。留空则使用你的 home 目录。',
|
||||
defaultDirUpdated: '默认项目目录已更新',
|
||||
|
|
|
|||
95
apps/desktop/src/store/session-pin-sync.test.ts
Normal file
95
apps/desktop/src/store/session-pin-sync.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
75
apps/desktop/src/store/session-pin-sync.ts
Normal file
75
apps/desktop/src/store/session-pin-sync.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue