diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 0faf5e5ccb06..3c245478bb09 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index 9102014b8ed8..73cfce113f90 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -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() { + + (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 + 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), + 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 ( +
+ { + setEnabled(on) + void persist(on, days) + }} + /> + {enabled && ( + + void persist(true, days)} + onChange={e => setDays(Math.max(1, Math.round(Number(e.target.value) || 1)))} + type="number" + value={days} + /> + + {s.autoArchiveDaysUnit} + +
+ } + title={s.autoArchiveDaysLabel} + /> + )} + + ) +} + // 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. diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 38b8e2fb85e9..1ac35a863fe5 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { return window.hermesDesktop.api({ path: `/api/sessions/search?q=${encodeURIComponent(query)}` diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 68314a5da203..c7397784cef5 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 590cd92d0669..b6ef78614131 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -890,6 +890,12 @@ export const ja = defineLocale({ messages: count => `${count} 件のメッセージ`, restored: '復元しました', deleteConfirm: title => `"${title}" を完全に削除しますか?この操作は元に戻せません。`, + autoArchiveTitle: '古いチャットを自動アーカイブ', + autoArchiveDesc: + 'しばらく操作していないチャットを自動的にアーカイブします。ピン留めしたチャットはアーカイブされず、削除もされません。アーカイブされたチャットはここに移動します。', + autoArchiveDaysLabel: 'アーカイブまでの日数', + autoArchiveDaysUnit: '日間操作なし', + autoArchiveFailed: '自動アーカイブを更新できませんでした', defaultDirTitle: 'デフォルトのプロジェクトディレクトリ', defaultDirDesc: '別のフォルダーを選択しない限り、新しいセッションはこのフォルダーで開始します。未設定の場合はホームディレクトリが使用されます。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 51bdbb263ba6..3957d668a824 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 44b9145baf25..4c07372af168 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -863,6 +863,12 @@ export const zhHant = defineLocale({ messages: count => `${count} 則訊息`, restored: '已還原', deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`, + autoArchiveTitle: '自動封存閒置對話', + autoArchiveDesc: + '自動封存你一段時間未使用的對話。已釘選的對話永遠不會被封存,也不會刪除任何內容——封存的對話會移到這裡。', + autoArchiveDaysLabel: '封存前', + autoArchiveDaysUnit: '天無活動', + autoArchiveFailed: '無法更新自動封存設定', defaultDirTitle: '預設專案目錄', defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。', defaultDirUpdated: '預設專案目錄已更新', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 8b2bf1efecea..0f2a1ba36295 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1042,6 +1042,12 @@ export const zh: Translations = { messages: count => `${count} 条消息`, restored: '已恢复', deleteConfirm: title => `永久删除“${title}”?此操作无法撤销。`, + autoArchiveTitle: '自动归档闲置会话', + autoArchiveDesc: + '自动归档你一段时间未使用的会话。已置顶的会话永远不会被归档,也不会删除任何内容——归档的会话会移动到这里。', + autoArchiveDaysLabel: '归档前', + autoArchiveDaysUnit: '天无活动', + autoArchiveFailed: '无法更新自动归档设置', defaultDirTitle: '默认项目目录', defaultDirDesc: '新会话默认从此文件夹开始,除非你选择其他目录。留空则使用你的 home 目录。', defaultDirUpdated: '默认项目目录已更新', diff --git a/apps/desktop/src/store/session-pin-sync.test.ts b/apps/desktop/src/store/session-pin-sync.test.ts new file mode 100644 index 000000000000..e6613d8658c4 --- /dev/null +++ b/apps/desktop/src/store/session-pin-sync.test.ts @@ -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 => + ({ 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() + }) +}) diff --git a/apps/desktop/src/store/session-pin-sync.ts b/apps/desktop/src/store/session-pin-sync.ts new file mode 100644 index 000000000000..35235e6dc64f --- /dev/null +++ b/apps/desktop/src/store/session-pin-sync.ts @@ -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() +// pin ids awaiting their row so we can resolve the owning profile before PATCH. +const pending = new Set() + +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) +}